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,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()) {