Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt
T
irisandClaude Opus 5 9f54a80ca4 Tell the difference between a blocked app and a missing server
Two findings from dev-updater's session reading this codebase, both checked
against the code here before acting on them, and both real.

**A denied local-network permission was invisible.** The manifest requests
ACCESS_LOCAL_NETWORK and MainActivity asks for it, but nothing ever checked
whether it was granted -- and on Android 17 a denial is indistinguishable
from an unreachable server at the socket, because the OS simply drops the
traffic. So every screen would have shown "is ai-server running, and is
this device able to reach that address (WireGuard up)?", blaming two things
that were both fine.

Stated once at the root as a standing condition rather than appended to
each failure it might have caused: it is not a property of any one request,
and repeating it per error is how a message ends up saying the same thing
twice, which this app has already done once today.

**The Keystore read path was creating keys.** `unseal` called the
get-or-create key function, so a sealed token whose key had been lost -- a
device reset, or the app's data restored onto a device the key cannot
travel to -- generated a fresh key, then failed to decrypt with it, leaving
a key nothing had ever sealed with. The behaviour was already right by
accident (it fails soft to "not enrolled"), but the read side now asks for
the key without making one, which is what it meant all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
2026-08-28 13:19:58 -04:00

133 lines
4.7 KiB
Kotlin

package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
/**
* One `when` rather than a navigation library: four screens, with the list as the root and the back
* button the only other way between them.
*/
private sealed class Screen {
data object SessionList : Screen()
data class Session(val summary: SessionSummary) : Screen()
data object Spawn : Screen()
data object Usage : Screen()
data object Models : Screen()
data object Setups : Screen()
data object Settings : Screen()
}
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity),
* re-reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*/
@Composable
fun AppRoot(settingsVersion: Int) {
val context = LocalContext.current
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.SessionList) }
// Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list.
var reloadToken by remember { mutableIntStateOf(0) }
// A standing condition rather than a per-request failure, so it is
// stated once here instead of appended to every error that might be
// caused by it. Without this the app is simply unreachable and every
// screen blames the server or the tunnel for it.
if (!localNetworkAllowed(context)) {
Text(
"This app is not allowed to reach local network addresses, so it cannot " +
"connect to the backend at all. Grant \"local network\" in Android's app " +
"settings; until then every screen here will look like the server is down.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
}
val current = settings
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR
// path lands in MainActivity and recomposes from the top.
SettingsScreen(
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.SessionList
},
onBack = null,
)
return
}
// The one way back, whichever screen is showing and whether it was
// reached by the system back gesture or a screen's own Back button.
// Every leaf screen can have changed something the list shows, so it
// always refetches.
val goToList = {
reloadToken++
screen = Screen.SessionList
}
if (screen !is Screen.SessionList) {
BackHandler(onBack = goToList)
}
when (val here = screen) {
is Screen.SessionList ->
SessionListScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onUsage = { screen = Screen.Usage },
onModels = { screen = Screen.Models },
onSetups = { screen = Screen.Setups },
onSettings = { screen = Screen.Settings },
)
is Screen.Session ->
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToList,
)
is Screen.Spawn ->
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToList,
)
is Screen.Usage -> UsageScreen(settings = current, onBack = goToList)
is Screen.Models -> ModelsScreen(settings = current, onBack = goToList)
is Screen.Setups -> SetupsScreen(settings = current, onBack = goToList)
is Screen.Settings ->
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToList()
},
onBack = goToList,
)
}
}