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

+1 -1
View File
@@ -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)
+3 -2
View File
@@ -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
+4
View File
@@ -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()) {
+1
View File
@@ -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
View File
@@ -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": {
+9 -6
View File
@@ -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;
+1 -1
View File
@@ -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"