Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS

Compose app mirroring local-updater's stack (single :androidApp module,
pinned CA, HttpURLConnection transport) plus what this app needs on top:
a bearer token sealed with an Android Keystore AES-GCM key, an
aiapp://enroll intent filter so scanning the server's terminal QR with
the stock camera enrolls the phone with no QR library, an SSE client
that resumes by transcript cursor, and a transcript renderer folding the
common event model into user bubbles, streaming text, collapsible tool
cards, and answerable question cards.

Verified on the tdep emulator against the real server: enrollment deep
link, list, spawn, streamed echo turn, question answer round trip, tool
card expansion, adjustResize keyboard behavior. Build is warning-clean
(compose.* accessors replaced with direct dependencies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:07:02 -04:00
1 parent 967fc814ab
commit 213bc72b64
24 files changed
+2086

No files matched your search

@@ -0,0 +1,97 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
/**
* 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 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 { mutableStateOf(0) }
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
}
when (val here = screen) {
is Screen.SessionList -> SessionListScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onSettings = { screen = Screen.Settings },
)
is Screen.Session -> {
BackHandler {
reloadToken++
screen = Screen.SessionList
}
SessionScreen(
settings = current,
summary = here.summary,
onBack = {
reloadToken++
screen = Screen.SessionList
},
)
}
is Screen.Spawn -> {
BackHandler { screen = Screen.SessionList }
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = { screen = Screen.SessionList },
)
}
is Screen.Settings -> {
BackHandler { screen = Screen.SessionList }
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
reloadToken++
screen = Screen.SessionList
},
onBack = { screen = Screen.SessionList },
)
}
}
}