The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
11 KiB
Kotlin
256 lines
11 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.activity.compose.BackHandler
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.imePadding
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.key
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.unit.dp
|
|
import com.example.wgapplink.localNetworkAllowed
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
|
|
* and the back button the only other way between them.
|
|
*
|
|
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
|
|
* them a step down from another -- and what is left here is only what genuinely is a step down: one
|
|
* session, spawning one, and settings.
|
|
*/
|
|
private sealed class Screen {
|
|
data object Main : Screen()
|
|
|
|
/**
|
|
* One session, with the file explorer over it when [files] is set.
|
|
*
|
|
* The explorer is a layer on this screen rather than a screen of its own, so the session under
|
|
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
|
|
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
|
|
* created on every return, refetching the transcript over the tunnel.
|
|
*/
|
|
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
|
|
|
|
data object Spawn : Screen()
|
|
|
|
data object Settings : Screen()
|
|
}
|
|
|
|
/**
|
|
* A session a notification tap asked to open, before it is a screen.
|
|
*
|
|
* The notification names an id and nothing else, so opening it means fetching the session first.
|
|
* [serial] tells two taps on the same session's notification apart, since they are two requests and
|
|
* would otherwise compare equal.
|
|
*/
|
|
data class SessionOpenRequest(val sessionId: String, val serial: Int)
|
|
|
|
/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */
|
|
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
|
|
|
|
/**
|
|
* [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.
|
|
*
|
|
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
|
|
*
|
|
* [shareRequest] is what another app shared in, likewise. It is held here until a session takes it,
|
|
* because the share arrives before anyone has said which session it is for.
|
|
*/
|
|
@Composable
|
|
fun AppRoot(
|
|
settingsVersion: Int,
|
|
openRequest: SessionOpenRequest?,
|
|
shareRequest: ShareRequest? = null,
|
|
) {
|
|
val context = LocalContext.current
|
|
val scope = rememberCoroutineScope()
|
|
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
|
|
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
|
|
// A notification tap this could not follow, and why. Null both before one is asked for and
|
|
// after one succeeds, since success is a screen rather than a message.
|
|
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
|
|
// Bumped whenever another screen changes something the list shows, so returning to it
|
|
// refetches.
|
|
var reloadToken by remember { mutableIntStateOf(0) }
|
|
// Cleared by the session screen that attached it, not when a newer request arrives: a share
|
|
// must be attached exactly once, and only the screen that did it knows that it has.
|
|
var share by remember { mutableStateOf<ShareRequest?>(null) }
|
|
LaunchedEffect(shareRequest) {
|
|
if (shareRequest != null) {
|
|
share = shareRequest
|
|
// A session already open takes it. Otherwise the list is where the choice is made,
|
|
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
|
|
if (screen !is Screen.Session) screen = Screen.Main
|
|
}
|
|
}
|
|
|
|
// A standing condition rather than a per-request failure, so it is stated once here instead of
|
|
// appended to every error it might cause. 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.
|
|
Box(Modifier.imePadding()) {
|
|
SettingsScreen(
|
|
existing = null,
|
|
onSaved = { saved ->
|
|
settings = saved
|
|
screen = Screen.Main
|
|
},
|
|
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 goToMain = {
|
|
reloadToken++
|
|
screen = Screen.Main
|
|
}
|
|
if (screen !is Screen.Main) {
|
|
BackHandler(onBack = goToMain)
|
|
}
|
|
|
|
// Turning a notification into the screen it points at. The id has to be resolved to a session
|
|
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
|
|
// here to seed it from.
|
|
//
|
|
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
|
|
// an app that opens to the session list with no explanation looks like the tap missed.
|
|
val open: suspend (SessionOpenRequest) -> Unit = { request ->
|
|
failedOpen = null
|
|
try {
|
|
val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) }
|
|
screen = Screen.Session(session)
|
|
} catch (e: ApiException) {
|
|
failedOpen = FailedOpen(request, e.message ?: "Unknown error")
|
|
}
|
|
}
|
|
LaunchedEffect(openRequest) { openRequest?.let { open(it) } }
|
|
|
|
val failed = failedOpen
|
|
if (failed != null) {
|
|
AlertDialog(
|
|
onDismissRequest = { failedOpen = null },
|
|
title = { Text("Couldn't open that session") },
|
|
text = { Text(failed.message) },
|
|
confirmButton = {
|
|
TextButton(onClick = { scope.launch { open(failed.request) } }) {
|
|
Text("Try again")
|
|
}
|
|
},
|
|
dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } },
|
|
)
|
|
}
|
|
|
|
// Every screen but the session takes the keyboard as bottom padding here. The session screen
|
|
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
|
|
// the cost that made it lag, so it moves only its composer and transcript.
|
|
when (val here = screen) {
|
|
is Screen.Main ->
|
|
Box(Modifier.imePadding()) {
|
|
MainScreen(
|
|
settings = current,
|
|
reloadToken = reloadToken,
|
|
share = share,
|
|
onOpen = { screen = Screen.Session(it) },
|
|
onSpawn = { screen = Screen.Spawn },
|
|
onImported = { imported ->
|
|
reloadToken++
|
|
screen = Screen.Session(imported)
|
|
},
|
|
onSettings = { screen = Screen.Settings },
|
|
)
|
|
}
|
|
is Screen.Session ->
|
|
// Keyed on the id, because a different session is a different screen rather than this
|
|
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
|
|
// and a scroll position, and without the key Compose keeps all of it across the change
|
|
// and merges two conversations -- which crashes the list on the first duplicate row
|
|
// key. Only reachable since a notification can move straight from one session to
|
|
// another.
|
|
key(here.summary.id) {
|
|
// A Box so the explorer can be drawn *over* the session rather than instead of it.
|
|
// No imePadding here, for the reason above -- the explorer adds its own.
|
|
Box {
|
|
SessionScreen(
|
|
settings = current,
|
|
summary = here.summary,
|
|
onBack = goToMain,
|
|
onFiles = { screen = here.copy(files = it) },
|
|
share = share,
|
|
onShareTaken = { share = null },
|
|
)
|
|
// Its own back handler is registered after this screen's, so it is the one the
|
|
// platform asks first, and it steps back inside itself before closing.
|
|
here.files?.let { target ->
|
|
FilesScreen(
|
|
settings = current,
|
|
target = target,
|
|
onClose = { screen = here.copy(files = null) },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
is Screen.Spawn ->
|
|
Box(Modifier.imePadding()) {
|
|
SpawnScreen(
|
|
settings = current,
|
|
onSpawned = { spawned ->
|
|
reloadToken++
|
|
screen = Screen.Session(spawned)
|
|
},
|
|
onBack = goToMain,
|
|
)
|
|
}
|
|
is Screen.Settings ->
|
|
Box(Modifier.imePadding()) {
|
|
SettingsScreen(
|
|
existing = current,
|
|
onSaved = { saved ->
|
|
settings = saved
|
|
goToMain()
|
|
},
|
|
onBack = goToMain,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
|
|
// the activity puts around this, and that Box paints in the order it was given. A session
|
|
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
|
|
// same act as tapping a notification, so it goes through the same `open`.
|
|
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
|
|
}
|