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:
1 parent
bb4ca6bfa9
commit
59bcbdfe3c
12 files changed
+419
-14
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Android 17 (API 37): local-network access is blocked by default.
|
||||
Required only for LAN gateway connections (192.168/10/172.16 etc.);
|
||||
loopback (127.0.0.1) and public remote hosts are exempt. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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<ConnectionProfile> = 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<ConnectUiState> = _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()) {
|
||||
|
||||
@@ -255,6 +255,7 @@
|
||||
<string name="connect_server_url_label">Server URL</string>
|
||||
<string name="connect_placeholder_server_url">https://host:9119/ or http://127.0.0.1:9119/</string>
|
||||
<string name="connect_error_url_invalid">Enter a valid server URL (https:// or http://)</string>
|
||||
<string name="connect_error_lan_permission_denied">Local network access denied. Grant the Local network permission in Settings, or connect to a remote gateway URL instead.</string>
|
||||
<string name="action_connect">Connect</string>
|
||||
|
||||
<!-- Kanban Screen -->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Generated
+3
-3
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user