diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index f90733c..1b53f41 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -142,5 +142,6 @@ dependencies { implementation(libs.compose.ui) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.zxing.embedded) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt index 0e9abfb..006149f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -31,12 +31,17 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S /** * Streams events after [after] into [onEvent] until the stream drops. * + * [onOpen] fires once the server has accepted the connection. That is the measured moment the + * stream is live again, and the only honest thing to clear a previous failure on: an earlier + * version cleared on the first event instead, so an idle session went on displaying a + * connection error that had already been recovered from, indefinitely. + * * [onReset] fires when the server answers that the cursor is too far behind to continue from: * everything already displayed is stale and the events that follow are a fresh window, so the * caller drops what it holds and rebuilds -- the same thing it does when the screen opens. It * arrives before those events, so a caller that clears on it stays in order. */ - fun run(after: Long, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { + fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { val connection = URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection() as HttpURLConnection @@ -55,6 +60,7 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream") } + onOpen() val reader = connection.inputStream.bufferedReader() // SSE framing: `data:` and `event:` lines accumulate until a // blank line ends the frame. `id:` (the seq) is also inside the @@ -83,7 +89,7 @@ class EventStream(private val settings: ServerSettings, private val sessionId: S } catch (e: IOException) { if (!closed) { throw ApiException( - "Lost the event stream (${e::class.simpleName}: ${e.message})", + "Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})", e, ) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index b7382e0..4095c5a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -51,6 +51,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers @@ -171,6 +174,7 @@ fun SessionScreen( // than listed here: a hardcoded list is a claim about a machine. var offeredModels by remember { mutableStateOf>(emptyList()) } val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current // The resume cursor, written from the stream's IO thread. val lastSeq = remember { AtomicLong(0) } val activeStream = remember { AtomicReference(null) } @@ -243,43 +247,65 @@ fun SessionScreen( ready = true } - LaunchedEffect(summary.id, ready) { + // Only while the screen is actually on screen. Android stops the + // activity when somebody switches away, and the socket dies with it -- + // which arrived as "Lost the event stream (SocketTimeoutException)" + // waiting at the top on their return. Switching apps is a choice + // somebody made, not a fault to report, and reconnecting on a phone + // that has been backgrounded is work nobody is watching. Stopping the + // stream deliberately makes the drop a close rather than an error (see + // EventStream.close), and resuming reconnects from the same cursor. + LaunchedEffect(summary.id, ready, lifecycleOwner) { if (!ready) return@LaunchedEffect - while (true) { - val stream = EventStream(settings, summary.id) - activeStream.set(stream) + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { try { - withContext(Dispatchers.IO) { - stream.run( - after = lastSeq.get(), - onReset = { - // Too far behind to continue from: what is on - // screen is a stale prefix of a conversation - // that has moved on, and the window arriving - // next is not adjacent to it. Dropping the rows - // is what makes this the same as opening the - // screen -- `apply` refills them, and scrolling - // up pages the rest back in as it always does. - items = listOf() - loaded = listOf() - oldestSeq = 0L - moreHistory = true - }, - ) { entry -> - apply(entry) - streamError = null + while (true) { + val stream = EventStream(settings, summary.id) + activeStream.set(stream) + try { + withContext(Dispatchers.IO) { + stream.run( + after = lastSeq.get(), + // Connected, measured rather than inferred: this is what + // takes a failure off the screen, and nothing else does. + // Clearing on the first event instead meant an idle + // session kept displaying an error it had recovered from. + onOpen = { streamError = null }, + onReset = { + // Too far behind to continue from: what is on + // screen is a stale prefix of a conversation + // that has moved on, and the window arriving + // next is not adjacent to it. Dropping the rows + // is what makes this the same as opening the + // screen -- `apply` refills them, and scrolling + // up pages the rest back in as it always does. + items = listOf() + loaded = listOf() + oldestSeq = 0L + moreHistory = true + }, + ) { entry -> + apply(entry) + } + } + } catch (e: ApiException) { + streamError = e.message + } finally { + stream.close() } + delay(RECONNECT_DELAY_MS) } - } catch (e: ApiException) { - streamError = e.message } finally { - stream.close() + // Cancellation -- going below STARTED, or leaving the screen -- + // cannot interrupt a blocking socket read. Closing is what + // unblocks it, and what marks the drop deliberate. + activeStream.getAndSet(null)?.close() } - delay(RECONNECT_DELAY_MS) } } - // Coroutine cancellation can't interrupt a blocking socket read; - // closing the stream is what unblocks it when this screen goes away. + // The screen going away entirely, which the lifecycle scope above does + // not cover: a composable can leave the composition while the activity + // stays started. DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } // Whether the view is pinned to the newest message. The list is laid diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml index 5c0d68f..63acf55 100644 --- a/app/gradle/libs.versions.toml +++ b/app/gradle/libs.versions.toml @@ -13,6 +13,12 @@ androidx-activityCompose = "1.13.0" # .toUri), and a transitive it merely inherited could change under it. androidx-core-ktx = "1.19.0" zxing-embedded = "4.3.0" +# Declared rather than inherited for the same reason as core-ktx: SessionScreen +# now calls repeatOnLifecycle/LocalLifecycleOwner directly, to hold the event +# stream open only while the screen is on screen. Latest stable, checked +# 2026-08-29 against Google Maven; activity-compose alone would have pulled +# 2.9.4. +androidx-lifecycle = "2.11.0" # The Kotlin formatter, run at its defaults (see CODE_RULES rule 27). ktfmt # itself is Kotlin-org owned and has almost nothing to configure, which is # the point; this is the Gradle wrapper for it. Checked 2026-08-28. @@ -25,6 +31,7 @@ desugar-jdk-libs = "2.1.5" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } # In-app QR scanner: a ready-made scanning Activity (camera preview, runtime # permission prompt, flashlight toggle) reached through the AndroidX Activity # Result API (ScanContract, added in 4.3.0). Fully offline -- no Play