build: Android 17 (API 37) + okhttp 5.5.0 + LAN local-network permission gate (#941)

* build: compileSdk 36 -> 37 + okhttp 5.4.0 -> 5.5.0

Take okhttp 5.5.0 (folds in dependabot PR #940) which hard-requires
compileSdk 37 via AAR metadata. targetSdk stays 36 for now.

* build: targetSdk 36 -> 37 (Android 17) + ACCESS_LOCAL_NETWORK gating

Opt into Android 17 runtime behavior. The app connects to a gateway
(default 127.0.0.1:9119, plus LAN + remote), so targeting 37 forces the
new ACCESS_LOCAL_NETWORK permission for LAN hosts.

- AndroidManifest: declare ACCESS_LOCAL_NETWORK (harmless below API 37)
- LocalNetwork.kt: pure detector + needsLocalNetworkPermission(baseUrl)
  (loopback + public remote exempt; RFC1918/link-local/CGNAT gated)
- ConnectViewModel: requestConnect() gates on the permission; screen
  launches the prompt; onLanPermissionResult proceeds or shows a clear error
- flake.nix: android platform 36 -> 37
- Tests: LocalNetworkTest + ConnectPermissionGateTest (SDK_INT / permission
  branching mocked, no device needed)
- Task 8 finding: assistant services (VoiceInteraction*/Recognition) and the
  remoteMessaging ChatNotificationService do NOT hit the Android 17 mic/
  while-in-use foreground hardening, so no change required.

* fix: ktlint + @Composable LocalContext hoist (was blocking CI)

- ConnectScreen: hoist LocalContext.current out of onClick lambda
  (calling a @Composable inside a non-composable lambda broke compileDebugKotlin)
- ktlint --format: multiline-expression-wrapping, function-signature,
  no-empty-first-line-in-class-body, no-unused-imports
- LocalNetwork.kt: merge consecutive KDocs (no-consecutive-comments)

flake.nix buildToolsVersion/platformToolsVersion intentionally stay 36.0.0
— they are decoupled from compileSdk; only platformVersions needs 37.

* fix: suspend mock (coEvery) + flake toolchain pinned to 37.0.0

- ConnectPermissionGateTest: getStatus() is suspend → use coEvery (was
  breaking compileDebugUnitTestKotlin)
- flake.nix: buildToolsVersion/platformToolsVersion/cmdLineToolsVersion
  pinned to 37.0.0 (verified against pinned nixpkgs 241313f — resolves to
  android-sdk-*-37.0.0 derivations; platformVersions already 37)

* fix(flake): revert cmdLineToolsVersion to 11.0 (not API-aligned)

cmdline-tools uses its own versioning (latest = 21.0, never 37.0.0) —
nixpkgs rejects 37.0.0 with 'version missing'. build-tools + platform-tools
stay 37.0.0 (verified resolve to android-sdk-*-37.0.0). flake now evals
clean against pinned nixpkgs 241313f.

* flake: track nixos-unstable + pin toolchain to latest (cmdline 22.0, platform-tools 37.0.1, build-tools 37.0.0, platform 37.1)

- inputs.nixpkgs -> nixos-unstable, flake.lock bumped to rev ffb3c9b7 (2026-08-19)
- cmdLineToolsVersion 11.0 -> 22.0 (own versioning, not API-aligned; v22_0 confirms)
- platformToolsVersion 37.0.0 -> 37.0.1
- buildToolsVersion stays 37.0.0
- platformVersions 37 -> 37.1
- verified: nix flake check --no-build -> all checks passed on hyari

* fix: unit tests — Uri stub + Build.SDK_INT final-field mock

- LocalNetwork.kt: needsLocalNetworkPermission uses java.net.URI instead of
  android.net.Uri (latter is a non-functional stub in JVM unit tests, its host
  returns null -> gate always false). Added pure requiresLocalNetworkPermission(sdkInt, baseUrl).
- ConnectViewModel: inject sdkVersion (package-private) instead of reading the
  final static Build.VERSION.SDK_INT directly; gate via requiresLocalNetworkPermission.
- ConnectPermissionGateTest: drop mockkStatic(Build.VERSION) (MockKException on
  final static field, and against repo convention); inject sdkVersion; add pure
  gate-decision matrix. Fixes 7 red tests.

* fix(test): remove illegal ':' from test function names (compile error)

* fix(test): order getString mocks so specific resources win over wildcard

* fix(flake): include android-37.0 system image so the emulator can boot

Google only publishes the android-37.0 system image (no 37.1 image exists
yet). With platformVersions=["37.1"] composeAndroidPackages found no matching
system image, leaving system-images/ empty and every AVD FATALing with
'Broken AVD system path'. Adding 37.0 pulls the published image so an AVD boots
(verified: hermes37 AVD boots Android 17 / API 37 on hyari). compileSdk/targetSdk
stay 37.

Also bump AGENTS.md SDK note 36 -> 37 (was stale).

* fix(flake): platformVersions 37.1 -> 37.0

Drop 37.1; 37.0 provides both the SDK platform for compileSdk 37 and the
published android-37.0 system image the emulator needs.

* fix(flake): remove stray tool-scratch line leaked into flake.nix (line 116)

A terminal tool's /tmp scratch rename line got appended to flake.nix, making
it invalid Nix. Removed; flake now parses + 'nix flake check' passes on hyari.

* fix(build): pin buildToolsVersion 37.0.0 to match nix SDK

AGP defaulted build-tools to 36.0.0, which isn't in the nix-managed SDK
(only 37.0.0 is provisioned). Local ./gradlew builds then tried to *install*
36.0.0 into the immutable /nix/store and failed. Pin 37.0.0 so the local
nix devShell builds. CI uses its own SDK so unaffected.

---------

Co-authored-by: Hy4ri <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57andHy4ri authored and GitHub committed 2026-08-20 14:35:42 +03:00
1 parent bb4ca6bfa9
commit 59bcbdfe3c
12 files changed
+419 -14

No files matched your search

@@ -0,0 +1,50 @@
package com.m57.hermescontrol.data.remote
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class LocalNetworkTest {
@Test
fun `loopback host is exempt`() {
assertFalse(isLocalNetworkHost("127.0.0.1"))
assertFalse(isLocalNetworkHost("localhost"))
assertFalse(isLocalNetworkHost("::1"))
}
@Test
fun `private rfc1918 ranges require permission`() {
assertTrue(isLocalNetworkHost("192.168.1.50"))
assertTrue(isLocalNetworkHost("10.0.0.5"))
assertTrue(isLocalNetworkHost("10.255.255.254"))
assertTrue(isLocalNetworkHost("172.16.0.1"))
assertTrue(isLocalNetworkHost("172.31.255.255"))
}
@Test
fun `link-local and cgnat require permission`() {
assertTrue(isLocalNetworkHost("169.254.0.1"))
assertTrue(isLocalNetworkHost("100.64.0.1"))
assertTrue(isLocalNetworkHost("100.127.255.254"))
}
@Test
fun `public remote hosts are exempt`() {
assertFalse(isLocalNetworkHost("8.8.8.8"))
assertFalse(isLocalNetworkHost("1.1.1.1"))
}
@Test
fun `needsLocalNetworkPermission gates by base url host`() {
assertFalse(needsLocalNetworkPermission("http://127.0.0.1:9119"))
assertFalse(needsLocalNetworkPermission("https://gateway.example.com"))
assertTrue(needsLocalNetworkPermission("http://192.168.1.50:9119"))
assertTrue(needsLocalNetworkPermission("https://10.0.0.5:9119/api"))
}
@Test
fun `malformed base url does not require permission`() {
assertFalse(needsLocalNetworkPermission("not-a-url"))
assertFalse(needsLocalNetworkPermission(""))
}
}
@@ -0,0 +1,222 @@
package com.m57.hermescontrol.ui.connect
import android.app.Application
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
/**
* Tests the Android 17 (API 37) ACCESS_LOCAL_NETWORK gate.
*
* The gate decision itself is a pure function [requiresLocalNetworkPermission]
* (covered across the SDK/host matrix below, no mocking needed). The ViewModel
* integration tests inject the device API level via [ConnectViewModel.sdkVersion]
* instead of mocking the final static [android.os.Build.VERSION.SDK_INT] field,
* which MockK cannot intercept in plain JVM unit tests.
*
* The runtime permission check ([ContextCompat.checkSelfPermission]) is mocked
* statically, which is safe for a static *method* (unlike the SDK_INT field).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ConnectPermissionGateTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApp = mockk<Application>(relaxed = true)
private val mockContext = mockk<Context>(relaxed = true)
private lateinit var mockApiService: HermesApiService
private var permissionGranted = false
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
MockKAnnotations.init(this)
mockkObject(AuthManager)
mockkObject(ApiClient)
mockkStatic(ContextCompat::class)
mockApiService = mockk()
every { ApiClient.hermesApi } returns mockApiService
every { ApiClient.createTempService(any(), any()) } returns mockApiService
every { ApiClient.rebuild() } returns Unit
every { AuthManager.getToken() } returns "tok"
every { AuthManager.getBaseUrl() } returns "http://192.168.1.50:9119/"
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every { AuthManager.endpoint() } answers {
ServerEndpoint.parse("http://192.168.1.50:9119/", CleartextPolicy.ALLOW_WITH_WARNING)
}
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
every { AuthManager.getProfileToken(any()) } returns null
every { AuthManager.setProfileToken(any(), any()) } returns Unit
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.setSelectedProfileId(any()) } returns Unit
every { AuthManager.ensureDefaultSelected() } returns Unit
every { mockApp.getString(any<Int>()) } returns ""
every { mockApp.getString(R.string.connect_error_token_required) } returns "Token is required"
every { mockApp.getString(R.string.connect_error_lan_permission_denied) } returns "LAN denied"
every {
ContextCompat.checkSelfPermission(
mockContext,
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
)
} answers { if (permissionGranted) PackageManager.PERMISSION_GRANTED else PackageManager.PERMISSION_DENIED }
// Successful status probe → connect() proceeds to persist.
coEvery { mockApiService.getStatus() } returns
Response.success(
com.m57.hermescontrol.data.model.StatusResponse(
version = "test",
gateway_running = true,
active_sessions = 0,
auth_required = false,
),
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
// --- pure gate decision matrix (no mocking) ---
@Test
fun `requiresLocalNetworkPermission API 37 LAN host needs permission`() {
assertTrue(requiresLocalNetworkPermission(37, "http://192.168.1.50:9119"))
}
@Test
fun `requiresLocalNetworkPermission below API 37 LAN host does not need permission`() {
assertFalse(requiresLocalNetworkPermission(36, "http://192.168.1.50:9119"))
}
@Test
fun `requiresLocalNetworkPermission API 37 loopback host does not need permission`() {
assertFalse(requiresLocalNetworkPermission(37, "http://127.0.0.1:9119"))
}
@Test
fun `requiresLocalNetworkPermission API 37 public host does not need permission`() {
assertFalse(requiresLocalNetworkPermission(37, "https://gateway.example.com"))
}
// --- ViewModel integration (API level injected via sdkVersion) ---
@Test
fun `API 37 + LAN host + no permission surfaces lanPermissionNeeded`() =
runTest {
permissionGranted = false
val vm = ConnectViewModel(mockApp)
vm.sdkVersion = 37
vm.requestConnect(mockContext)
advanceUntilIdle()
assertTrue(vm.uiState.value.lanPermissionNeeded)
}
@Test
fun `API 37 + LAN host + granted permission connects directly`() =
runTest {
permissionGranted = true
val vm = ConnectViewModel(mockApp)
vm.sdkVersion = 37
vm.requestConnect(mockContext)
advanceUntilIdle()
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertTrue(vm.uiState.value.connectionSuccess)
}
@Test
fun `API 37 + loopback host connects without permission prompt`() =
runTest {
permissionGranted = false
every { AuthManager.getBaseUrl() } returns "http://127.0.0.1:9119/"
every { AuthManager.endpoint() } answers {
ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING)
}
val vm = ConnectViewModel(mockApp)
vm.sdkVersion = 37
vm.requestConnect(mockContext)
advanceUntilIdle()
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertTrue(vm.uiState.value.connectionSuccess)
}
@Test
fun `below API 37 LAN host connects without permission prompt`() =
runTest {
permissionGranted = false
val vm = ConnectViewModel(mockApp)
vm.sdkVersion = 36
vm.requestConnect(mockContext)
advanceUntilIdle()
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertTrue(vm.uiState.value.connectionSuccess)
}
@Test
fun `denied permission sets lanPermissionNeeded false and error message`() =
runTest {
permissionGranted = false
val vm = ConnectViewModel(mockApp)
vm.sdkVersion = 37
vm.requestConnect(mockContext)
advanceUntilIdle()
assertTrue(vm.uiState.value.lanPermissionNeeded)
// User denies → launcher reports false
vm.onLanPermissionResult(false)
advanceUntilIdle()
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals("LAN denied", vm.uiState.value.errorMessage)
assertFalse(vm.uiState.value.connectionSuccess)
}
@Test
fun `granted after prompt connects`() =
runTest {
permissionGranted = false
val vm = ConnectViewModel(mockApp)
vm.sdkVersion = 37
vm.requestConnect(mockContext)
advanceUntilIdle()
assertTrue(vm.uiState.value.lanPermissionNeeded)
// User grants → launcher reports true → connect proceeds
permissionGranted = true
vm.onLanPermissionResult(true)
advanceUntilIdle()
assertTrue(vm.uiState.value.connectionSuccess)
}
}