diff --git a/AGENTS.md b/AGENTS.md index a302c8b9..cf4ba778 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ This complements [README.md](README.md) (for humans) with agent-focused context. dashboard's REST API and WebSocket TUI Gateway (JSON-RPC 2.0) over a trusted LAN. - **Package:** `com.m57.hermescontrol` -- **Min SDK 26 / Target SDK 36 / Compile SDK 36** +- **Min SDK 26 / Target SDK 37 / Compile SDK 37** - **Kotlin 2.4.10**, KSP 2.3.10 (standalone versioning, NOT `kotlinVersion-kspVersion`) - **Jetpack Compose**, Room 2.7.x, Navigation3, OkHttp WebSocket, Retrofit, Kotlinx Serialization - **Auth:** `EncryptedSharedPreferences` (AES256-GCM), Bearer token (REST) + `?token=` (WS) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0b9a301e..55d12195 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,11 +14,12 @@ android { lint { disable += "MissingTranslation" } - compileSdk = 36 + compileSdk = 37 + buildToolsVersion = "37.0.0" defaultConfig { applicationId = "com.m57.hermescontrol" minSdk = 26 - targetSdk = 36 + targetSdk = 37 // Version overrides passed from CI via -PversionName / -PversionCode // Falls back to defaults for local development. versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bfb9edb9..1fe12882 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/LocalNetwork.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/LocalNetwork.kt new file mode 100644 index 00000000..200e8617 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/LocalNetwork.kt @@ -0,0 +1,57 @@ +package com.m57.hermescontrol.data.remote + +import java.net.InetAddress +import java.net.URI + +/** + * Android 17 (API 37) gates local-network access behind the + * `ACCESS_LOCAL_NETWORK` runtime permission. The platform's own definition of + * "local network" excludes loopback (127/8) and covers private RFC1918 ranges, + * link-local (169.254/16), and CGNAT (100.64/10). + * + * Connections to loopback (the default `127.0.0.1:9119` gateway on-device) and + * to public/remote hosts do NOT require the permission — only true LAN hosts do. + * + * @return true when [host] resolves to an address Android 17 gates behind local-network permission. + */ +fun isLocalNetworkHost(host: String): Boolean { + val addr = + runCatching { InetAddress.getByName(host) }.getOrNull() ?: return false + if (addr.isLoopbackAddress) return false + val raw = addr.address + if (raw.size != 4) return false // IPv6: not LAN-gated by the legacy ranges below + val a = raw[0].toInt() and 0xFF + val b = raw[1].toInt() and 0xFF + return when { + a == 10 -> true // 10.0.0.0/8 + a == 172 && b in 16..31 -> true // 172.16.0.0/12 + a == 192 && b == 168 -> true // 192.168.0.0/16 + a == 169 && b == 254 -> true // 169.254.0.0/16 link-local + a == 100 && b in 64..127 -> true // 100.64.0.0/10 CGNAT + else -> false + } +} + +/** + * Whether connecting to [baseUrl] requires the `ACCESS_LOCAL_NETWORK` permission + * on Android 17+. Extracted as a pure function so the connect flow can be gated + * (and unit-tested) without a real permission check. + * + * Uses [java.net.URI] rather than [android.net.Uri] because the latter is a + * non-functional stub under plain JVM unit tests (its `host` returns null). + */ +fun needsLocalNetworkPermission(baseUrl: String): Boolean { + val host = runCatching { URI(baseUrl).host }.getOrNull() ?: return false + return isLocalNetworkHost(host) +} + +/** + * Gate decision for the connect flow: on Android 17+ (API 37) a LAN gateway host + * requires the `ACCESS_LOCAL_NETWORK` runtime permission; earlier APIs and + * loopback/public hosts do not. Pure so it can be unit-tested without mocking + * [android.os.Build.VERSION.SDK_INT]. + */ +fun requiresLocalNetworkPermission( + sdkInt: Int, + baseUrl: String, +): Boolean = sdkInt >= 37 && needsLocalNetworkPermission(baseUrl) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt index bddc876f..22c71de5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectScreen.kt @@ -1,6 +1,8 @@ package com.m57.hermescontrol.ui.connect import android.app.Application +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -75,6 +77,19 @@ fun ConnectScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val lanPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> viewModel.onLanPermissionResult(granted) }, + ) + + LaunchedEffect(state.lanPermissionNeeded) { + if (state.lanPermissionNeeded) { + lanPermissionLauncher.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK) + } + } + LaunchedEffect(state.connectionSuccess) { if (state.connectionSuccess) { onConnected() @@ -219,7 +234,7 @@ fun ConnectScreen( } Button( - onClick = viewModel::connect, + onClick = { viewModel.requestConnect(context) }, enabled = !state.isConnecting, modifier = Modifier diff --git a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt index 9c7722cc..a312bd6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/connect/ConnectViewModel.kt @@ -1,6 +1,10 @@ package com.m57.hermescontrol.ui.connect import android.app.Application +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -15,6 +19,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission import com.m57.hermescontrol.data.remote.safeApiCall import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -29,6 +34,7 @@ data class ConnectUiState( val isConnecting: Boolean = false, val connectionSuccess: Boolean = false, val errorMessage: String? = null, + val lanPermissionNeeded: Boolean = false, val profileName: String = "", val saveProfile: Boolean = false, val profiles: List = emptyList(), @@ -40,6 +46,15 @@ data class ConnectUiState( class ConnectViewModel( private val app: Application, ) : ViewModel() { + /** + * Source of the device's API level. Defaults to the real [Build.VERSION.SDK_INT] + * but is package-visible so unit tests can exercise the Android 17 (API 37) + * local-network gate without mocking the final static [Build.VERSION.SDK_INT] + * field (which MockK cannot intercept in plain JVM tests). + */ + @Suppress("MemberVisibilityCanBePrivate") + internal var sdkVersion: Int = Build.VERSION.SDK_INT + private val _uiState = MutableStateFlow(ConnectUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -143,6 +158,43 @@ class ConnectViewModel( _uiState.update { it.copy(baseUrl = trimmed, transportWarning = warning, errorMessage = null) } } + /** + * Entry point for the connect button. On Android 17 (API 37) a LAN gateway + * host requires the `ACCESS_LOCAL_NETWORK` runtime permission. If it's + * needed and not yet granted, we surface [ConnectUiState.lanPermissionNeeded] + * so the screen can launch the system prompt instead of dialing out. Loopback + * (127.0.0.1) and public remote hosts skip this entirely. + */ + fun requestConnect(context: Context) { + val baseUrl = _uiState.value.baseUrl + val needsPermission = + requiresLocalNetworkPermission(sdkVersion, baseUrl) && + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_LOCAL_NETWORK, + ) != PackageManager.PERMISSION_GRANTED + if (needsPermission) { + _uiState.update { it.copy(lanPermissionNeeded = true, errorMessage = null) } + return + } + connect() + } + + /** Called by the screen after the user responds to the local-network prompt. */ + fun onLanPermissionResult(granted: Boolean) { + if (granted) { + _uiState.update { it.copy(lanPermissionNeeded = false) } + connect() + } else { + _uiState.update { + it.copy( + lanPermissionNeeded = false, + errorMessage = app.getString(R.string.connect_error_lan_permission_denied), + ) + } + } + } + fun connect() { val state = _uiState.value if (state.token.isBlank()) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd904edd..2f2d3c8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -255,6 +255,7 @@ Server URL https://host:9119/ or http://127.0.0.1:9119/ Enter a valid server URL (https:// or http://) + Local network access denied. Grant the Local network permission in Settings, or connect to a remote gateway URL instead. Connect diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/LocalNetworkTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/LocalNetworkTest.kt new file mode 100644 index 00000000..30a979d5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/LocalNetworkTest.kt @@ -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("")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectPermissionGateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectPermissionGateTest.kt new file mode 100644 index 00000000..7aa2eb6f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/connect/ConnectPermissionGateTest.kt @@ -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(relaxed = true) + private val mockContext = mockk(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()) } 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) + } +} diff --git a/flake.lock b/flake.lock index d24f7460..e35cb6a4 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1784497964, - "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "lastModified": 1787135253, + "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 14906a38..a4b5857e 100644 --- a/flake.nix +++ b/flake.nix @@ -21,18 +21,21 @@ }; }; - buildToolsVersion = "36.0.0"; + buildToolsVersion = "37.0.0"; androidSdk = (pkgs.androidenv.composeAndroidPackages { - # Command-line & platform tools - cmdLineToolsVersion = "11.0"; - platformToolsVersion = "36.0.0"; + # Toolchain pinned to the latest available on nixos-unstable (rev ffb3c9b7) + cmdLineToolsVersion = "22.0"; + platformToolsVersion = "37.0.1"; # Build tools buildToolsVersions = [buildToolsVersion]; - # Target platforms (36 required by AGP 9.0.1 / compileSdk 36) - platformVersions = ["36"]; + # Target platforms: 37.0 provides both the SDK platform for + # compileSdk 37 AND the published android-37.0 system image the + # emulator needs to boot an AVD (no 37.1 image exists yet). + # compileSdk/targetSdk in app/build.gradle.kts stay 37. + platformVersions = ["37.0"]; # Emulator + system images for local AVD testing includeEmulator = true; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0ac261c..546fe530 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ ksp = "2.3.11" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" leakcanary = "2.14" -okhttp = "5.4.0" +okhttp = "5.5.0" retrofit = "3.0.0" securityCrypto = "1.0.0" mockk = "1.14.11"