git.iko.soy/opencode-android
on master help sign in
Rebuild Android client with Crux shared Rust core and Jetpack Compose shell
dbb988diko
.gitignore+9
@@ -30,3 +30,12 @@ Thumbs.db
30# Nix
30# Nix
31result
31result
32.direnv/
32.direnv/
33+
34+# Rust
35+shared/target/
36+
37+# Generated Kotlin types (regenerated from Rust core)
38+generated/jniLibs/
39+
40+# Gradle wrapper (should be committed, but ignore if not present)
41+gradle/wrapper/gradle-wrapper.jar
AGENTS.mddeleted−197
@@ -1,197 +0,0 @@
1−# AGENTS.md
2−
3−Guidance for AI coding agents (and humans) working in this repository.
4−
5−## Project
6−
7−opencode-android is a native Android client (Kotlin + Jetpack Compose) for the
8−[`opencode serve`](https://opencode.ai/docs/server/) HTTP API. It talks to a
9−remote opencode server over REST plus a long-lived Server-Sent Events stream,
10−and renders conversations (markdown, diffs, tool calls, reasoning, images) with
11−an adaptive single-pane / two-pane Material 3 UI.
12−
13−- Application id / namespace: `soy.iko.opencode`
14−- Package root: `app/src/main/java/soy/iko/opencode`
15−- minSdk 26, compileSdk/targetSdk 37, JDK 17, Kotlin 2.2.x, AGP 9.2.x
16−- Single Gradle module (`:app`); no dynamic feature modules.
17−
18−## Build & environment
19−
20−A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 37, and a
21−Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
22−
23−```bash
24−nix develop # enter the dev shell (sets ANDROID_HOME, JAVA_HOME, GRADLE_OPTS)
25−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
26−./gradlew assembleRelease # R8-minified + resource-shrunk; signed only when OPENCODE_STORE_* env vars are set
27−```
28−
29−Outside Nix, you need JDK 17 and the Android SDK (platform 37, build-tools
30−37.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
31−
32−Install on a connected device/emulator:
33−
34−```bash
35−adb install -r app/build/outputs/apk/debug/app-debug.apk
36−adb shell am start -n soy.iko.opencode/.MainActivity
37−```
38−
39−## Test, lint, and static analysis
40−
41−```bash
42−./gradlew testDebugUnitTest # JVM unit tests (JSON/reducer/ViewModel logic)
43−./gradlew detekt # static analysis; config: config/detekt/detekt.yml, baseline: app/detekt-baseline.xml
44−./gradlew lintDebug # Android Lint (abortOnError = true)
45−./gradlew connectedAndroidTest # instrumented tests; needs a running AVD or device
46−```
47−
48−- Unit tests live in `app/src/test/...` and run on the JVM. They use fakes from
49− `app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt` (Fake* subclasses
50− that override the `open` members of the real stores/clients). JUnit 4 +
51− `kotlinx-coroutines-test`; no Robolectric — `testOptions.unitTests.isReturnDefaultValues = true`
52− stubs Android framework calls so tests brushing `Log.d` etc. don't need it.
53−- Instrumented tests live in `app/src/androidTest/...` and need a device.
54−- detekt runs with a baseline (`app/detekt-baseline.xml`) so only **new**
55− findings block CI; existing ones are grandfathered.
56−- CI (`.github/workflows/build.yml`) runs: `testDebugUnitTest` → `detekt` →
57− `lintDebug` → `assembleDebug` → `assembleRelease`, then uploads both APKs.
58−
59−## Running a server to develop against
60−
61−```bash
62−opencode serve --hostname 0.0.0.0 --port 4096
63−```
64−
65−Connect the app over LAN (`http://<host-ip>:4096`) or USB
66−(`adb reverse tcp:4096 tcp:4096` then `http://127.0.0.1:4096`). Auth is optional
67−(`OPENCODE_SERVER_PASSWORD`; username defaults to `opencode`). See the README's
68−"Set up a server and connect to it" section for full details.
69−
70−## Architecture
71−
72−Unidirectional data flow with manual DI, no annotation processing (no Hilt/KSP).
73−
74−```
75−OpencodeApp (Application)
76− └─ AppContainer (service locator: profile/settings/draft stores, active connection,
77− unread tracker, auto-reconnect, pending share / open-session signals)
78− └─ OpencodeConnection (one per active server profile)
79− ├─ HttpClient (Ktor + OkHttp, base URL + Basic auth + JSON + SSE)
80− ├─ OpencodeApiClient (REST: sessions, messages, files, providers, agents, commands, permissions)
81− ├─ EventStreamClient (long-lived GET /event SSE -> hot SharedFlow<BusEvent>)
82− └─ SessionRepository (reduces BusEvents + REST seeds -> Flow<List<MessageWithParts>>)
83− └─ MessageStore (in-memory reducer; internal, unit-tested)
84−
85−ViewModels (ui/<feature>/<Feature>ViewModel.kt)
86− └─ expose StateFlow; Compose collects with collectAsStateWithLifecycle
87− └─ built via ui/VmFactory.kt (viewModelFactory { initializer { ... } }) from the AppContainer
88−```
89−
90−Key invariants:
91−
92−- **Subscribe before you prompt.** `SessionRepository.observeMessages` collects
93− the SSE stream *before* the initial REST load and before any prompt POST, so
94− early streamed parts aren't missed. Preserve this ordering if you touch it.
95−- **Resilient decoding.** `OpencodeJson` registers `polymorphicDefaultDeserializer`
96− for `Part`, `BusEvent`, `MessageInfo`, and `ToolState`, so an unknown
97− discriminator decodes to an `Unknown*` / `ToolUnknown` variant instead of
98− throwing. Never add a `when (event)` that isn't exhaustive or that crashes on
99− `UnknownEvent` — the whole point is that a new server release can't break the
100− client. Add new modeled variants alongside the `Unknown` fallback.
101−- **Conflated message stream.** `observeMessages` is `.conflate()`d so a burst of
102− per-token snapshots doesn't back-pressure the reducer. Don't remove this.
103−- **Re-seed on SSE reconnect.** When `EventStreamClient.state` returns to
104− `Connected` after a prior connection, the repository re-fetches messages from
105− REST and merges (without pruning) under a generation counter so a stale fetch
106− can't clobber a newer one. See `SessionRepository.kt` for the contract.
107−- **One Ktor client per profile.** `HttpClientFactory.create(profile)` carries
108− the base URL, JSON negotiation, SSE plugin, and auth. Basic auth is sent
109− eagerly (no 401 challenge round-trip) and only installed for HTTPS profiles;
110− for HTTP profiles the `Authorization` header is set proactively in
111− `defaultRequest` instead, and the reactive Auth plugin is deliberately NOT
112− installed so credentials can't be re-sent silently on a cleartext 401.
113−- **Process lifetime.** `OpencodeApp` installs a `CrashLogger`, builds the
114− `AppContainer`, and registers a JVM shutdown hook (since `onTerminate()` isn't
115− called on real devices) that cancels the app scope and closes the active
116− connection. `AppContainer.shutdown()` is guarded against double-invocation.
117−
118−## Source map
119−
120−```
121−data/model Wire models (kotlinx.serialization, sealed polymorphism, @Immutable)
122−data/network Ktor: HttpClientFactory, OpencodeApiClient (REST), EventStreamClient (SSE),
123− OpencodeJson (shared Json), NetworkConfig (all tuning constants)
124−data/repo SessionRepository + MessageStore (reducer), ProfileStore (DataStore +
125− EncryptedSharedPreferences), SettingsStore (theme/dynamic color),
126− DraftStore (per-session draft persistence), CrashLogger, ErrorKind
127−di AppContainer (service locator) + OpencodeConnection (per-server bundle)
128−notification NotificationChannels, RunForegroundService (holds process priority
129− during a run), SessionNotifications (completion notifications)
130−ui OpencodeApp (NavHost), Routes, VmFactory, theme/
131−ui/chat ChatScreen + ChatViewModel, message bubbles, part rendering,
132− agent/model/command picker sheets, permission dialog, export
133−ui/session SessionListScreen + ViewModel, TwoPaneSessionChat (≥840dp)
134−ui/server ServerListScreen + ServerEditScreen (+ ViewModels)
135−ui/file FileBrowserScreen + FileViewScreen (+ ViewModels)
136−ui/settings SettingsScreen, DiagnosticsScreen (crash reports)
137−util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
138−```
139−
140−## Conventions
141−
142−- **No comments unless asked** — existing code has extensive `//` comments
143− explaining *why* (invariants, race conditions, security rationale). When you
144− add code, match that style only for genuinely non-obvious invariants; don't
145− narrate *what* the code does.
146−- **`runCatchingCancellable`** (`util/Coroutines.kt`) instead of `runCatching`
147− inside coroutines — plain `runCatching` swallows `CancellationException` and
148− breaks structured concurrency. Use it for any try/catch in a suspend context.
149−- **`safeExceptionSummary`** for logging Ktor exceptions — `ClientRequestException`
150− messages embed the full request URL (may contain auth/paths); never log the
151− raw exception from an HTTP call. Crash reports scrub URLs via a regex too.
152−- **`open` classes with protected no-arg test constructors.** Stores, the API
153− client, `EventStreamClient`, `OpencodeConnection`, and `AppContainer` are
154− `open` and have a `protected constructor()` so tests can subclass and override
155− individual members without instantiating Android frameworks. Keep this pattern
156− when adding new injectable components.
157−- **`NetworkConfig`** holds all tuning constants (timeouts, backoffs, buffer
158− sizes, layout thresholds, debounce delays). Add new magic numbers here rather
159− than scattering literals.
160−- **REST path encoding.** `OpencodeApiClient` URL-encodes path segments via
161− `encode()` (e.g. `session/${encode(id)}`). Deep-link session ids are validated
162− against `[A-Za-z0-9_-]+` in `MainActivity` to prevent path traversal.
163−- **Idempotency keys.** `sendPrompt` and `runCommand` generate the
164− `Idempotency-Key` header *before* `withRetry` so all retry attempts share it.
165− Preserve this when adding mutating endpoints.
166−- **`withRetry`** wraps all REST reads and the permission response. Transient
167− failures back off with jitter; see `NetworkConfig` for the parameters.
168−- **Compose:** `collectAsStateWithLifecycle` everywhere; `WhileSubscribed` with a
169− 5s grace period (`stateFlowSubscriptionTimeoutMs`). `@Composable` functions
170− use PascalCase (detekt `FunctionNaming` disabled for this reason). Long
171− composables and long parameter lists are accepted (detekt `LongMethod` /
172− `LongParameterList` disabled).
173−- **Manifest:** cleartext HTTP is permitted via `network_security_config.xml`
174− (opencode serves plain `http://`). `enableOnBackInvokedCallback="true"` for
175− Android 14 predictive back. Deep link scheme `opencode://session/{id}`.
176−- **`@Immutable`** on data models exposed to Compose so they skip recomposition
177− correctly.
178−- **Kotlin code style: `official`** (see `gradle.properties`).
179−
180−## Things to avoid
181−
182−- Don't add Hilt/Dagger/KSP — DI is intentionally hand-written (`AppContainer`).
183−- Don't add Robolectric — JVM tests use `isReturnDefaultValues = true` stubs and
184− fakes; only device-dependent code goes in `androidTest`.
185−- Don't run `nix flake check` manually (CI only — per repo convention).
186−- Don't read or query `/nix/store` (extremely large — per repo convention).
187−- Don't introduce new detekt findings; the baseline only grandfathers existing
188− ones. Run `./gradlew detekt` before considering work done.
189−- Don't log raw Ktor/OkHttp exceptions — use `safeExceptionSummary`.
190−- Don't make a `when (BusEvent)` / `when (Part)` non-exhaustive; the `Unknown*`
191− variants exist for forward compatibility and must remain reachable.
192−
193−## Renovate
194−
195−`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196−Ktor is grouped; AndroidX majors are held for manual review (pinned to
197−compileSdk 37). Dependency upgrades land via Renovate PRs, not manual bumps.
CLAUDE.mddeleted−1
@@ -1 +0,0 @@
1−AGENTS.md
README.md+86−73
@@ -1,91 +1,104 @@
1# opencode-android
1# opencode-android
2
2
3−A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
3+An Android client for the [opencode](https://github.com/sst/opencode) server, built with
4+[Crux](https://github.com/redbadger/crux) (shared Rust core + Kotlin shell) and Jetpack Compose.
4
5
5−Application id: `soy.iko.opencode`
6+## Architecture
6−
7−## Set up a server and connect to it
8−
9−This app is a client for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API, so you
10−need an opencode server running somewhere reachable from your device.
11−
12−### 1. Start the server
13−
14−On the host that has your project and opencode installed:
15
7
16−```bash
8+```
17−opencode serve --hostname 0.0.0.0 --port 4096
9+┌─────────────────────────────────────────────────┐
10+│ Shell (Kotlin / Jetpack Compose) │
11+│ ┌─────────────┐ ┌──────────────────────────┐ │
12+│ │ Compose UI │ │ HttpClient / SseClient │ │
13+│ │ (3 screens) │ │ (Ktor + OkHttp) │ │
14+│ └──────┬───────┘ └────────────┬─────────────┘ │
15+│ │ │ │
16+│ Event (bincode) Effect (bincode) │
17+│ │ ▲ │
18+│ ┌──────▼───────────────────────┴────────────┐ │
19+│ │ Core (ViewModel) — FFI bridge │ │
20+│ │ CoreFfi → NativeCoreFfi / PureCoreFfi │ │
21+│ └───────────────────────────────────────────┘ │
22+└──────────┬──────────────────────────────────────┘
23+ │ JNI (bincode bytes)
24+┌──────────▼──────────────────────────────────────┐
25+│ Core (Rust / Crux) │
26+│ ┌────────┐ ┌─────────┐ ┌──────────┐ │
27+│ │ App │ │ Model │ │ Commands │ │
28+│ │ update │ │ state │ │ effects │ │
29+│ └────────┘ └─────────┘ └──────────┘ │
30+│ No side-effects — pure logic │
31+└─────────────────────────────────────────────────┘
18```
32```
19
33
20−Useful flags:
34+### Rust Core (`shared/`)
21−
35+- **`app.rs`** — Crux `App` implementation: `Event`, `Model`, `ViewModel`, `Effect`, `update()`, `view()`
22−| Flag | Default | Description |
36+- **`ffi.rs`** — BoltFFI export wrapping `crux_core::bridge::Bridge`
23−| --- | --- | --- |
37+- **`bin/codegen.rs`** — Type generator (produces Kotlin types via `facet-generate`)
24−| `--port` | `4096` | Port to listen on |
38+
25−| `--hostname` | `127.0.0.1` | Hostname/interface to bind (`0.0.0.0` exposes it on your LAN) |
39+### Kotlin Shell (`app/`)
26−| `--mdns` | `false` | Enable mDNS discovery |
40+- **`core/CoreFfi.kt`** — FFI interface + JNI bridge (loads `libopencode.so`)
27−| `--cors` | `[]` | Extra browser origins to allow (repeatable) |
41+- **`core/PureCoreFfi.kt`** — Pure-Kotlin fallback (mirrors Rust logic for dev without NDK)
28−
42+- **`core/Core.kt`** — Android ViewModel bridge: sends events, processes effects, exposes `StateFlow<ViewModel>`
29−The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
43+- **`core/HttpClient.kt`** — Executes `Effect.Http` via Ktor/OkHttp, returns `HttpResult` to core
30−
44+- **`core/SseClient.kt`** — Subscribes to opencode SSE event stream
31−### 2. (Optional) Add authentication
45+- **`ui/screens/`** — Connect, Sessions, Chat screens (Jetpack Compose + Material 3)
46+
47+### Generated Types (`generated/`)
48+- Auto-generated Kotlin types matching the Rust core (Event, Effect, ViewModel, Request, etc.)
49+- Bincode serialization helpers (`com.novi.bincode`, `com.novi.serde`)
50+
51+## Building
52+
53+### Prerequisites
54+- Rust 1.90+ (with Android NDK targets for native builds)
55+- Android SDK (compile SDK 37, build-tools 37.0.0)
56+- JDK 17+
57+
58+### Generate Kotlin types
59+```sh
60+cd shared
61+cargo run --bin codegen --features codegen -- --language kotlin --output-dir ../generated
62+```
32
63
33−opencode has no auth by default. To protect the server with HTTP Basic auth:
64+### Build native library (requires NDK)
65+```sh
66+# Install Android targets
67+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
34
68
35−```bash
69+# Build and pack with BoltFFI
36−OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
70+boltffi pack android
37```
71```
38
72
39−The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
73+### Build the APK
40−app's add-server dialog.
74+```sh
41−
75+./gradlew assembleDebug
42−### 3. Connect from the app
76+```
43
77
44−**Over Wi-Fi / LAN** — add a server with base URL `http://<host-LAN-ip>:4096`.
78+The app works without the native library — `PureCoreFfi` provides a pure-Kotlin
79+reimplementation of the core logic. When `libopencode.so` is present, `NativeCoreFfi`
80+is used automatically.
45
81
46−**Over USB only** — forward the port to the device and point the app at localhost:
82+### Run tests
83+```sh
84+# Rust core tests
85+cd shared && cargo test
47
86
48−```bash
87+# Kotlin shell tests
49−adb reverse tcp:4096 tcp:4096
88+./gradlew testDebugUnitTest
50```
89```
51
90
52−Then use base URL `http://127.0.0.1:4096` in the app.
91+## API Coverage
53
92
54−> The app permits cleartext HTTP because opencode serves plain `http://`. If you terminate TLS with
93+The client connects to an opencode server and supports:
55−> a reverse proxy, use an `https://` base URL instead.
94+- **Health check** (`GET /global/health`)
56−
95+- **Session list** (`GET /session`)
57−## Architecture
96+- **Create session** (`POST /session`)
58−
97+- **Load messages** (`GET /session/{id}/message`)
59−- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
98+- **Send message** (`POST /session/{id}/prompt_async`)
60−- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
99+- **SSE events** (`GET /event`) — live message streaming
61− (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
62− discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
63− can't break decoding.
64−- **Manual DI** (`AppContainer` on the `Application`) — no Hilt/KSP.
65−- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
66− `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
67− Compose collects with `collectAsStateWithLifecycle`.
68−- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
69−
70−Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
71−(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
72−
73−## Build
74−
75−```bash
76−nix develop # JDK 17, Gradle 9.6.1, Android SDK 37, aapt2 override
77−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
78−./gradlew assembleRelease # R8-minified + resource-shrunk release APK
79−./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
80−./gradlew detekt # static analysis (config: config/detekt/detekt.yml)
81−./gradlew lintDebug # Android Lint
82−./gradlew connectedAndroidTest # device/emulator tests (needs an AVD)
83−```
84
100
85−## Install & run
101+## Configuration
86
102
87−```bash
103+The default server URL is `http://localhost:4096`. Change it in the Connect screen.
88−adb install -r app/build/outputs/apk/debug/app-debug.apk
104+`android:usesCleartextTraffic="true"` is enabled for local development.
89−adb shell am start -n soy.iko.opencode/.MainActivity
90−adb logcat --pid=$(adb shell pidof soy.iko.opencode)
91−```
app/build.gradle.kts+28−119
@@ -1,26 +1,12 @@
1+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
1plugins {
3plugins {
2 alias(libs.plugins.android.application)
4 alias(libs.plugins.android.application)
3− alias(libs.plugins.kotlin.serialization)
5+ alias(libs.plugins.kotlin.android)
4 alias(libs.plugins.kotlin.compose)
6 alias(libs.plugins.kotlin.compose)
5− alias(libs.plugins.detekt)
7+ alias(libs.plugins.kotlin.serialization)
6}
8}
7
9
8−/**
9− * Monotonically increasing versionCode: the git commit count on HEAD, so each release
10− * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
11− * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
12− */
13−val gitCommitCount: Int = runCatching {
14− providers.exec {
15− commandLine("git", "rev-list", "--count", "HEAD")
16− }.standardOutput.asText.get().trim().toInt()
17−}.getOrDefault(1)
18−
19−// Optional versionName override supplied via `-PversionName=...` (used by the
20−// release workflow to stamp the commit-date version into the APK). Falls back to
21−// the hard-coded version when absent so local builds are unaffected.
22−val versionNameOverride: String? = providers.gradleProperty("versionName").orNull
23−
24android {
10android {
25 namespace = "soy.iko.opencode"
11 namespace = "soy.iko.opencode"
26 compileSdk = 37
12 compileSdk = 37
@@ -30,144 +16,67 @@ android {
30 applicationId = "soy.iko.opencode"
16 applicationId = "soy.iko.opencode"
31 minSdk = 26
17 minSdk = 26
32 targetSdk = 37
18 targetSdk = 37
33− // Monotonically increasing across builds: derived from the git commit count so
19+ versionCode = 1
34− // each release ships a strictly higher versionCode without manual bookkeeping
35− // (and never triggers a "downgrade" install block). Falls back to 1 when the
36− // .git history isn't available (e.g. a plain source export).
37− versionCode = gitCommitCount
38− versionName = versionNameOverride ?: "0.1.0"
20+ versionName = "0.1.0"
39 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
21 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
40 }
22 }
41
23
42− // Release builds are signed when keystore credentials are supplied via env vars
43− // (so CI can publish an installable APK). When the env vars are absent, the
44− // release build falls back to unsigned — same behavior as before — so local
45− // builds keep working without any keystore setup.
46− val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
47− signingConfigs.create("release") {
48− storeFile = file(path)
49− storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
50− keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
51− keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
52− }
53− }
54−
55 buildTypes {
24 buildTypes {
56 release {
25 release {
57− isMinifyEnabled = true
26+ isMinifyEnabled = false
58− isShrinkResources = true
59 proguardFiles(
27 proguardFiles(
60 getDefaultProguardFile("proguard-android-optimize.txt"),
28 getDefaultProguardFile("proguard-android-optimize.txt"),
61 "proguard-rules.pro",
29 "proguard-rules.pro",
62 )
30 )
63− releaseSigning?.let { signingConfig = it }
64 }
31 }
65 }
32 }
66
33
67 compileOptions {
34 compileOptions {
68− sourceCompatibility = JavaVersion.VERSION_17
35+ sourceCompatibility = JavaVersion.VERSION_11
69− targetCompatibility = JavaVersion.VERSION_17
36+ targetCompatibility = JavaVersion.VERSION_11
70 }
37 }
71
38
72− // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
73− // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
74 kotlin {
39 kotlin {
75 compilerOptions {
40 compilerOptions {
76− jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
41+ jvmTarget = JvmTarget.JVM_11
77 }
42 }
78 }
43 }
79
44
80 buildFeatures {
45 buildFeatures {
81 compose = true
46 compose = true
82 }
47 }
83−
84− lint {
85− // Fail CI on real errors. Warnings are reported but don't break the build so
86− // minor/style issues don't gate development.
87− abortOnError = true
88− // Dependency upgrades are tracked by Renovate, so don't lint for them here.
89− // ProduceStateDoesNotAssignValue false-positives on valid produceState calls that
90− // assign `value` via a conditional/`withContext` expression (see FileViewScreen's
91− // match-index scan), so it's disabled rather than worked around at each call site.
92− // InvalidFragmentVersionForActivityResult false-positives on FragmentActivity
93− // (the app doesn't use fragments, but BiometricPrompt requires FragmentActivity).
94− // LocalContextGetResourceValueCall flags context.getString() inside LaunchedEffect /
95− // remember lambdas where stringResource() can't be called (not a @Composable scope);
96− // the check is overly aggressive for valid non-composable coroutine contexts.
97− disable += setOf(
98− "GradleDependency",
99− "AndroidGradlePluginVersion",
100− "ProduceStateDoesNotAssignValue",
101− "InvalidFragmentVersionForActivityResult",
102− "LocalContextGetResourceValueCall",
103− )
104− }
105−
106− packaging {
107− resources {
108− excludes += "/META-INF/{AL2.0,LGPL2.1}"
109− }
110− }
111−
112− testOptions {
113− unitTests {
114− // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
115− // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
116− // against Android APIs don't need Robolectric.
117− isReturnDefaultValues = true
118− isIncludeAndroidResources = true
119− }
120− }
121}
48}
122
49
123dependencies {
50dependencies {
51+ implementation(project(":shared"))
52+
124 implementation(libs.androidx.core.ktx)
53 implementation(libs.androidx.core.ktx)
125− implementation(libs.androidx.core.splashscreen)
54+ implementation(libs.androidx.lifecycle.runtime.ktx)
126− implementation(libs.androidx.window)
127 implementation(libs.androidx.activity.compose)
55 implementation(libs.androidx.activity.compose)
56+ implementation(platform(libs.androidx.compose.bom))
57+ implementation(libs.androidx.compose.ui)
58+ implementation(libs.androidx.compose.ui.graphics)
59+ implementation(libs.androidx.compose.ui.tooling.preview)
60+ implementation(libs.androidx.compose.material3)
61+ implementation(libs.androidx.compose.material.icons.extended)
128− implementation(libs.androidx.lifecycle.viewmodel.compose)
62+ implementation(libs.lifecycle.viewmodel.compose)
129− implementation(libs.androidx.lifecycle.runtime.compose)
130 implementation(libs.androidx.navigation.compose)
63 implementation(libs.androidx.navigation.compose)
131
64
132− implementation(platform(libs.compose.bom))
65+ implementation(libs.kotlinx.coroutines.android)
133− implementation(libs.compose.ui)
134− implementation(libs.compose.ui.graphics)
135− implementation(libs.compose.ui.tooling.preview)
136− implementation(libs.compose.material3)
137− implementation(libs.compose.material.icons.extended)
138− debugImplementation(libs.compose.ui.tooling)
139
66
140 implementation(libs.ktor.client.core)
67 implementation(libs.ktor.client.core)
141 implementation(libs.ktor.client.okhttp)
68 implementation(libs.ktor.client.okhttp)
142 implementation(libs.ktor.client.content.negotiation)
69 implementation(libs.ktor.client.content.negotiation)
143 implementation(libs.ktor.serialization.kotlinx.json)
70 implementation(libs.ktor.serialization.kotlinx.json)
71+ implementation(libs.ktor.client.logging)
144 implementation(libs.ktor.client.auth)
72 implementation(libs.ktor.client.auth)
145
73
146− implementation(libs.kotlinx.serialization.json)
147− implementation(libs.kotlinx.coroutines.android)
148−
149− implementation(libs.androidx.datastore.preferences)
150− implementation(libs.androidx.security.crypto)
151− implementation(libs.androidx.biometric)
152− implementation(libs.coil.compose)
153− implementation(libs.coil.network.okhttp)
154− implementation(libs.markdown.renderer)
155− implementation(libs.markdown.renderer.m3)
156−
157 testImplementation(libs.junit)
74 testImplementation(libs.junit)
158 testImplementation(libs.kotlinx.coroutines.test)
75 testImplementation(libs.kotlinx.coroutines.test)
159− testImplementation(libs.ktor.client.mock)
160−
161− androidTestImplementation(libs.androidx.test.ext.junit)
76+ androidTestImplementation(libs.androidx.junit)
162− androidTestImplementation(libs.androidx.test.runner)
77+ androidTestImplementation(libs.androidx.espresso.core)
163− androidTestImplementation(libs.androidx.test.core)
164− androidTestImplementation(libs.kotlinx.coroutines.test)
78+ androidTestImplementation(platform(libs.androidx.compose.bom))
165−}
79+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
166−
80+ debugImplementation(libs.androidx.compose.ui.tooling)
167−detekt {
81+ debugImplementation(libs.androidx.compose.ui.test.manifest)
168− buildUponDefaultConfig = true
169− // Carry forward the current baseline so CI only fails on *new* findings;
170− // existing issues are grandfathered rather than blocking the migration.
171− baseline = file("detekt-baseline.xml")
172− config.setFrom("$rootDir/config/detekt/detekt.yml")
173}
82}
app/detekt-baseline.xmldeleted−33
@@ -1,33 +0,0 @@
1−<?xml version="1.0" ?>
2−<SmellBaseline>
3− <ManuallySuppressedIssues/>
4− <CurrentIssues>
5− <ID>ComplexCondition:ChatScreen.kt$searchActive &amp;&amp; searchQuery.isNotEmpty() &amp;&amp; searchMessages.isEmpty() &amp;&amp; messages.isNotEmpty()</ID>
6− <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7− <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
8− <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
9− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, focusMessageId: String? = null, onEditProfile: ((String) -&gt; Unit)? = null, )</ID>
10− <ID>CyclomaticComplexMethod:CodeHighlighter.kt$fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString</ID>
11− <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
12− <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
13− <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
14− <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
15− <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
16− <ID>CyclomaticComplexMethod:GlobalSearchScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun GlobalSearchScreen( container: AppContainer, onOpenSession: (String, String?) -&gt; Unit, onBack: () -&gt; Unit, )</ID>
17− <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
18− <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
19− <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onServerSettings: (String) -&gt; Unit, )</ID>
20− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
21− <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, onEditProfile: ((String) -&gt; Unit)? = null, )</ID>
22− <ID>LargeClass:AppContainer.kt$AppContainer</ID>
23− <ID>LargeClass:SessionListViewModel.kt$SessionListViewModel : ViewModel</ID>
24− <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
25− <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
26− <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
27− <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
28− <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
29− <ID>TooManyFunctions:MessageBubble.kt$soy.iko.opencode.ui.chat.MessageBubble.kt</ID>
30− <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
31− <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
32− </CurrentIssues>
33−</SmellBaseline>
app/proguard-rules.pro+6−49
@@ -1,51 +1,8 @@
1−# --- kotlinx.serialization ---
1+# Keep generated Crux types (serialization reflection)
2−# The plugin generates serializers; keep them and the $$serializer companion members.
2+-keep class soy.iko.opencode.core.** { *; }
3−-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
3+-keep class com.novi.serde.** { *; }
4−-dontnote kotlinx.serialization.**
4+-keep class com.novi.bincode.** { *; }
5−-keepclassmembers class soy.iko.opencode.** {
6− *** Companion;
7−}
8−-keepclasseswithmembers class soy.iko.opencode.** {
9− kotlinx.serialization.KSerializer serializer(...);
10−}
11−# Keep @Serializable classes themselves so their generated serializers can reference them.
12−-keep,includedescriptorclasses class soy.iko.opencode.**$$serializer { *; }
13−-keepclassmembers class soy.iko.opencode.** {
14− *** Companion;
15−}
16−# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17−# Covers ALL @Serializable classes in the app — not just data.model — because several
18−# classes used for persistent storage live elsewhere (OutboxMessage/PersistedAttachment in
19−# data/repo, StoredProfile in ProfileStore, Backup* in BackupManager, RecentSession, …)
20−# and R8 stripping/renaming their fields would break on-disk deserialization in release.
21−-keep @kotlinx.serialization.Serializable class soy.iko.opencode.** { *; }
22
5
23−# --- Ktor / OkHttp (HTTP client + SSE) ---
6+# Ktor
24−# Don't warn about missing optional dependencies; keep only the classes that are
25−# accessed via reflection (Ktor engine registration, OkHttp internals that R8
26−# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
7+-keep class io.ktor.** { *; }
27−# so R8 can still eliminate dead code and shrink the APK.
28-dontwarn io.ktor.**
8-dontwarn io.ktor.**
29−-dontwarn okhttp3.**
30−-dontwarn okio.**
31−-dontwarn org.slf4j.**
32−-dontwarn org.jetbrains.annotations.**
33−# Ktor loads engines and plugins via ServiceLoader — keep the service files.
34−-keep class io.ktor.client.engine.** { *; }
35−-keep class io.ktor.client.plugins.** { *; }
36−-keep class io.ktor.serialization.** { *; }
37−-keep class io.ktor.http.** { *; }
38−# OkHttp uses reflection for platform detection and internal classes.
39−-keep class okhttp3.internal.platform.** { *; }
40−-keep class okhttp3.** { *; }
41−-keep class okio.** { *; }
42−
43−# --- Coil (image loading) ---
44−# Coil uses reflection for loadable decoders and the Compose integration.
45−-keep class coil.** { *; }
46−-keep class coil3.** { *; }
47−-dontwarn coil.**
48−
49−# --- androidx.security.crypto pulls in Tink, which references compile-only
50−# com.google.errorprone annotations. They aren't on the runtime classpath.
51−-dontwarn com.google.errorprone.annotations.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktdeleted−36
@@ -1,36 +0,0 @@
1−package soy.iko.opencode
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertNotNull
6−import org.junit.Assert.assertTrue
7−import org.junit.Test
8−import org.junit.runner.RunWith
9−
10−/**
11− * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12− * are initialized on startup, and the notification channels are created.
13− */
14−@RunWith(AndroidJUnit4::class)
15−class ApplicationBootstrapTest {
16−
17− @Test
18− fun containerIsInitialized() {
19− val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20− assertNotNull(app.container)
21− assertNotNull(app.container.settingsStore)
22− }
23−
24− @Test
25− fun notificationChannelsExist() {
26− if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27− val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28− // Touching the app instance runs Application.onCreate, which creates the channels.
29− ApplicationProvider.getApplicationContext<OpencodeApp>()
30− val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31− as android.app.NotificationManager
32− val ids = nm.notificationChannels.map { it.id }
33− assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34− assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35− }
36−}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktdeleted−67
@@ -1,67 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertNotNull
7−import org.junit.Assert.assertNull
8−import org.junit.Assert.assertTrue
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−/**
13− * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14− * real app-private filesystem: a simulated crash is written, then read back, listed, and
15− * deleted through the public API.
16− */
17−@RunWith(AndroidJUnit4::class)
18−class CrashLoggerTest {
19−
20− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21− private val logger = CrashLogger.get(context)
22−
23− @Test
24− fun writeReadDeleteRoundTrip() {
25− logger.clearAll()
26−
27− // Simulate an uncaught exception by invoking the report path directly.
28− val report = writeFakeReport()
29−
30− logger.refresh()
31− assertEquals(1, logger.reportCount())
32−
33− val content = logger.readReport(report)
34− assertNotNull(content)
35− assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36− assertTrue("report should contain device metadata", content.contains("Android:"))
37−
38− logger.deleteReport(report)
39− assertEquals(0, logger.reportCount())
40− assertNull(logger.readReport(report))
41− }
42−
43− @Test
44− fun clearAllRemovesEveryReport() {
45− writeFakeReport("a")
46− writeFakeReport("b")
47− logger.refresh()
48− assertTrue(logger.reportCount() >= 2)
49−
50− logger.clearAll()
51− assertEquals(0, logger.reportCount())
52− }
53−
54− private fun writeFakeReport(tag: String = "test"): String {
55− val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56− val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57− java.io.File(dir, name).writeText(
58− """
59− opencode-android crash report
60− Android: test
61− java.lang.IllegalStateException: boom $tag
62− at Crasher.crash(Crasher.kt:1)
63− """.trimIndent(),
64− )
65− return name
66− }
67−}
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktdeleted−59
@@ -1,59 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import kotlinx.coroutines.CoroutineScope
6−import kotlinx.coroutines.Dispatchers
7−import kotlinx.coroutines.SupervisorJob
8−import kotlinx.coroutines.cancel
9−import kotlinx.coroutines.runBlocking
10−import org.junit.Assert.assertEquals
11−import org.junit.Test
12−import org.junit.runner.RunWith
13−
14−@RunWith(AndroidJUnit4::class)
15−class DraftStoreTest {
16−
17− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
18−
19− @Test
20− fun setGetRemoveRoundTrip() = runBlocking {
21− val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
22− val store = DraftStore(context, scope)
23−
24− // Use a unique session ID so it doesn't collide with other tests.
25− val sid = "test-${System.nanoTime()}"
26−
27− store.set(sid, "hello world")
28− assertEquals("hello world", store.get(sid))
29−
30− // Overwrite
31− store.set(sid, "updated")
32− assertEquals("updated", store.get(sid))
33−
34− // Blank clears the entry
35− store.set(sid, " ")
36− assertEquals("", store.get(sid))
37−
38− // Explicit remove
39− store.set(sid, "text")
40− store.remove(sid)
41− assertEquals("", store.get(sid))
42−
43− scope.cancel()
44− }
45−
46− @Test
47− fun getReturnsEmptyForUnknownSession() {
48− // Create the scope in a local val so it can be cancelled — DraftStore launches a
49− // debounced-persistence collector into the supplied scope that keeps running until
50− // cancelled, and an uncanceled scope leaks across test invocations on device.
51− val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
52− try {
53− val store = DraftStore(context, scope)
54− assertEquals("", store.get("never-set-${System.nanoTime()}"))
55− } finally {
56− scope.cancel()
57− }
58− }
59−}
app/src/main/AndroidManifest.xml+5−114
@@ -4,132 +4,23 @@
4
4
5 <uses-permission android:name="android.permission.INTERNET" />
5 <uses-permission android:name="android.permission.INTERNET" />
6 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7− <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8− <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9− <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
10−
11− <!-- Camera is used opportunistically for attachment capture via the system camera app
12− (ACTION_IMAGE_CAPTURE), so it's optional — devices without a camera still install. -->
13− <uses-feature android:name="android.hardware.camera.any" android:required="false" />
14
7
15 <application
8 <application
16− android:name=".OpencodeApp"
17− android:allowBackup="false"
9+ android:allowBackup="true"
18− android:dataExtractionRules="@xml/data_extraction_rules"
19− android:fullBackupContent="false"
20− android:icon="@mipmap/ic_launcher"
21 android:label="@string/app_name"
10 android:label="@string/app_name"
22− android:networkSecurityConfig="@xml/network_security_config"
23 android:supportsRtl="true"
11 android:supportsRtl="true"
24− android:theme="@style/Theme.OpencodeAndroid">
12+ android:theme="@style/Theme.Opencode"
25−
13+ android:usesCleartextTraffic="true"
14+ tools:targetApi="31">
26 <activity
15 <activity
27 android:name=".MainActivity"
16 android:name=".MainActivity"
28 android:exported="true"
17 android:exported="true"
29− android:launchMode="singleTop"
30− android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
31− android:enableOnBackInvokedCallback="true"
32− android:windowSoftInputMode="adjustResize"
33− tools:targetApi="33"
34− android:theme="@style/Theme.OpencodeAndroid.Splash">
18+ android:theme="@style/Theme.Opencode">
35 <intent-filter>
19 <intent-filter>
36 <action android:name="android.intent.action.MAIN" />
20 <action android:name="android.intent.action.MAIN" />
37 <category android:name="android.intent.category.LAUNCHER" />
21 <category android:name="android.intent.category.LAUNCHER" />
38 </intent-filter>
22 </intent-filter>
39− <intent-filter>
40− <action android:name="android.intent.action.SEND" />
41− <category android:name="android.intent.category.DEFAULT" />
42− <data android:mimeType="text/plain" />
43− </intent-filter>
44− <!-- Receive images shared from other apps (single or multiple) to attach to a prompt. -->
45− <intent-filter>
46− <action android:name="android.intent.action.SEND" />
47− <category android:name="android.intent.category.DEFAULT" />
48− <data android:mimeType="image/*" />
49− </intent-filter>
50− <intent-filter>
51− <action android:name="android.intent.action.SEND_MULTIPLE" />
52− <category android:name="android.intent.category.DEFAULT" />
53− <data android:mimeType="image/*" />
54− </intent-filter>
55− <!-- Deep link to a session: opencode://session/{sessionId} -->
56− <intent-filter>
57− <action android:name="android.intent.action.VIEW" />
58− <category android:name="android.intent.category.DEFAULT" />
59− <category android:name="android.intent.category.BROWSABLE" />
60− <data android:scheme="opencode" android:host="session" />
61− </intent-filter>
62− <!-- Deep links: opencode://file/{path} and opencode://new -->
63− <intent-filter>
64− <action android:name="android.intent.action.VIEW" />
65− <category android:name="android.intent.category.DEFAULT" />
66− <category android:name="android.intent.category.BROWSABLE" />
67− <data android:scheme="opencode" android:host="file" />
68− </intent-filter>
69− <intent-filter>
70− <action android:name="android.intent.action.VIEW" />
71− <category android:name="android.intent.category.DEFAULT" />
72− <category android:name="android.intent.category.BROWSABLE" />
73− <data android:scheme="opencode" android:host="new" />
74− </intent-filter>
75 </activity>
23 </activity>
76−
77− <service
78− android:name=".notification.RunForegroundService"
79− android:exported="false"
80− android:foregroundServiceType="dataSync" />
81−
82− <!-- Handles notification action buttons (permission responses, inline reply). Not
83− exported: only the app's own PendingIntents target it. -->
84− <receiver
85− android:name=".notification.NotificationActionReceiver"
86− android:exported="false" />
87−
88− <!-- Home-screen widget listing recent sessions. -->
89− <receiver
90− android:name=".platform.SessionsWidgetProvider"
91− android:exported="true">
92− <intent-filter>
93− <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
94− </intent-filter>
95− <meta-data
96− android:name="android.appwidget.provider"
97− android:resource="@xml/sessions_widget_info" />
98− </receiver>
99−
100− <!-- Serves the widget's session list rows. Bound by the system with BIND_REMOTEVIEWS.
101− exported="true" is required: the AppWidget host runs in the launcher/system process
102− (a different process from the app), so it can only bindService to a service that's
103− visible outside the app. The BIND_REMOTEVIEWS permission restricts *who* can bind to
104− the system; exported="false" would make the service invisible to the host and the
105− widget's RemoteViewsService adapter would never populate. -->
106− <service
107− android:name=".platform.SessionsWidgetService"
108− android:permission="android.permission.BIND_REMOTEVIEWS"
109− android:exported="true" />
110−
111− <!-- Quick-settings tile: start a new session. Bound by the system. -->
112− <service
113− android:name=".platform.NewSessionTileService"
114− android:icon="@drawable/ic_launcher_foreground"
115− android:label="@string/tile_new_session"
116− android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
117− android:exported="true">
118− <intent-filter>
119− <action android:name="android.service.quicksettings.action.QS_TILE" />
120− </intent-filter>
121− </service>
122−
123− <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
124− <provider
125− android:name="androidx.core.content.FileProvider"
126− android:authorities="${applicationId}.fileprovider"
127− android:exported="false"
128− android:grantUriPermissions="true">
129− <meta-data
130− android:name="android.support.FILE_PROVIDER_PATHS"
131− android:resource="@xml/file_paths" />
132− </provider>
133 </application>
24 </application>
134
25
135</manifest>
26</manifest>
app/src/main/java/soy/iko/opencode/MainActivity.kt+20−470
@@ -1,489 +1,39 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3−import android.Manifest
4−import android.content.Intent
5−import android.content.pm.PackageManager
6−import android.net.Uri
7−import android.os.Build
8import android.os.Bundle
3import android.os.Bundle
9−import android.view.WindowManager
4+import androidx.activity.ComponentActivity
10−import androidx.core.content.IntentCompat
11−import soy.iko.opencode.data.network.NetworkConfig
12−import soy.iko.opencode.notification.NotificationActionReceiver
13import androidx.activity.compose.setContent
5import androidx.activity.compose.setContent
14import androidx.activity.enableEdgeToEdge
6import androidx.activity.enableEdgeToEdge
15−import androidx.activity.result.contract.ActivityResultContracts
7+import androidx.compose.runtime.Composable
16−import androidx.compose.foundation.isSystemInDarkTheme
17−import androidx.compose.material3.AlertDialog
18−import androidx.compose.material3.Text
19−import androidx.compose.material3.TextButton
8+import androidx.compose.runtime.collectAsState
20−import androidx.compose.runtime.LaunchedEffect
21import androidx.compose.runtime.getValue
9import androidx.compose.runtime.getValue
22−import androidx.compose.runtime.mutableStateOf
10+import androidx.lifecycle.viewmodel.compose.viewModel
23−import androidx.compose.runtime.remember
11+import soy.iko.opencode.core.Core
24−import androidx.compose.runtime.setValue
25−import androidx.compose.ui.res.stringResource
26−import kotlinx.coroutines.Dispatchers
27−import kotlinx.coroutines.flow.combine
28−import kotlinx.coroutines.launch
29−import kotlinx.coroutines.withContext
30−import androidx.core.content.ContextCompat
31−import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
32−import androidx.fragment.app.FragmentActivity
33−import androidx.lifecycle.compose.collectAsStateWithLifecycle
34−import androidx.lifecycle.lifecycleScope
35−import soy.iko.opencode.data.repo.SettingsStore
12+import soy.iko.opencode.core.Screen
36−import soy.iko.opencode.data.repo.ThemeMode
13+import soy.iko.opencode.ui.screens.ChatScreen
37−import soy.iko.opencode.ui.AppLockGate
14+import soy.iko.opencode.ui.screens.ConnectScreen
38−import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
15+import soy.iko.opencode.ui.screens.SessionsScreen
39import soy.iko.opencode.ui.theme.OpencodeTheme
16import soy.iko.opencode.ui.theme.OpencodeTheme
40
17
41−// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
42−// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
43−// /activity-result APIs are unaffected.
44−class MainActivity : FragmentActivity() {
18+class MainActivity : ComponentActivity() {
45−
46− private val requestNotificationPermission = registerForActivityResult(
47− ActivityResultContracts.RequestPermission(),
48− ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
49−
50− private var shareIntentHandled = false
51− private var openSessionHandled = false
52− private var newSessionHandled = false
53− private var openFileHandled = false
54− // Set when a deep link was present but failed validation (malformed id/path). Surfaced
55− // as a toast from onResume so the user knows the link was rejected, not silently ignored.
56− // On a warm start (onNewIntent while already resumed) the toast is shown immediately from
57− // handleIntent, since onResume is NOT re-called on an already-resumed activity — without
58− // that the toast would stay pending until the next pause/resume cycle (e.g. user
59− // backgrounds and returns), making the rejected link look silently ignored.
60− private var showInvalidLinkToast = false
61− // True between onResume and onPause. Used by handleIntent to decide whether to fire the
62− // invalid-link toast immediately (warm start) or defer it to onResume (cold start, where
63− // a toast from onCreate could flash before the first frame is drawn).
64− private var isResumed = false
65− // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
66− // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
67− // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
68− // them and renders an M3 AlertDialog that picks up the app palette. The triggers
69− // themselves are idempotent per instance (crashes are acknowledged on first fire;
70− // notificationPermissionRequested is restored from saved state), so a plain
71− // mutableStateOf (not saveable) preserves the original dismissal semantics across
72− // config-change recreation: the prompt simply doesn't re-fire after a rotate.
73− private var showCrashPrompt by mutableStateOf(false)
74− private var showNotifRationale by mutableStateOf(false)
75− // Track whether the notification-permission prompt has been shown for this Activity
76− // instance. onCreate only requests when savedInstanceState == null (a fresh process),
77− // but an Activity recreated by a config change NOT listed in the manifest's configChanges
78− // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
79− // prompt forever. onResume re-requests once per instance when still ungranted so the user
80− // is never silently left without notifications after such a recreation.
81− private var notificationPermissionRequested = false
82−
83 override fun onCreate(savedInstanceState: Bundle?) {
19 override fun onCreate(savedInstanceState: Bundle?) {
84− val splash = installSplashScreen()
85 super.onCreate(savedInstanceState)
20 super.onCreate(savedInstanceState)
86 enableEdgeToEdge()
21 enableEdgeToEdge()
87− savedInstanceState?.let {
88− shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
89− openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
90− newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
91− openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
92− }
93− val container = (application as OpencodeApp).container
94− handleIntent(intent)
95− if (savedInstanceState == null) {
96− maybeRequestNotificationPermission()
97− } else {
98− // Restored instance: the permission may have been requested pre-recreation, but
99− // we can't know the result from here. onResume will re-check and request if still
100− // ungranted (once per Activity instance).
101− notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
102− }
103− // Hold the splash until the persisted theme and app-lock settings load, so we never
104− // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
105− // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
106− // before app lock resolves. Mirrors the SettingsScreen's null-gate.
107− var settingsLoaded = false
108− splash.setKeepOnScreenCondition { !settingsLoaded }
109− // Custom splash exit: a quick fade-out instead of the system default's slide, so the
110− // hand-off to Compose reads as a branded dissolve. The listener must call
111− // splashScreenViewProvider.remove() to release the splash view; otherwise the system
112− // keeps it on screen for its default duration. Reduced-motion is respected via the
113− // animator's duration scale (the system zeroes it under reduced motion).
114− splash.setOnExitAnimationListener { splashScreenViewProvider ->
115− val view = splashScreenViewProvider.view
116− val fade = android.view.animation.AlphaAnimation(1f, 0f).apply {
117− duration = 180
118− fillAfter = true
119− }
120− fade.setAnimationListener(object : android.view.animation.Animation.AnimationListener {
121− override fun onAnimationStart(animation: android.view.animation.Animation?) {}
122− override fun onAnimationEnd(animation: android.view.animation.Animation?) {
123− splashScreenViewProvider.remove()
124− }
125− override fun onAnimationRepeat(animation: android.view.animation.Animation?) {}
126− })
127− view.startAnimation(fade)
128− }
129 setContent {
22 setContent {
130− val theme by remember(container) {
23+ OpencodeTheme {
131− container.settingsStore.themeMode
24+ App()
132− .combine(container.settingsStore.dynamicColor, ::Pair)
133− }.collectAsStateWithLifecycle(initialValue = null)
134− val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
135− val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
136− .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
137− LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
138− // Hide the app's content from screenshots / the recents thumbnail while app lock is
139− // enabled — and while its value is still unknown (null) at cold start, so a protected
140− // launch can't flash into the recents thumbnail before the setting resolves.
141− LaunchedEffect(appLock) {
142− if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
143− else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
144− }
145− val themePair = theme
146− val locked = appLock
147− if (themePair != null && locked != null) {
148− val (themeMode, dynamicColor) = themePair
149− val amoled = themeMode == ThemeMode.AMOLED
150− val dark = when (themeMode) {
151− ThemeMode.SYSTEM -> isSystemInDarkTheme()
152− ThemeMode.LIGHT -> false
153− ThemeMode.DARK -> true
154− ThemeMode.AMOLED -> true
155− }
156− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
157− AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
158− OpencodeAppUi(container = container)
159− }
160− // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
161− // still surface over the lock screen, matching the prior framework-dialog
162− // behavior) and inside OpencodeTheme so they use the app palette.
163− if (showCrashPrompt) {
164− AlertDialog(
165− onDismissRequest = { showCrashPrompt = false },
166− title = { Text(stringResource(R.string.crash_last_run_title)) },
167− text = { Text(stringResource(R.string.crash_last_run_text)) },
168− confirmButton = {
169− TextButton(onClick = {
170− showCrashPrompt = false
171− container.requestDiagnostics()
172− }) { Text(stringResource(R.string.crash_last_run_view)) }
173− },
174− dismissButton = {
175− TextButton(onClick = { showCrashPrompt = false }) {
176− Text(stringResource(R.string.crash_last_run_dismiss))
177− }
178− },
179− )
180− }
181− if (showNotifRationale) {
182− AlertDialog(
183− // Mirrors the original setCancelable(false): not dismissible by
184− // back/outside tap, only by the buttons below.
185− onDismissRequest = { },
186− title = { Text(stringResource(R.string.notif_rationale_title)) },
187− text = { Text(stringResource(R.string.notif_rationale_text)) },
188− confirmButton = {
189− TextButton(onClick = {
190− showNotifRationale = false
191− runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
192− }) { Text(stringResource(R.string.notif_rationale_allow)) }
193− },
194− dismissButton = {
195− TextButton(onClick = { showNotifRationale = false }) {
196− Text(stringResource(R.string.notif_rationale_skip))
197− }
198− },
199− )
200− }
201− }
202− }
203− }
204− // Surface a "crashed last time" prompt if a crash report from a previous run
205− // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
206− // every rotation/resume; the user can always find reports in Settings → Diagnostics.
207− maybeShowCrashPrompt()
208− }
209−
210− private fun maybeShowCrashPrompt() {
211− // The check does a directory scan + per-file stat (listFiles + lastModified), which
212− // is synchronous filesystem I/O. Run it off the main thread so the cold-start
213− // critical path (before setContent's first frame) isn't blocked by up to ~20 stat
214− // syscalls on slow storage. The prompt state flips on once the result arrives; the
215− // splash screen has already been dismissed by then, so the dialog appears a frame
216− // or two later than it would have synchronously — invisible to the user.
217− val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
218− lifecycleScope.launch {
219− val hasUnacknowledged = withContext(Dispatchers.IO) {
220− crashLogger.hasUnacknowledgedCrash()
221− }
222− if (hasUnacknowledged) {
223− crashLogger.acknowledgeCrashes()
224− showCrashPrompt = true
225 }
25 }
226 }
26 }
227 }
27 }
28+}
228
29
229− override fun onNewIntent(intent: Intent) {
30+@Composable
230− super.onNewIntent(intent)
31+fun App(core: Core = viewModel()) {
231− setIntent(intent)
32+ val view by core.view.collectAsState()
232− shareIntentHandled = false
233− openSessionHandled = false
234− newSessionHandled = false
235− openFileHandled = false
236− handleIntent(intent)
237− }
238−
239− // Track real foreground state so background notifications (permission requests,
240− // completions, errors) still fire for a session the user "has open" but has walked
241− // away from — the chat screen isn't disposed when the phone is locked, so
242− // container.currentSession stays set and can't be used as a foreground signal.
243− override fun onStart() {
244− super.onStart()
245− (application as OpencodeApp).container.setForeground(true)
246− }
247−
248− override fun onStop() {
249− super.onStop()
250− (application as OpencodeApp).container.setForeground(false)
251− }
252−
253− override fun onResume() {
254− super.onResume()
255− isResumed = true
256− // Re-check on resume: an Activity recreated by a config change not in configChanges
257− // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
258− // ensures the user is prompted once per Activity instance when still ungranted rather
259− // than silently losing all notifications.
260− maybeRequestNotificationPermission()
261− // Surface a rejected deep link once, so a malformed opencode:// link doesn't appear
262− // to be silently ignored. Cleared after firing so a rotation doesn't re-toast. This
263− // covers the cold-start path (handleIntent ran from onCreate, before the activity was
264− // resumed, so it deferred the toast here). Warm-start rejections fire immediately from
265− // handleIntent and never set this flag.
266− if (showInvalidLinkToast) {
267− showInvalidLinkToast = false
268− showInvalidLinkToastNow()
269− }
270− }
271−
272− override fun onPause() {
273− super.onPause()
274− isResumed = false
275− }
276−
277− /** Fire the invalid-deep-link toast. Extracted so the cold-start (onResume) and warm-start
278− * (handleIntent) paths share a single implementation. */
279− private fun showInvalidLinkToastNow() {
280− android.widget.Toast.makeText(this, R.string.deep_link_invalid, android.widget.Toast.LENGTH_SHORT).show()
281− }
282−
283− /** Capture text shared from another app so it can be prefilled into a session draft,
284− * and session ids from notification taps / deep links so we can open them. */
285− private fun handleIntent(intent: Intent?) {
286− val container = (application as OpencodeApp).container
287− val action = intent?.action
288− // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
289− // process-death restore (which re-delivers the intent) doesn't spawn a second session.
290− if (action == ACTION_NEW_SESSION && !newSessionHandled) {
291− container.requestNewSession()
292− newSessionHandled = true
293− }
294− if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
295− val type = intent.type.orEmpty()
296− if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
297− // A single shared image → stage as an attachment in the next opened session.
298− val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
299− if (uri != null) {
300− container.setPendingSharedMedia(listOf(uri.toString()))
301− shareIntentHandled = true
302− }
303− } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
304− val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
305− ?.take(NetworkConfig.maxAttachments)
306− .orEmpty()
307− if (uris.isNotEmpty()) {
308− container.setPendingSharedMedia(uris.map { it.toString() })
309− shareIntentHandled = true
310− }
311− } else if (action == Intent.ACTION_SEND) {
312− // A non-image file (e.g. a PDF or text file shared from another app): stage it
313− // as an attachment too, so the user can send it to the agent. The attachment
314− // pipeline handles non-image mimes with a generic file icon. Falls back to
315− // EXTRA_TEXT when no stream is present (a plain text share).
316− val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
317− if (uri != null) {
318− container.setPendingSharedMedia(listOf(uri.toString()))
319− shareIntentHandled = true
320− } else {
321− val text = intent.getStringExtra(Intent.EXTRA_TEXT)
322− ?.takeIf { it.isNotBlank() }
323− ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
324− if (text != null) {
325− container.setPendingShare(text)
326− shareIntentHandled = true
327− }
328− }
329− } else {
330− val text = intent.getStringExtra(Intent.EXTRA_TEXT)
331− ?.takeIf { it.isNotBlank() }
332− ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
333− if (text != null) {
334− container.setPendingShare(text)
335− shareIntentHandled = true
336− }
337− }
338− }
339− // Deep links under the opencode:// scheme. The manifest exposes several hosts:
340− // session/{id} → open (and switch to) that conversation
341− // file/{path} → open the file viewer at that workspace path
342− // new → start a fresh session
343− // The file path is percent-encoded by the link source; decode it here and validate the
344− // segment to keep path-traversal out of the REST path (mirroring the session guard).
345− val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
346− when (data?.host) {
347− "session" -> {
348− val seg = data.lastPathSegment
349− if (!openSessionHandled) {
350− seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
351− val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
352− ?.takeIf { it.isNotBlank() }
353− container.requestOpenSession(it, profileId)
354− openSessionHandled = true
355− } ?: run {
356− // The link carried a session host but no valid id (or a blank/traversal
357− // attempt). Surface a toast so the user knows the link was rejected,
358− // rather than silently no-op'ing (which reads as "the app did nothing").
359− // On a warm start (already resumed) fire immediately; on a cold start
360− // defer to onResume so the toast doesn't race the first frame.
361− if (!seg.isNullOrBlank()) {
362− if (isResumed) showInvalidLinkToastNow() else showInvalidLinkToast = true
363− }
364− // Mark the link handled even on rejection, so a process-death-and-restore
365− // (which re-delivers the same intent) doesn't re-fire the toast on every
366− // restore — the rejection is final, not a transient state to retry.
367− openSessionHandled = true
368− }
369− }
370− }
371− "file" -> {
372− // Reconstruct the path from the URI. `file` is the URI *host* (authority), so
373− // data.path is the path AFTER the authority — e.g. `opencode://file/foo/bar.txt`
374− // yields data.path = "/foo/bar.txt", which begins with a leading slash that the
375− // server's file endpoints don't expect (the in-app browser uses server-returned
376− // FileNode.path values with no leading slash, e.g. "foo/bar.txt"). Strip the
377− // single leading slash, then percent-decode and validate against traversal/control
378− // characters. (Using pathSegments would also work but over-decodes `+` and other
379− // characters that aren't percent-encoded in real workspace paths.)
380− val rawPath = data.path?.let { p ->
381− val stripped = if (p.startsWith("/")) p.substring(1) else p
382− android.net.Uri.decode(stripped)
383− }
384− if (!openFileHandled) {
385− rawPath?.takeIf { isValidFilePath(it) }
386− ?.let { container.requestOpenFile(it); openFileHandled = true } ?: run {
387− if (!rawPath.isNullOrBlank()) {
388− // Same warm-vs-cold logic as the session branch above.
389− if (isResumed) showInvalidLinkToastNow() else showInvalidLinkToast = true
390− }
391− // Mark handled on rejection too, mirroring the session branch —
392− // otherwise a process-death restore re-fires the invalid-link toast.
393− openFileHandled = true
394− }
395− }
396− }
397− "new" -> {
398− if (!newSessionHandled) {
399− container.requestNewSession()
400− newSessionHandled = true
401− }
402− }
403− }
404− // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link
405− // was present at all. A deep link that matched a host but failed validation (e.g. a
406− // malformed session id) does NOT fall through to the extra: the intent's primary
407− // routing signal was the URI, and the extra may carry a different (valid) id that
408− // would silently open the wrong session. Only when data?.host is null (a pure widget
409− // / notification intent with no URI) does the extra carry the routing intent.
410− if (data?.host == null && !openSessionHandled) {
411− intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
412− val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
413− ?.takeIf { it.isNotBlank() }
414− container.requestOpenSession(it, profileId)
415− openSessionHandled = true
416− }
417− }
418− }
419−
420− companion object {
421− /** Intent extra carrying a session id to open (notifications / deep links / widget). */
422− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
423−
424− /** Action for the "New session" launcher shortcut and quick-settings tile. */
425− const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
426−
427− /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
428− private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
429−
430− /** A file path must not carry traversal segments or control characters. Looser than a
431− * positive allowlist: real workspace paths commonly contain `~`, `:`, `+`, `=`, `,`,
432− * parentheses, and non-ASCII unicode, all of which a tight `[A-Za-z0-9_\\-./ ]+`
433− * regex would reject. The server validates the path against the workspace root, so
434− * here we only reject what's unambiguously hostile (traversal) or non-printable. */
435− private fun isValidFilePath(path: String): Boolean {
436− if (path.isBlank()) return false
437− if (path.contains("..")) return false
438− if (path.any { it.isISOControl() }) return false
439− return true
440− }
441−
442− private const val KEY_SHARE_HANDLED = "shareIntentHandled"
443− private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
444− private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
445− private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
446− private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
447− }
448−
449− override fun onSaveInstanceState(outState: Bundle) {
450− super.onSaveInstanceState(outState)
451− val container = (application as OpencodeApp).container
452− // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
453− // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
454− // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
455− // consumed the signal, restore the flag as true so the fresh container never receives it —
456− // the deep-linked / new / shared session would silently never open. So AND each flag with
457− // "the container no longer holds the signal": still-pending at save time → persist false so
458− // the re-delivered intent re-dispatches it into the new container on restore; already
459− // consumed → persist true so we don't re-open it unexpectedly on every later restore.
460− val openConsumed = container.pendingOpenSession.value == null
461− val newConsumed = container.pendingNewSession.value == 0
462− val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
463− val fileConsumed = container.pendingOpenFile.value == null
464− outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
465− outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
466− outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
467− outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
468− outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
469− }
470−
471− /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
472− * Idempotent per Activity instance via [notificationPermissionRequested].
473− * Shows a rationale dialog first (explaining what the user gains) before the system
474− * permission prompt, so a user who denies understands what they're missing. */
475− private fun maybeRequestNotificationPermission() {
476− if (notificationPermissionRequested) return
477− if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
478− val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
479− PackageManager.PERMISSION_GRANTED
480− if (!granted) {
481− notificationPermissionRequested = true
482− showNotificationRationaleDialog()
483− }
484− }
485
33
486− private fun showNotificationRationaleDialog() {
34+ when (view.screen) {
487− showNotifRationale = true
35+ Screen.CONNECT -> ConnectScreen(core)
36+ Screen.SESSIONS -> SessionsScreen(core)
37+ Screen.CHAT -> ChatScreen(core)
488 }
38 }
489}
39}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktdeleted−130
@@ -1,130 +0,0 @@
1−package soy.iko.opencode
2−
3−import android.app.Application
4−import android.content.Context
5−import coil3.ImageLoader
6−import coil3.SingletonImageLoader
7−import coil3.disk.DiskCache
8−import coil3.memory.MemoryCache
9−import coil3.network.okhttp.OkHttpNetworkFetcherFactory
10−import okhttp3.Call
11−import okhttp3.CertificatePinner
12−import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
13−import okhttp3.OkHttpClient
14−import okhttp3.Request
15−import okio.Path.Companion.toOkioPath
16−import soy.iko.opencode.data.model.ServerProfile
17−import soy.iko.opencode.data.network.CertPinUtil
18−import soy.iko.opencode.data.repo.CrashLogger
19−import soy.iko.opencode.di.AppContainer
20−import java.util.concurrent.atomic.AtomicBoolean
21−
22−class OpencodeApp : Application(), SingletonImageLoader.Factory {
23− lateinit var container: AppContainer
24− private set
25−
26− private val shutdownCalled = AtomicBoolean(false)
27−
28− override fun onCreate() {
29− super.onCreate()
30− CrashLogger.get(this).install()
31− container = AppContainer(this)
32− // onTerminate() is only called in emulated process environments, not on real
33− // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
34− // scope cancellation, connection close) runs when the process is actually killed.
35− Runtime.getRuntime().addShutdownHook(Thread {
36− shutdown()
37− })
38− }
39−
40− // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
41− // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
42− // the (auth-protected) network every time. Data-URI (base64) images skip the disk
43− // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
44− //
45− // Route fetches through a Call.Factory that applies the SAME per-profile certificate
46− // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry the
47− // server's HTTP Basic auth header (see RemoteImage), so without this they would travel over
48− // an UNPINNED TLS connection, silently defeating the pin the user opted into for that host.
49− override fun newImageLoader(context: Context): ImageLoader = ImageLoader.Builder(context)
50− .memoryCache {
51− MemoryCache.Builder()
52− .maxSizePercent(context, 0.25)
53− .build()
54− }
55− .diskCache {
56− DiskCache.Builder()
57− .directory(cacheDir.resolve("image_cache").toOkioPath())
58− .maxSizeBytes(50L * 1024 * 1024)
59− .build()
60− }
61− .components {
62− // Route fetches through a Call.Factory that applies the SAME per-profile certificate
63− // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry
64− // the server's HTTP Basic auth header (see RemoteImage), so without this they would
65− // travel over an UNPINNED TLS connection, silently defeating the pin the user opted
66− // into for that host.
67− add(OkHttpNetworkFetcherFactory(callFactory = { PinnedImageCallFactory(container) }))
68− }
69− .build()
70−
71− override fun onTerminate() {
72− super.onTerminate()
73− shutdown()
74− }
75−
76− private fun shutdown() {
77− // Guard against double shutdown — onTerminate and the shutdown hook can both
78− // fire in emulated environments.
79− if (!shutdownCalled.compareAndSet(false, true)) return
80− container.shutdown()
81− }
82−}
83−
84−/**
85− * A Coil [Call.Factory] that pins image loads exactly like [HttpClientFactory] pins the
86− * REST/SSE channel. The [ImageLoader] is a process singleton built once, but the active
87− * [ServerProfile] — and therefore its pins — changes when the user switches servers, so the
88− * certificate pinner is resolved *per request* from [AppContainer.activeConnection] rather than
89− * baked in at build time. This fails closed: a profile that configures pins always gets a pinned
90− * connection for its images (an unparseable pinned host throws, matching HttpClientFactory), so
91− * image loads for a pinned host are never silently downgraded to an unpinned channel — and a
92− * later server switch is picked up immediately instead of retaining the first profile's pins.
93− */
94−private class PinnedImageCallFactory(private val container: AppContainer) : Call.Factory {
95− // A single shared base client; each derived pinned client reuses this one's connection pool
96− // and dispatcher via newBuilder(), so per-profile pinning doesn't spin up parallel resources.
97− private val base = OkHttpClient()
98−
99− // Cache the last derived client keyed by (host, pins) so we don't rebuild it on every request;
100− // a server switch changes the signature and triggers a rebuild. Guarded because Coil invokes
101− // newCall from arbitrary background threads, possibly concurrently.
102− private val lock = Any()
103− private var cachedSignature: String? = null
104− private var cachedClient: OkHttpClient = base
105−
106− override fun newCall(request: Request): Call =
107− clientFor(container.activeConnection.value?.profile).newCall(request)
108−
109− private fun clientFor(profile: ServerProfile?): OkHttpClient {
110− val pins = CertPinUtil.parseCertPins(profile?.certPin)
111− if (pins.isEmpty()) return base
112− // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
113− // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
114− // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
115− val effectiveUrl = CertPinUtil.effectiveBaseUrl(profile!!)
116− val host = effectiveUrl.toHttpUrlOrNull()?.host
117− ?: throw IllegalStateException(
118− "Certificate pinning is configured but the server host could not be parsed from $effectiveUrl",
119− )
120− val signature = "$host\n${pins.sorted().joinToString(" ")}"
121− synchronized(lock) {
122− if (signature != cachedSignature) {
123− val pinner = CertificatePinner.Builder().apply { pins.forEach { add(host, it) } }.build()
124− cachedClient = base.newBuilder().certificatePinner(pinner).build()
125− cachedSignature = signature
126− }
127− return cachedClient
128− }
129− }
130−}
app/src/main/java/soy/iko/opencode/core/Core.ktnew file+96
@@ -0,0 +1,96 @@
1+package soy.iko.opencode.core
2+
3+import androidx.lifecycle.viewModelScope
4+import kotlinx.coroutines.flow.MutableStateFlow
5+import kotlinx.coroutines.flow.StateFlow
6+import kotlinx.coroutines.flow.asStateFlow
7+import kotlinx.coroutines.launch
8+
9+/**
10+ * The bridge between the Crux core (Rust or pure-Kotlin fallback) and the
11+ * Jetpack Compose UI.
12+ *
13+ * - Sends [Event]s to the core via [update]
14+ * - Processes [Effect]s returned by the core (Render, Http)
15+ * - Exposes the current [ViewModel] as a [StateFlow] for Compose to observe
16+ */
17+class Core : androidx.lifecycle.ViewModel() {
18+ private val ffi: CoreFfi = CoreFfi.create()
19+ private val httpClient = HttpClient()
20+ private val sseClient = SseClient()
21+
22+ private val _view = MutableStateFlow(getInitialView())
23+ val view: StateFlow<ViewModel> = _view.asStateFlow()
24+
25+ init {
26+ // Emit Start event to initialize the model
27+ update(Event.Start)
28+
29+ // Subscribe to SSE events
30+ viewModelScope.launch {
31+ sseClient.events.collect { data ->
32+ update(Event.EventReceived(data))
33+ }
34+ }
35+ }
36+
37+ fun update(event: Event) {
38+ val effectsBytes = ffi.update(event.bincodeSerialize())
39+ val requests = Requests.bincodeDeserialize(effectsBytes).value
40+ for (request in requests) {
41+ processEffect(request)
42+ }
43+ }
44+
45+ private fun processEffect(request: Request) {
46+ when (val effect = request.effect) {
47+ is Effect.Render -> {
48+ _view.value = ViewModel.bincodeDeserialize(ffi.view())
49+ }
50+ is Effect.Http -> {
51+ handleHttpEffect(effect.value, request.id)
52+ }
53+ }
54+ }
55+
56+ private fun handleHttpEffect(request: HttpRequest, requestId: UInt) {
57+ viewModelScope.launch {
58+ val result = httpClient.request(request)
59+ val effectsBytes = ffi.resolve(requestId, result.bincodeSerialize())
60+ val requests = Requests.bincodeDeserialize(effectsBytes).value
61+ for (req in requests) {
62+ processEffect(req)
63+ }
64+ // Refresh the view after resolving
65+ _view.value = ViewModel.bincodeDeserialize(ffi.view())
66+
67+ // Connect SSE when we have a server URL and are connected
68+ val currentView = _view.value
69+ if (currentView.connected && currentView.currentSessionId != null) {
70+ sseClient.connect("${currentView.serverUrl}/event")
71+ }
72+ }
73+ }
74+
75+ private fun getInitialView(): ViewModel {
76+ return try {
77+ ViewModel.bincodeDeserialize(ffi.view())
78+ } catch (e: Throwable) {
79+ ViewModel(
80+ screen = Screen.CONNECT,
81+ serverUrl = "http://localhost:4096",
82+ connected = false,
83+ loading = false,
84+ error = null,
85+ sessions = emptyList(),
86+ currentSessionId = null,
87+ messages = emptyList(),
88+ draftMessage = "",
89+ )
90+ }
91+ }
92+
93+ override fun onCleared() {
94+ sseClient.disconnect()
95+ }
96+}
app/src/main/java/soy/iko/opencode/core/CoreFfi.ktnew file+44
@@ -0,0 +1,44 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * FFI interface to the Rust Crux core.
5+ *
6+ * When the native library [libopencode.so] is available, [NativeCoreFfi] is used.
7+ * Otherwise [PureCoreFfi] provides a pure-Kotlin reimplementation for development.
8+ */
9+interface CoreFfi {
10+ fun update(data: ByteArray): ByteArray
11+ fun resolve(id: UInt, data: ByteArray): ByteArray
12+ fun view(): ByteArray
13+
14+ companion object {
15+ fun create(): CoreFfi {
16+ return try {
17+ NativeCoreFfi()
18+ } catch (e: Throwable) {
19+ PureCoreFfi()
20+ }
21+ }
22+ }
23+}
24+
25+/**
26+ * JNI bridge to the Rust [libopencode.so] native library.
27+ * Generated by BoltFFI.
28+ */
29+private class NativeCoreFfi : CoreFfi {
30+ init {
31+ System.loadLibrary("opencode")
32+ }
33+
34+ private external fun nativeNew(): Long
35+ private external fun nativeUpdate(ptr: Long, data: ByteArray): ByteArray
36+ private external fun nativeResolve(ptr: Long, id: Int, data: ByteArray): ByteArray
37+ private external fun nativeView(ptr: Long): ByteArray
38+
39+ private val ptr: Long = nativeNew()
40+
41+ override fun update(data: ByteArray): ByteArray = nativeUpdate(ptr, data)
42+ override fun resolve(id: UInt, data: ByteArray): ByteArray = nativeResolve(ptr, id.toInt(), data)
43+ override fun view(): ByteArray = nativeView(ptr)
44+}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktnew file+74
@@ -0,0 +1,74 @@
1+package soy.iko.opencode.core
2+
3+import com.novi.serde.Bytes
4+import io.ktor.client.HttpClient as KtorHttpClient
5+import io.ktor.client.call.body
6+import io.ktor.client.engine.okhttp.OkHttp
7+import io.ktor.client.plugins.HttpTimeout
8+import io.ktor.client.request.headers
9+import io.ktor.client.request.request
10+import io.ktor.client.request.setBody
11+import io.ktor.client.statement.bodyAsText
12+import io.ktor.http.HttpMethod
13+import io.ktor.util.flattenEntries
14+import kotlinx.coroutines.Dispatchers
15+import kotlinx.coroutines.withContext
16+import java.net.SocketTimeoutException
17+import java.net.UnknownHostException
18+import kotlin.coroutines.cancellation.CancellationException
19+
20+/**
21+ * Executes HTTP requests on behalf of the Crux core.
22+ *
23+ * When the core emits an [Effect.Http], the shell calls [request] with the
24+ * [HttpRequest] and returns the [HttpResult] back to the core via [Core.resolve].
25+ */
26+class HttpClient {
27+ private val ktorHttpClient = KtorHttpClient(OkHttp) {
28+ install(HttpTimeout) {
29+ requestTimeoutMillis = 30_000
30+ connectTimeoutMillis = 15_000
31+ socketTimeoutMillis = 15_000
32+ }
33+ }
34+
35+ suspend fun request(request: HttpRequest): HttpResult = withContext(Dispatchers.IO) {
36+ try {
37+ val response = executeRequest(request)
38+ HttpResult.Ok(response)
39+ } catch (ce: CancellationException) {
40+ throw ce
41+ } catch (e: Throwable) {
42+ HttpResult.Err(toHttpError(e))
43+ }
44+ }
45+
46+ private suspend fun executeRequest(request: HttpRequest): HttpResponse {
47+ val response = ktorHttpClient.request(request.url) {
48+ this.method = HttpMethod.parse(request.method)
49+ this.headers {
50+ for (header in request.headers) {
51+ append(header.name, header.value)
52+ }
53+ }
54+ if (request.body.content.isNotEmpty()) {
55+ setBody(request.body.content)
56+ }
57+ }
58+ val bytes: ByteArray = response.body()
59+ val headers = response.headers
60+ .flattenEntries()
61+ .map { HttpHeader(it.first, it.second) }
62+ return HttpResponse(
63+ status = response.status.value.toUShort(),
64+ headers = headers,
65+ body = Bytes(bytes),
66+ )
67+ }
68+
69+ private fun toHttpError(error: Throwable): HttpError = when (error) {
70+ is SocketTimeoutException -> HttpError.Timeout
71+ is UnknownHostException -> HttpError.Io("Unknown host")
72+ else -> HttpError.Io(error.message ?: "HTTP request failed")
73+ }
74+}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.ktnew file+347
@@ -0,0 +1,347 @@
1+package soy.iko.opencode.core
2+
3+import com.novi.serde.Bytes
4+
5+/**
6+ * Pure-Kotlin fallback implementation of the Crux core.
7+ *
8+ * This mirrors the Rust [app.rs] logic so the app can run during development
9+ * without the compiled native library. When [libopencode.so] is available,
10+ * [NativeCoreFfi] is used instead and this class is never instantiated.
11+ */
12+internal class PureCoreFfi : CoreFfi {
13+ private val model = PureModel()
14+
15+ override fun update(data: ByteArray): ByteArray {
16+ val event = Event.bincodeDeserialize(data)
17+ val effects = model.handleEvent(event)
18+ return effects.bincodeSerialize()
19+ }
20+
21+ override fun resolve(id: UInt, data: ByteArray): ByteArray {
22+ val result = HttpResult.bincodeDeserialize(data)
23+ val effects = model.handleResult(id, result)
24+ return effects.bincodeSerialize()
25+ }
26+
27+ override fun view(): ByteArray {
28+ return model.currentView().bincodeSerialize()
29+ }
30+}
31+
32+internal data class PureModel(
33+ var serverUrl: String = "http://localhost:4096",
34+ var connected: Boolean = false,
35+ var loading: Boolean = false,
36+ var error: String? = null,
37+ var sessions: MutableList<PureSession> = mutableListOf(),
38+ var currentSessionId: String? = null,
39+ var messages: MutableList<PureMessage> = mutableListOf(),
40+ var draftMessage: String = "",
41+ var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
42+ var nextId: UInt = 0u,
43+) {
44+ fun handleEvent(event: Event): Requests {
45+ val requests = mutableListOf<Request>()
46+ when (event) {
47+ is Event.Start -> {
48+ serverUrl = "http://localhost:4096"
49+ }
50+ is Event.ServerUrlChanged -> {
51+ serverUrl = event.value
52+ }
53+ is Event.Connect -> {
54+ loading = true
55+ error = null
56+ val id = nextId++
57+ pendingRequests[id] = PendingRequest.Connect
58+ requests.add(
59+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/global/health"))),
60+ )
61+ }
62+ is Event.LoadSessions -> {
63+ loading = true
64+ val id = nextId++
65+ pendingRequests[id] = PendingRequest.LoadSessions
66+ requests.add(
67+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session"))),
68+ )
69+ }
70+ is Event.SelectSession -> {
71+ currentSessionId = event.value
72+ messages.clear()
73+ val id = nextId++
74+ pendingRequests[id] = PendingRequest.LoadMessages(event.value)
75+ requests.add(
76+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
77+ )
78+ }
79+ is Event.CreateSession -> {
80+ loading = true
81+ val id = nextId++
82+ pendingRequests[id] = PendingRequest.CreateSession
83+ requests.add(
84+ Request(id, Effect.Http(makePostRequest("${serverUrl}/session", ByteArray(0))),
85+ ))
86+ }
87+ is Event.LoadMessages -> {
88+ loading = true
89+ val id = nextId++
90+ pendingRequests[id] = PendingRequest.LoadMessages(event.value)
91+ requests.add(
92+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
93+ )
94+ }
95+ is Event.SendMessage -> {
96+ val sessionId = currentSessionId ?: return Requests(requests)
97+ draftMessage = ""
98+ val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
99+ .toByteArray()
100+ val id = nextId++
101+ pendingRequests[id] = PendingRequest.SendMessage(sessionId)
102+ requests.add(
103+ Request(
104+ id,
105+ Effect.Http(
106+ makePostRequest("${serverUrl}/session/$sessionId/prompt_async", body)
107+ .copy(headers = listOf(HttpHeader("Content-Type", "application/json"))),
108+ ),
109+ ),
110+ )
111+ }
112+ is Event.EventReceived -> {
113+ val sid = currentSessionId
114+ if (sid != null) {
115+ val id = nextId++
116+ pendingRequests[id] = PendingRequest.LoadMessages(sid)
117+ requests.add(
118+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
119+ )
120+ }
121+ }
122+ is Event.NavigateToChat -> {
123+ currentSessionId = event.value
124+ messages.clear()
125+ }
126+ is Event.NavigateToSessions -> {
127+ currentSessionId = null
128+ messages.clear()
129+ }
130+ is Event.DismissError -> {
131+ error = null
132+ }
133+ else -> {}
134+ }
135+ return Requests(requests)
136+ }
137+
138+ fun handleResult(id: UInt, result: HttpResult): Requests {
139+ val requests = mutableListOf<Request>()
140+ val pending = pendingRequests.remove(id) ?: return Requests(requests)
141+
142+ when (pending) {
143+ is PendingRequest.Connect -> {
144+ when (result) {
145+ is HttpResult.Ok -> {
146+ connected = true
147+ loading = false
148+ // Trigger LoadSessions
149+ val newId = nextId++
150+ pendingRequests[newId] = PendingRequest.LoadSessions
151+ requests.add(
152+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
153+ )
154+ }
155+ is HttpResult.Err -> {
156+ loading = false
157+ error = "Connection failed: ${result.value}"
158+ }
159+ }
160+ }
161+ is PendingRequest.LoadSessions -> {
162+ loading = false
163+ when (result) {
164+ is HttpResult.Ok -> {
165+ val body = result.value.body.content.decodeToString()
166+ sessions = parseSessions(body).toMutableList()
167+ connected = true
168+ }
169+ is HttpResult.Err -> {
170+ error = "Failed to load sessions: ${result.value}"
171+ }
172+ }
173+ }
174+ is PendingRequest.CreateSession -> {
175+ loading = false
176+ when (result) {
177+ is HttpResult.Ok -> {
178+ val body = result.value.body.content.decodeToString()
179+ val sid = extractJsonString(body, "id") ?: ""
180+ if (sid.isNotEmpty()) {
181+ currentSessionId = sid
182+ messages.clear()
183+ val newId = nextId++
184+ pendingRequests[newId] = PendingRequest.LoadMessages(sid)
185+ requests.add(
186+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
187+ )
188+ }
189+ }
190+ is HttpResult.Err -> {
191+ error = "Failed to create session: ${result.value}"
192+ }
193+ }
194+ }
195+ is PendingRequest.LoadMessages -> {
196+ loading = false
197+ when (result) {
198+ is HttpResult.Ok -> {
199+ val body = result.value.body.content.decodeToString()
200+ messages = parseMessages(body).toMutableList()
201+ }
202+ is HttpResult.Err -> {
203+ error = "Failed to load messages: ${result.value}"
204+ }
205+ }
206+ }
207+ is PendingRequest.SendMessage -> {
208+ when (result) {
209+ is HttpResult.Ok -> {
210+ val sid = pending.sessionId
211+ val newId = nextId++
212+ pendingRequests[newId] = PendingRequest.LoadMessages(sid)
213+ requests.add(
214+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
215+ )
216+ }
217+ is HttpResult.Err -> {
218+ error = "Failed to send message: ${result.value}"
219+ }
220+ }
221+ }
222+ }
223+ return Requests(requests)
224+ }
225+
226+ fun currentView(): ViewModel {
227+ val screen = when {
228+ currentSessionId != null -> Screen.CHAT
229+ connected -> Screen.SESSIONS
230+ else -> Screen.CONNECT
231+ }
232+ return ViewModel(
233+ screen = screen,
234+ serverUrl = serverUrl,
235+ connected = connected,
236+ loading = loading,
237+ error = error,
238+ sessions = sessions.map { SessionView(it.id, it.title) },
239+ currentSessionId = currentSessionId,
240+ messages = messages.map { MessageView(it.id, it.role, it.text, it.time) },
241+ draftMessage = draftMessage,
242+ )
243+ }
244+
245+ private fun makeGetRequest(url: String): HttpRequest =
246+ HttpRequest(
247+ method = "GET",
248+ url = url,
249+ headers = emptyList(),
250+ body = Bytes(ByteArray(0)),
251+ )
252+
253+ private fun makePostRequest(url: String, body: ByteArray): HttpRequest =
254+ HttpRequest(
255+ method = "POST",
256+ url = url,
257+ headers = emptyList(),
258+ body = Bytes(body),
259+ )
260+}
261+
262+internal sealed class PendingRequest {
263+ data object Connect : PendingRequest()
264+ data object LoadSessions : PendingRequest()
265+ data object CreateSession : PendingRequest()
266+ data class LoadMessages(val sessionId: String) : PendingRequest()
267+ data class SendMessage(val sessionId: String) : PendingRequest()
268+}
269+
270+internal data class PureSession(val id: String, val title: String)
271+
272+internal data class PureMessage(val id: String, val role: String, val text: String, val time: ULong)
273+
274+internal fun parseSessions(json: String): List<PureSession> {
275+ val sessions = mutableListOf<PureSession>()
276+ var depth = 0
277+ var start = 0
278+ for (i in json.indices) {
279+ when (json[i]) {
280+ '{' -> {
281+ if (depth == 0) start = i
282+ depth++
283+ }
284+ '}' -> {
285+ depth--
286+ if (depth == 0) {
287+ val obj = json.substring(start, i + 1)
288+ val id = extractJsonString(obj, "id") ?: ""
289+ val title = extractJsonString(obj, "title") ?: ""
290+ if (id.isNotEmpty()) {
291+ sessions.add(PureSession(id, if (title.isNotEmpty()) title else "Untitled"))
292+ }
293+ }
294+ }
295+ }
296+ }
297+ return sessions
298+}
299+
300+internal fun parseMessages(json: String): List<PureMessage> {
301+ val messages = mutableListOf<PureMessage>()
302+ var depth = 0
303+ var start = 0
304+ for (i in json.indices) {
305+ when (json[i]) {
306+ '{' -> {
307+ if (depth == 0) start = i
308+ depth++
309+ }
310+ '}' -> {
311+ depth--
312+ if (depth == 0) {
313+ val obj = json.substring(start, i + 1)
314+ val id = extractJsonString(obj, "id") ?: ""
315+ val role = extractJsonString(obj, "role") ?: ""
316+ val time = extractJsonNumber(obj, "created") ?: 0uL
317+ if (id.isNotEmpty() && role.isNotEmpty()) {
318+ messages.add(PureMessage(id, role, "", time))
319+ }
320+ }
321+ }
322+ }
323+ }
324+ return messages
325+}
326+
327+internal fun extractJsonString(json: String, key: String): String? {
328+ val pattern = "\"$key\""
329+ val idx = json.indexOf(pattern) ?: return null
330+ val rest = json.substring(idx + pattern.length)
331+ val colon = rest.indexOf(':') ?: return null
332+ val rest2 = rest.substring(colon + 1)
333+ val quote = rest2.indexOf('"') ?: return null
334+ val rest3 = rest2.substring(quote + 1)
335+ val end = rest3.indexOf('"') ?: return null
336+ return rest3.substring(0, end)
337+}
338+
339+internal fun extractJsonNumber(json: String, key: String): ULong? {
340+ val pattern = "\"$key\""
341+ val idx = json.indexOf(pattern) ?: return null
342+ val rest = json.substring(idx + pattern.length)
343+ val colon = rest.indexOf(':') ?: return null
344+ val rest2 = rest.substring(colon + 1).trimStart()
345+ val end = rest2.indexOfFirst { !it.isDigit() }.let { if (it == -1) rest2.length else it }
346+ return rest2.substring(0, end).toULongOrNull()
347+}
app/src/main/java/soy/iko/opencode/core/SseClient.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode.core
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.okhttp.OkHttp
5+import io.ktor.client.plugins.HttpTimeout
6+import io.ktor.client.request.prepareGet
7+import io.ktor.client.statement.bodyAsChannel
8+import io.ktor.utils.io.readUTF8Line
9+import kotlinx.coroutines.CoroutineScope
10+import kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.Job
12+import kotlinx.coroutines.SupervisorJob
13+import kotlinx.coroutines.flow.MutableSharedFlow
14+import kotlinx.coroutines.flow.SharedFlow
15+import kotlinx.coroutines.flow.asSharedFlow
16+import kotlinx.coroutines.launch
17+
18+/**
19+ * Subscribes to the opencode server's SSE event stream.
20+ *
21+ * Events are emitted as raw strings on [events]. The Core converts them into
22+ * [Event.EventReceived] which triggers a message reload.
23+ */
24+class SseClient {
25+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
26+ private val httpClient = HttpClient(OkHttp) {
27+ install(HttpTimeout) {
28+ requestTimeoutMillis = Long.MAX_VALUE
29+ connectTimeoutMillis = 15_000
30+ socketTimeoutMillis = Long.MAX_VALUE
31+ }
32+ }
33+
34+ private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
35+ val events: SharedFlow<String> = _events.asSharedFlow()
36+
37+ private var currentJob: Job? = null
38+
39+ fun connect(url: String) {
40+ currentJob?.cancel()
41+ currentJob = scope.launch {
42+ try {
43+ httpClient.prepareGet(url).execute { response ->
44+ val channel = response.bodyAsChannel()
45+ val dataBuilder = StringBuilder()
46+ while (!channel.isClosedForRead) {
47+ val line = channel.readUTF8Line() ?: break
48+ if (line.startsWith("data: ")) {
49+ dataBuilder.append(line.substring(6))
50+ } else if (line.isEmpty() && dataBuilder.isNotEmpty()) {
51+ _events.emit(dataBuilder.toString())
52+ dataBuilder.clear()
53+ }
54+ }
55+ }
56+ } catch (_: Throwable) {
57+ // Connection lost — the Core will retry on next event cycle
58+ }
59+ }
60+ }
61+
62+ fun disconnect() {
63+ currentJob?.cancel()
64+ currentJob = null
65+ }
66+}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktdeleted−37
@@ -1,37 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Entry from `GET /agent`. */
7−@Immutable
8−@Serializable
9−data class Agent(
10− // Defaulted so one name-less element in GET /agent doesn't fail the whole list decode
11− // (emptying the agent picker) — matches the resilient-decoding philosophy. A blank name is
12− // degraded but recoverable; a thrown decode is not.
13− val name: String = "",
14− val description: String? = null,
15− val mode: String? = null,
16− val builtIn: Boolean = false,
17− val prompt: String? = null,
18−) {
19− val isPrimary: Boolean get() = mode == "primary"
20− val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
21−}
22−
23−/** Entry from `GET /command`. */
24−@Immutable
25−@Serializable
26−data class Command(
27− // Defaulted so one name-less element in GET /command doesn't fail the whole list decode
28− // (emptying the command picker) — matches the resilient-decoding philosophy.
29− val name: String = "",
30− val description: String? = null,
31− val agent: String? = null,
32− val model: String? = null,
33− val template: String = "",
34− val subtask: Boolean = false,
35−) {
36− val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
37−}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktdeleted−109
@@ -1,109 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/**
7− * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8− * Discriminated on `type` (the global discriminator). Unknown events decode to
9− * [UnknownEvent] so the stream never dies on a new event type.
10− *
11− * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12− * drift across opencode versions; the reducer only reads the fields it needs.
13− */
14−@Serializable
15−sealed interface BusEvent
16−
17−@Serializable
18−@SerialName("server.connected")
19−data object ServerConnected : BusEvent
20−
21−@Serializable
22−@SerialName("message.updated")
23−data class MessageUpdated(val properties: Props) : BusEvent {
24− @Serializable
25− data class Props(val info: MessageInfo? = null)
26−}
27−
28−@Serializable
29−@SerialName("message.part.updated")
30−data class MessagePartUpdated(val properties: Props) : BusEvent {
31− @Serializable
32− data class Props(
33− val part: Part,
34− val sessionID: String? = null,
35− val messageID: String? = null,
36− )
37−}
38−
39−@Serializable
40−@SerialName("message.part.removed")
41−data class MessagePartRemoved(val properties: Props) : BusEvent {
42− @Serializable
43− data class Props(
44− val sessionID: String? = null,
45− val messageID: String? = null,
46− val partID: String? = null,
47− )
48−}
49−
50−@Serializable
51−@SerialName("message.removed")
52−data class MessageRemoved(val properties: Props) : BusEvent {
53− @Serializable
54− data class Props(
55− val sessionID: String? = null,
56− val messageID: String? = null,
57− )
58−}
59−
60−@Serializable
61−@SerialName("session.idle")
62−data class SessionIdle(val properties: Props) : BusEvent {
63− @Serializable
64− data class Props(val sessionID: String? = null)
65−}
66−
67−@Serializable
68−@SerialName("session.error")
69−data class SessionError(val properties: Props) : BusEvent {
70− @Serializable
71− data class Props(
72− val sessionID: String? = null,
73− val error: kotlinx.serialization.json.JsonElement? = null,
74− )
75−}
76−
77−@Serializable
78−@SerialName("session.updated")
79−data class SessionUpdated(val properties: Props) : BusEvent {
80− @Serializable
81− data class Props(val info: Session? = null, val sessionID: String? = null)
82−}
83−
84−@Serializable
85−@SerialName("session.deleted")
86−data class SessionDeleted(val properties: Props) : BusEvent {
87− @Serializable
88− data class Props(val info: Session? = null, val sessionID: String? = null)
89−}
90−
91−@Serializable
92−@SerialName("permission.updated")
93−data class PermissionUpdated(val properties: Permission) : BusEvent
94−
95−@Serializable
96−@SerialName("permission.replied")
97−data class PermissionReplied(val properties: Props) : BusEvent {
98− @Serializable
99− data class Props(
100− val sessionID: String? = null,
101− val permissionID: String? = null,
102− val response: String? = null,
103− )
104−}
105−
106−/** Fallback for any event type we don't model. */
107−@Serializable
108−@SerialName("__unknown")
109−data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktdeleted−41
@@ -1,41 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−@Immutable
7−@Serializable
8−data class TimeInfo(
9− val created: Long? = null,
10− val updated: Long? = null,
11− val completed: Long? = null,
12−)
13−
14−/** Timing carried by message parts and tool states. The opencode server sends `time: { start,
15− * end?, compacted? }` for these (unlike Session/MessageInfo/Permission, which use the
16− * created/updated/completed shape of [TimeInfo]). With `ignoreUnknownKeys`, modelling these
17− * fields with [TimeInfo] would silently discard start/end/compacted — every part/tool time
18− * would decode to all-nulls — so this separate shape exists for them. */
19−@Immutable
20−@Serializable
21−data class PartTimeInfo(
22− val start: Long? = null,
23− val end: Long? = null,
24− val compacted: Long? = null,
25−)
26−
27−@Immutable
28−@Serializable
29−data class Tokens(
30− val input: Long = 0,
31− val output: Long = 0,
32− val reasoning: Long = 0,
33− val cache: Cache = Cache(),
34−) {
35− @Serializable
36− @Immutable
37− data class Cache(
38− val read: Long = 0,
39− val write: Long = 0,
40− )
41−}
app/src/main/java/soy/iko/opencode/data/model/Files.ktdeleted−44
@@ -1,44 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Entry from `GET /file?path=` (directory listing). */
7−@Immutable
8−@Serializable
9−data class FileNode(
10− val name: String = "",
11− val path: String = "",
12− val type: String? = null,
13− // Size in bytes and last-modified epoch-ms. The server doesn't currently emit these, so
14− // they default to null and the browser renders nothing extra; optional + defaulted keeps
15− // the client forward-compatible (resilient decoding) so the moment the server adds them
16− // the listing shows size/date with no client change.
17− val size: Long? = null,
18− val mtime: Long? = null,
19−) {
20− val isDirectory: Boolean get() = type == "directory"
21−}
22−
23−/** Response of `GET /file/content?path=`. */
24−@Immutable
25−@Serializable
26−data class FileContent(
27− val type: String? = null, // "text" | "binary"
28− val content: String = "",
29− val diff: String? = null,
30− val mimeType: String? = null,
31− val encoding: String? = null,
32−) {
33− val isBinary: Boolean get() = type == "binary" || encoding == "base64"
34−}
35−
36−/** Entry from `GET /file/status` (VCS status). */
37−@Immutable
38−@Serializable
39−data class FileStatusEntry(
40− val path: String = "",
41− val added: Int = 0,
42− val removed: Int = 0,
43− val status: String? = null, // "added" | "deleted" | "modified"
44−)
app/src/main/java/soy/iko/opencode/data/model/Message.ktdeleted−69
@@ -1,69 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonClassDiscriminator
8−
9−/**
10− * The `info` object of a message. Discriminated on `role`, which differs from the
11− * global `type` discriminator used by [Part], so this hierarchy overrides it.
12− */
13−@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14−@Serializable
15−@JsonClassDiscriminator("role")
16−sealed interface MessageInfo {
17− val id: String
18− val sessionID: String
19− val time: TimeInfo?
20−}
21−
22−@Immutable
23−@Serializable
24−@SerialName("user")
25−data class UserMessage(
26− override val id: String = "",
27− override val sessionID: String = "",
28− override val time: TimeInfo? = null,
29−) : MessageInfo
30−
31−@Immutable
32−@Serializable
33−@SerialName("assistant")
34−data class AssistantMessage(
35− override val id: String = "",
36− override val sessionID: String = "",
37− val parentID: String? = null,
38− val providerID: String? = null,
39− val modelID: String? = null,
40− val cost: Double? = null,
41− val tokens: Tokens? = null,
42− // JsonElement (not JsonObject) so a server that sends `error` as a bare string/array — or any
43− // non-object shape — decodes instead of throwing mid-AssistantMessage (the MessageInfo
44− // polymorphic fallback can't catch that, since `role` already matched). Mirrors
45− // SessionError.error. Not currently read by the UI, but kept tolerant for forward-compat.
46− val error: JsonElement? = null,
47− override val time: TimeInfo? = null,
48−) : MessageInfo {
49− /** A run is complete once the server stamps a completion time. */
50− val isComplete: Boolean get() = time?.completed != null
51−}
52−
53−/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
54−@Immutable
55−@Serializable
56−@SerialName("__unknown")
57−data class UnknownMessage(
58− override val id: String = "",
59− override val sessionID: String = "",
60− override val time: TimeInfo? = null,
61−) : MessageInfo
62−
63−/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
64−@Immutable
65−@Serializable
66−data class MessageWithParts(
67− val info: MessageInfo,
68− val parts: List<Part> = emptyList(),
69−)
app/src/main/java/soy/iko/opencode/data/model/Part.ktdeleted−127
@@ -1,127 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonObject
8−import kotlinx.serialization.json.JsonPrimitive
9−
10−/**
11− * A message part — a discriminated union on `type` (the global class discriminator).
12− * Unknown types decode to [UnknownPart] via the registered polymorphic default,
13− * so the stream survives server additions.
14− *
15− * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
16− * uses to attach streamed parts to the right message.
17− */
18−@Serializable
19−sealed interface Part {
20− val id: String
21− val messageID: String?
22− val sessionID: String?
23−}
24−
25−@Immutable
26−@Serializable
27−@SerialName("text")
28−data class TextPart(
29− override val id: String = "",
30− override val messageID: String? = null,
31− override val sessionID: String? = null,
32− val text: String = "",
33− val synthetic: Boolean = false,
34− val ignored: Boolean = false,
35− val time: PartTimeInfo? = null,
36−) : Part
37−
38−@Immutable
39−@Serializable
40−@SerialName("reasoning")
41−data class ReasoningPart(
42− override val id: String = "",
43− override val messageID: String? = null,
44− override val sessionID: String? = null,
45− val text: String = "",
46− val time: PartTimeInfo? = null,
47−) : Part
48−
49−@Immutable
50−@Serializable
51−@SerialName("tool")
52−data class ToolPart(
53− override val id: String = "",
54− override val messageID: String? = null,
55− override val sessionID: String? = null,
56− val callID: String = "",
57− val tool: String = "",
58− val state: ToolState = ToolPending,
59−) : Part
60−
61−@Immutable
62−@Serializable
63−@SerialName("file")
64−data class FilePart(
65− override val id: String = "",
66− override val messageID: String? = null,
67− override val sessionID: String? = null,
68− val mime: String? = null,
69− val filename: String? = null,
70− val url: String? = null,
71− // opencode's message-v2 schema sends `source` as a structured FilePartSource OBJECT
72− // ({ type, path, text, ... }), not a string, for @-file mentions. Typing it as String
73− // threw a SerializationException on decode — which the SSE path silently swallows, so the
74− // whole part vanished. Keep it as a tolerant JsonElement (mirroring Permission.pattern) and
75− // read the path via [sourcePath]; a bare-string source is still tolerated.
76− val source: JsonElement? = null,
77−) : Part
78−
79−/** The file path referenced by a [FilePart.source]. opencode sends `source` as a FilePartSource
80− * object whose `path` field holds the file path; older/other shapes may send a bare string.
81− * Returns null when there's no usable path. */
82−val FilePart.sourcePath: String?
83− get() = when (val s = source) {
84− is JsonPrimitive -> if (s.isString) s.content else null
85− is JsonObject -> (s["path"] as? JsonPrimitive)?.let { if (it.isString) it.content else null }
86− else -> null
87− }
88−
89−@Immutable
90−@Serializable
91−@SerialName("step-start")
92−data class StepStartPart(
93− override val id: String = "",
94− override val messageID: String? = null,
95− override val sessionID: String? = null,
96−) : Part
97−
98−@Immutable
99−@Serializable
100−@SerialName("agent")
101−data class AgentPart(
102− override val id: String = "",
103− override val messageID: String? = null,
104− override val sessionID: String? = null,
105− val name: String = "",
106−) : Part
107−
108−@Immutable
109−@Serializable
110−@SerialName("step-finish")
111−data class StepFinishPart(
112− override val id: String = "",
113− override val messageID: String? = null,
114− override val sessionID: String? = null,
115− val cost: Double? = null,
116− val tokens: Tokens? = null,
117−) : Part
118−
119−/** Fallback for an unrecognized `type`. */
120−@Immutable
121−@Serializable
122−@SerialName("__unknown")
123−data class UnknownPart(
124− override val id: String = "",
125− override val messageID: String? = null,
126− override val sessionID: String? = null,
127−) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktdeleted−58
@@ -1,58 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.json.JsonElement
6−import kotlinx.serialization.json.JsonArray
7−import kotlinx.serialization.json.JsonPrimitive
8−
9−/**
10− * A permission request from `permission.updated`. The tool detail lives in [type] +
11− * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
12− * it's kept as a [JsonElement] and flattened for display.
13− */
14−@Immutable
15−@Serializable
16−data class Permission(
17− val id: String = "",
18− val sessionID: String = "",
19− val type: String? = null,
20− val pattern: JsonElement? = null,
21− val messageID: String? = null,
22− val callID: String? = null,
23− val title: String? = null,
24− // JsonElement (not JsonObject) so a non-object `metadata` shape decodes instead of throwing,
25− // matching [pattern]. Not read by the UI today, but kept tolerant for forward-compat.
26− val metadata: JsonElement? = null,
27− val time: TimeInfo? = null,
28−) {
29− val patternText: String?
30− get() = when (val p = pattern) {
31− null -> null
32− is JsonPrimitive -> p.content
33− // Only a JSON array is flattened; any other shape (e.g. an object) would make
34− // the previous unguarded `p.jsonArray` throw during composition, so treat it
35− // as "no displayable pattern" instead.
36− is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
37− else -> null
38− }
39−}
40−
41−/** Allowed values for the permission response body.
42− *
43− * [SESSION] is a client-side scope: it auto-responds [ONCE] to subsequent permission requests
44− * whose (type, pattern) match a remembered set, without showing the dialog. The set lives in
45− * [soy.iko.opencode.ui.chat.ChatViewModel] and is cleared on connection change. It is NOT sent
46− * to the server (the server only knows `once`/`always`/`reject`); the VM maps it to [ONCE] on
47− * the wire and tracks the grant locally. This gives users a middle ground between "allow once"
48− * (re-prompts on every file read) and "always" (persists in the server config outliving the app). */
49−enum class PermissionResponse(val wire: String) {
50− ONCE("once"),
51− ALWAYS("always"),
52− REJECT("reject"),
53− SESSION("once"),
54−}
55−
56−/** Body for `POST /session/:id/permissions/:permissionID`. */
57−@Serializable
58−data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Project.ktdeleted−32
@@ -1,32 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/**
7− * A project known to the server, from `GET /project`. A project corresponds to a git
8− * worktree root; [worktree] is the absolute path the agent can run in. Used to populate
9− * the directory picker when creating a session in a specific directory.
10− */
11−@Immutable
12−@Serializable
13−data class Project(
14− val id: String = "",
15− val worktree: String = "",
16− val vcs: String? = null,
17− val time: TimeInfo? = null,
18−)
19−
20−/**
21− * The server's current path info, from `GET /path`. [directory] is the working directory
22− * a request with no `directory` override resolves to (the server's launch cwd), shown in
23− * the picker as the "server default" option.
24− */
25−@Immutable
26−@Serializable
27−data class PathInfo(
28− val directory: String? = null,
29− val worktree: String? = null,
30− val state: String? = null,
31− val config: String? = null,
32−)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktdeleted−68
@@ -1,68 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/** Body for `POST /session/:id/message`. */
7−@Serializable
8−data class PromptRequest(
9− val parts: List<PromptPart>,
10− val model: ModelRef? = null,
11− val agent: String? = null,
12−)
13−
14−/**
15− * An input part for a prompt — a discriminated union on `type` (the global class
16− * discriminator). We compose text and file (image/document attachment) parts; the server
17− * models more (agent, subtask) that this client doesn't send.
18− */
19−@Serializable
20−sealed interface PromptPart
21−
22−@Serializable
23−@SerialName("text")
24−data class TextPromptPart(val text: String) : PromptPart
25−
26−/**
27− * A file/image attachment. [url] must be a data URL carrying the base64 payload —
28− * `data:<mime>;base64,<bytes>` — which is how the server embeds media for the model to
29− * see (see opencode's message-v2.ts). [mime] must match the payload; the server forwards
30− * it as the model's mediaType.
31− */
32−@Serializable
33−@SerialName("file")
34−data class FilePromptPart(
35− val mime: String,
36− val url: String,
37− val filename: String? = null,
38−) : PromptPart
39−
40−/** Body for `POST /session/:id/command`. */
41−@Serializable
42−data class CommandRequest(
43− val command: String,
44− val arguments: String = "",
45− val agent: String? = null,
46−)
47−
48−/** Body for `POST /session/:id/revert`. */
49−@Serializable
50−data class RevertRequest(
51− val messageID: String,
52− val partID: String? = null,
53−)
54−
55−/** Body for `POST /session/:id/summarize`. */
56−@Serializable
57−data class SummarizeRequest(
58− val providerID: String,
59− val modelID: String,
60−)
61−
62−/** Body for `POST /session/:id/shell`. */
63−@Serializable
64−data class ShellRequest(
65− val agent: String,
66− val command: String,
67− val model: ModelRef? = null,
68−)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktdeleted−84
@@ -1,84 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Response of `GET /config/providers`. */
7−@Immutable
8−@Serializable
9−data class ProvidersResponse(
10− val providers: List<Provider> = emptyList(),
11− val default: Map<String, String> = emptyMap(),
12−)
13−
14−@Immutable
15−@Serializable
16−data class Provider(
17− // Defaulted so one id-less element in GET /config/providers doesn't fail the whole list decode
18− // (emptying the model picker) — matches MessageInfo's defaulted id and the resilient-decoding
19− // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
20− val id: String = "",
21− val name: String? = null,
22− val models: Map<String, ModelInfo> = emptyMap(),
23−) {
24− val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
25−}
26−
27−@Immutable
28−@Serializable
29−data class ModelInfo(
30− val id: String? = null,
31− val name: String? = null,
32−) {
33− fun displayName(modelKey: String): String =
34− name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
35−}
36−
37−/** A flattened provider/model selection used by the prompt body and the model picker. */
38−@Immutable
39−@Serializable
40−data class ModelRef(
41− val providerID: String,
42− val modelID: String,
43−)
44−
45−/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
46−@Immutable
47−data class ModelOption(
48− val providerID: String,
49− val modelID: String,
50− val providerLabel: String,
51− val modelLabel: String,
52−) {
53− val ref: ModelRef get() = ModelRef(providerID, modelID)
54−}
55−
56−/** Flatten providers into a sorted list of pickable options. */
57−fun ProvidersResponse.toOptions(): List<ModelOption> =
58− providers.flatMap { provider ->
59− provider.models.map { (modelKey, info) ->
60− ModelOption(
61− providerID = provider.id,
62− modelID = info.id ?: modelKey,
63− providerLabel = provider.displayName,
64− modelLabel = info.displayName(modelKey),
65− )
66− }
67− }.sortedWith(
68− // CASE_INSENSITIVE_ORDER compares in place; compareBy { it.x.lowercase() } would
69− // allocate a fresh lowercased string on every comparison (O(N log N) allocations).
70− compareBy<ModelOption, String>(String.CASE_INSENSITIVE_ORDER) { it.providerLabel }
71− .thenBy(String.CASE_INSENSITIVE_ORDER) { it.modelLabel },
72− )
73−
74−/** The default option to preselect: the server's default for some provider, else the first model. */
75−fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
76− // Try each configured provider default in turn and take the first whose model actually
77− // survives in `options` (the list may be filtered). Checking only the first `default` entry
78− // would mispreselect an arbitrary first option whenever that one provider's default model
79− // isn't present, ignoring the other configured defaults.
80− for ((providerID, modelID) in default) {
81− options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
82− }
83− return options.firstOrNull()
84−}
app/src/main/java/soy/iko/opencode/data/model/Search.ktdeleted−101
@@ -1,101 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−
7−/**
8− * One ripgrep match from `GET /find?pattern=` (content search). [path] and [lines] are
9− * wrapped `{ text }` objects on the wire; [submatches] carry the column offsets of each
10− * hit within the line so the UI can highlight them.
11− */
12−@Immutable
13−@Serializable
14−data class FindMatch(
15− val path: TextField = TextField(),
16− val lines: TextField = TextField(),
17− @SerialName("line_number") val lineNumber: Int = 0,
18− @SerialName("absolute_offset") val absoluteOffset: Long = 0,
19− val submatches: List<SubMatch> = emptyList(),
20−) {
21− @Immutable
22− @Serializable
23− data class TextField(val text: String = "")
24−
25− @Immutable
26− @Serializable
27− data class SubMatch(
28− val start: Int = 0,
29− val end: Int = 0,
30− )
31−
32− /** The matched file's path (relative to the search root). */
33− val filePath: String get() = path.text
34−
35− /** The matching line's text, trimmed of a trailing newline for display. */
36− val lineText: String get() = lines.text.trimEnd('\n', '\r')
37−}
38−
39−/**
40− * One workspace symbol from `GET /find/symbol?query=`. [kind] is the LSP `SymbolKind`
41− * integer; [location.uri] is a file URI and [location.range] the symbol's span.
42− */
43−@Immutable
44−@Serializable
45−data class SymbolResult(
46− val name: String = "",
47− val kind: Int = 0,
48− val location: Location = Location(),
49−) {
50− @Immutable
51− @Serializable
52− data class Location(
53− val uri: String = "",
54− val range: Range = Range(),
55− )
56−
57− @Immutable
58− @Serializable
59− data class Range(
60− val start: Pos = Pos(),
61− val end: Pos = Pos(),
62− )
63−
64− @Immutable
65− @Serializable
66− data class Pos(
67− val line: Int = 0,
68− val character: Int = 0,
69− )
70−
71− /** The file path portion of [Location.uri], stripping a `file://` scheme when present.
72− * Handles both empty-authority (`file:///abs/path`) and non-empty-authority
73− * (`file://host/abs/path`) URIs by parsing the URI and taking its path component,
74− * rather than a prefix strip that would leave the authority glued to the path. */
75− val filePath: String
76− get() {
77− val uri = location.uri
78− // Only parse file: URIs; for anything else, return as-is.
79− if (!uri.startsWith("file:")) return uri
80− return runCatching { java.net.URI(uri).path }
81− .getOrNull()
82− ?.takeIf { it.isNotBlank() }
83− ?: uri.removePrefix("file://").ifBlank { uri }
84− }
85−
86− /** 1-based line for display; the wire range is 0-based. */
87− val displayLine: Int get() = location.range.start.line + 1
88−}
89−
90−// LSP SymbolKind → label. A map lookup (rather than a large `when`) keeps the accessor
91−// trivial. Indices follow the LSP SymbolKind enum.
92−private val symbolKindLabels = mapOf(
93− 1 to "file", 2 to "module", 3 to "namespace", 4 to "package", 5 to "class",
94− 6 to "method", 7 to "property", 8 to "field", 9 to "constructor", 10 to "enum",
95− 11 to "interface", 12 to "function", 13 to "variable", 14 to "constant", 15 to "string",
96− 16 to "number", 17 to "boolean", 18 to "array", 19 to "object", 20 to "key",
97− 22 to "enum member", 23 to "struct", 26 to "type",
98−)
99−
100−/** Human-readable label for an LSP [SymbolResult.kind]. */
101−fun symbolKindLabel(kind: Int): String = symbolKindLabels[kind] ?: "symbol"
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktdeleted−27
@@ -1,27 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−
5−/**
6− * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
7− * separately from EncryptedSharedPreferences and is null when not loaded/needed.
8− */
9−@Immutable
10−data class ServerProfile(
11− val id: String,
12− val label: String,
13− val baseUrl: String,
14− val username: String? = null,
15− val password: String? = null,
16− val lastUsed: Long = 0,
17− // When true, the client upgrades a cleartext http:// URL to https:// so credentials and
18− // traffic are never sent over the wire in the clear for this server.
19− val requireHttps: Boolean = false,
20− // Optional OkHttp certificate pin(s) ("sha256/<base64>", whitespace/comma-separated for
21− // multiple) applied to this server's TLS connection for defense against a rogue CA.
22− val certPin: String? = null,
23−) {
24− val hasAuth: Boolean get() = !username.isNullOrBlank()
25−
26− val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
27−}
app/src/main/java/soy/iko/opencode/data/model/Session.ktdeleted−68
@@ -1,68 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** A session as returned by `GET /session` and `POST /session`. */
7−@Immutable
8−@Serializable
9−data class Session(
10− // Defaulted so one id-less element in GET /session doesn't fail the whole list decode
11− // (emptying the session list) — matches MessageInfo's defaulted id and the resilient-decoding
12− // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
13− val id: String = "",
14− val parentID: String? = null,
15− val title: String? = null,
16− val version: String? = null,
17− val time: TimeInfo? = null,
18− // The absolute worktree/directory the agent runs in for this session. Set server-side
19− // from the `directory` query param at creation; persisted per session so the client
20− // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
21− val directory: String? = null,
22− val projectID: String? = null,
23− // Present only while the session is shared (POST /session/:id/share); cleared on unshare.
24− val share: Share? = null,
25− // Present only while a revert checkpoint is active (POST /session/:id/revert); cleared on unrevert.
26− val revert: Revert? = null,
27−) {
28− val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
29−
30− /** A compact form of [directory] for list/subtitle display: the last path segment,
31− * or the full path when it has no separators. Null/blank when no directory is set. */
32− val displayDirectory: String?
33− get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
34−
35− /** True while the session has an active public share link. */
36− val isShared: Boolean get() = !share?.url.isNullOrBlank()
37−
38− /** True while a revert checkpoint is active (messages after it are hidden server-side). */
39− val isReverted: Boolean get() = revert != null
40−
41− /** Public share link for the session, from `POST /session/:id/share`. */
42− @Immutable
43− @Serializable
44− data class Share(val url: String = "")
45−
46− /** The active revert checkpoint, from `POST /session/:id/revert`. */
47− @Immutable
48− @Serializable
49− data class Revert(
50− val messageID: String = "",
51− val partID: String? = null,
52− val snapshot: String? = null,
53− val diff: String? = null,
54− )
55−}
56−
57−/** Body for `POST /session`. */
58−@Serializable
59−data class CreateSessionRequest(
60− val parentID: String? = null,
61− val title: String? = null,
62−)
63−
64−/** Body for `PATCH /session/:id`. */
65−@Serializable
66−data class UpdateSessionRequest(
67− val title: String? = null,
68−)
app/src/main/java/soy/iko/opencode/data/model/Todo.ktdeleted−125
@@ -1,125 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.builtins.ListSerializer
6−import kotlinx.serialization.json.Json
7−import kotlinx.serialization.json.JsonElement
8−
9−/** The tool name opencode uses to write the agent's task plan. */
10−const val TODO_WRITE_TOOL = "todowrite"
11−
12−/**
13− * A single entry in an agent's task plan, as written by opencode's `todowrite` tool.
14− * Every field is defaulted and decoding ignores unknown keys (see [parseTodos]) so a
15− * change to the tool's payload shape degrades to a best-effort render instead of
16− * dropping the whole plan.
17− */
18−@Immutable
19−@Serializable
20−data class TodoItem(
21− val id: String = "",
22− val content: String = "",
23− val status: String = "pending",
24− val priority: String? = null,
25−)
26−
27−/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
28−enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
29−
30−/** Normalized priority of a [TodoItem]; [NONE] covers null/unmodelled priorities. The order
31− * matters: HIGH > MEDIUM > LOW, used by [TodoPlan] tinting and a11y labels. */
32−enum class TodoPriority { HIGH, MEDIUM, LOW, NONE }
33−
34−fun TodoItem.priorityEnum(): TodoPriority = when ((priority ?: "").trim().lowercase()) {
35− "high", "h", "1", "urgent", "critical" -> TodoPriority.HIGH
36− "medium", "med", "m", "normal", "2" -> TodoPriority.MEDIUM
37− "low", "l", "3", "minor", "backlog" -> TodoPriority.LOW
38− else -> TodoPriority.NONE
39−}
40−
41−fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
42− "pending", "todo", "" -> TodoStatus.PENDING
43− "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
44− "completed", "complete", "done" -> TodoStatus.COMPLETED
45− "cancelled", "canceled", "skipped" -> TodoStatus.CANCELLED
46− else -> TodoStatus.UNKNOWN
47−}
48−
49−@Serializable
50−private data class TodoWriteInput(val todos: List<TodoItem> = emptyList())
51−
52−// A lenient decoder mirroring OpencodeJson's resilience (unknown keys ignored, nulls
53−// coerced to defaults) without pulling the network layer into data/model.
54−private val todoJson = Json {
55− ignoreUnknownKeys = true
56− isLenient = true
57− coerceInputValues = true
58− explicitNulls = false
59−}
60−
61−/**
62− * Extract the todo list from a `todowrite` tool call's input JSON. The tool sends the
63− * full list on every write, so a single call's input is a complete plan snapshot.
64− * Accepts either the documented `{ "todos": [...] }` object or a bare array, and returns
65− * an empty list on anything unparseable.
66− */
67−fun parseTodos(input: JsonElement?): List<TodoItem> {
68− if (input == null) return emptyList()
69− return runCatching {
70− todoJson.decodeFromJsonElement(TodoWriteInput.serializer(), input).todos
71− }.recoverCatching {
72− todoJson.decodeFromJsonElement(ListSerializer(TodoItem.serializer()), input)
73− }.getOrDefault(emptyList())
74−}
75−
76−/**
77− * The current task plan for a conversation: the todos of the most recent `todowrite`
78− * tool call that carries a non-empty, parseable list. opencode rewrites the whole list
79− * on every write, so a single call is a complete snapshot; scanning newest-first and
80− * skipping empties avoids a mid-stream write (input not yet arrived) briefly blanking a
81− * plan that's still valid.
82− *
83− * A caller-side cache ([TodoPlanCache]) memoizes the result against the input list's
84− * identity so a back-to-back emission of the same list instance (e.g. a `combine` that
85− * re-evaluates without a real upstream change) skips the O(messages×parts) scan. The
86− * scan still runs whenever the list instance changes (every streamed snapshot), but
87− * `distinctUntilChanged` downstream dedupes the *result* so the UI doesn't recompose.
88− */
89−fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
90− for (mi in messages.indices.reversed()) {
91− val parts = messages[mi].parts
92− for (pi in parts.indices.reversed()) {
93− val part = parts[pi]
94− if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
95− val todos = parseTodos(part.state.inputElement())
96− if (todos.isNotEmpty()) return todos
97− }
98− }
99− }
100− return emptyList()
101−}
102−
103−/**
104− * A tiny memoization layer for [currentTodoPlan]: if the same list instance is passed
105− * twice in a row, the cached result is returned without re-scanning. The [ChatViewModel]
106− * `todoPlan` flow's `combine` can re-emit the same list instance when an unrelated input
107− * (e.g. the optimistic-messages StateFlow) updates without the SSE stream having changed;
108− * without this cache, every such re-emit re-scans every part of every message off-main.
109− *
110− * Not thread-safe by design — it's only read from the `flowOn(Dispatchers.Default)`
111− * operator's coroutine, which is single-threaded per emission. The cache holds a single
112− * `(ref, result)` pair; a different list instance replaces it.
113− */
114−class TodoPlanCache {
115− private var lastRef: List<MessageWithParts>? = null
116− private var lastResult: List<TodoItem> = emptyList()
117−
118− fun plan(messages: List<MessageWithParts>): List<TodoItem> {
119− if (messages === lastRef) return lastResult
120− val result = currentTodoPlan(messages)
121− lastRef = messages
122− lastResult = result
123− return result
124− }
125−}
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktdeleted−62
@@ -1,62 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonClassDiscriminator
8−
9−/**
10− * The state machine of a tool call, discriminated on `status`.
11− * pending -> running -> completed | error
12− */
13−@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14−@Serializable
15−@JsonClassDiscriminator("status")
16−sealed interface ToolState
17−
18−@Serializable
19−@SerialName("pending")
20−data object ToolPending : ToolState
21−
22−@Immutable
23−@Serializable
24−@SerialName("running")
25−data class ToolRunning(
26− val input: JsonElement? = null,
27− val title: String? = null,
28− val time: PartTimeInfo? = null,
29−) : ToolState
30−
31−@Immutable
32−@Serializable
33−@SerialName("completed")
34−data class ToolCompleted(
35− val input: JsonElement? = null,
36− val output: String? = null,
37− val title: String? = null,
38− val time: PartTimeInfo? = null,
39−) : ToolState
40−
41−@Immutable
42−@Serializable
43−@SerialName("error")
44−data class ToolError(
45− val input: JsonElement? = null,
46− val error: String? = null,
47− val time: PartTimeInfo? = null,
48−) : ToolState
49−
50−/** Fallback for an unrecognized `status`. */
51−@Serializable
52−@SerialName("__unknown")
53−data object ToolUnknown : ToolState
54−
55−/** The raw input arguments carried by a tool call, present on the running/completed/error
56− * states (pending/unknown carry none). */
57−fun ToolState.inputElement(): JsonElement? = when (this) {
58− is ToolRunning -> input
59− is ToolCompleted -> input
60− is ToolError -> input
61− else -> null
62−}
app/src/main/java/soy/iko/opencode/data/network/CertPinUtil.ktdeleted−35
@@ -1,35 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−
5−/**
6− * Shared helpers for resolving a [ServerProfile]'s certificate-pin field and the
7− * http→https upgrade it implies. The logic is used in three places that must agree:
8− * [HttpClientFactory] (the REST/SSE channel), [soy.iko.opencode.OpencodeApp]'s
9− * PinnedImageCallFactory (the image-loading channel), and [soy.iko.opencode.ui.components.RemoteImage]
10− * (resolving image base URLs). Previously each call site carried its own private copy
11− * with a "must be kept in sync" comment; the helpers here are the single source of truth.
12− */
13−internal object CertPinUtil {
14−
15− /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
16− * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
17− fun parseCertPins(raw: String?): List<String> =
18− raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
19−
20− /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
21− * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer.
22− * A configured certificate pin also forces TLS: a pin is meaningless over cleartext and
23− * would otherwise be silently dropped, so we honor the opt-in security control by upgrading
24− * rather than connecting unpinned in the clear. Relies on [HttpClientFactory.normalizeBaseUrl]
25− * for scheme/trailing-slash normalization. */
26− fun effectiveBaseUrl(profile: ServerProfile): String {
27− val normalized = HttpClientFactory.normalizeBaseUrl(profile.baseUrl)
28− val forceHttps = profile.requireHttps || parseCertPins(profile.certPin).isNotEmpty()
29− return if (forceHttps && normalized.lowercase().startsWith("http://")) {
30− "https://" + normalized.substring("http://".length)
31− } else {
32− normalized
33− }
34− }
35−}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktdeleted−360
@@ -1,360 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.util.safeExceptionSummary
5−import android.util.Log
6−import io.ktor.client.HttpClient
7−import io.ktor.client.plugins.timeout
8−import io.ktor.client.plugins.sse.sse
9−import io.ktor.sse.ServerSentEvent
10−import kotlinx.coroutines.CancellationException
11−import kotlinx.coroutines.CoroutineScope
12−import kotlin.coroutines.EmptyCoroutineContext
13−import kotlinx.coroutines.channels.BufferOverflow
14−import kotlinx.coroutines.channels.Channel
15−import kotlinx.coroutines.channels.ChannelResult
16−import kotlinx.coroutines.flow.Flow
17−import kotlinx.coroutines.flow.MutableStateFlow
18−import kotlinx.coroutines.flow.SharedFlow
19−import kotlinx.coroutines.flow.StateFlow
20−import kotlinx.coroutines.flow.SharingStarted
21−import kotlinx.coroutines.flow.asStateFlow
22−import kotlinx.coroutines.flow.buffer
23−import kotlinx.coroutines.flow.channelFlow
24−import kotlinx.coroutines.flow.shareIn
25−import kotlinx.coroutines.isActive
26−import kotlinx.coroutines.launch
27−import kotlinx.coroutines.selects.onTimeout
28−import kotlinx.coroutines.selects.select
29−import java.io.IOException
30−import kotlin.random.Random
31−
32−/**
33− * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
34− * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
35− * subscribes (WhileSubscribed) and reconnects with backoff on drop.
36− *
37− * Ordering contract: callers that send a prompt must already be collecting [events]
38− * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
39− */
40−@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
41−open class EventStreamClient(
42− private val client: HttpClient,
43− private val scope: CoroutineScope,
44− private val idleTimeoutMs: Long = NetworkConfig.sseIdleTimeoutMs,
45− private val initialBackoffMs: Long = NetworkConfig.sseInitialBackoffMs,
46− private val maxBackoffMs: Long = NetworkConfig.sseMaxBackoffMs,
47− private val bufferCapacity: Int = NetworkConfig.sseEventBufferCapacity,
48−) {
49− protected constructor() : this(
50− HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
51− CoroutineScope(EmptyCoroutineContext),
52− )
53− enum class ConnectionState { Disconnected, Connecting, Connected, Failed, AuthFailed }
54−
55− private val _state = MutableStateFlow(ConnectionState.Disconnected)
56− open val state: StateFlow<ConnectionState> = _state.asStateFlow()
57−
58− /** Number of reconnect attempts made since the last successful [ConnectionState.Connected]
59− * period. Incremented each time the stream loop begins a new connection attempt after a
60− * drop, and reset to 0 on a successful connect. Surfaced so the UI can show "Reconnecting
61− * (attempt N)…" during a sustained outage, giving the user confidence the system is
62− * actively retrying rather than stuck. */
63− private val _reconnectAttempts = MutableStateFlow(0)
64− open val reconnectAttempts: StateFlow<Int> = _reconnectAttempts.asStateFlow()
65−
66− open val events: SharedFlow<BusEvent> by lazy {
67− stream()
68− .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
69− .shareIn(
70− scope = scope,
71− started = SharingStarted.WhileSubscribed(stopTimeoutMillis = NetworkConfig.stateFlowSubscriptionTimeoutMs),
72− replay = 0,
73− )
74− }
75−
76− /** Conflated channel used to cut the reconnect backoff short when network returns. */
77− private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
78−
79− /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
80− * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
81− * reads "check the server URL and version". Returns null for transient/retryable errors.
82− * SSL/TLS and certificate-pinning failures are permanent (a changed/rotated cert won't
83− * heal on retry) and are classified as Other so the user sees "check the server URL and
84− * version / cert pin" instead of an infinite "Reconnecting…" spinner. */
85− private enum class SseFailureKind { Auth, Other }
86−
87− private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
88− generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
89− .take(16)
90− .mapNotNull { t ->
91− when (t) {
92− is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
93− is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
94− // A TLS handshake / cert-pinning / SSL peer unverified failure happens
95− // before any HTTP response, so it carries no status code. It is never
96− // transient (the server's cert must change), so classify it directly
97− // instead of falling through to the null (retryable) default — otherwise
98− // the client retries forever with backoff against an unreachable pinned
99− // server, showing "Reconnecting…" with no hint that the failure is fixed.
100− is javax.net.ssl.SSLException -> -1
101− is java.security.cert.CertificateException -> -1
102− else -> null
103− }
104− }
105− // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
106− // The -1 sentinel (SSL/cert) is also permanent → Other.
107− .map { code -> when {
108− code == -1 -> SseFailureKind.Other
109− code in 400..499 && code != 408 && code != 429 ->
110− if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
111− else -> null
112− } }
113− .firstOrNull { it != null }
114−
115− /** Request an immediate reconnect, skipping any in-progress backoff. */
116− open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
117−
118− /**
119− * Read SSE events from [events], racing each read against an idle-timeout watchdog.
120− * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
121− * clean stream close or scope cancellation.
122− *
123− * If the idle watchdog tripped because a [triggerReconnect] signal was consumed
124− * (rather than a genuine idle timeout), [onReconnectConsumed] is invoked so the outer
125− * reconnect loop can reconnect immediately instead of waiting out the backoff — the
126− * signal is already consumed, so the backoff `select` couldn't be cut short.
127− */
128− private suspend fun readSseEvents(
129− scope: CoroutineScope,
130− events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
131− onReconnectConsumed: () -> Unit,
132− send: suspend (BusEvent) -> Unit,
133− ) {
134− while (scope.isActive) {
135− // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
136− // read), so a socket the peer never closed would otherwise hang until OS TCP
137− // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
138− // within the idle timeout, treat the connection as half-open and drop it.
139− var consumedReconnectSignal = false
140− val result: ChannelResult<ServerSentEvent>? = select {
141− events.onReceiveCatching { it }
142− onTimeout(idleTimeoutMs) { null }
143− // A triggerReconnect() issued while a read is active must interrupt it now:
144− // return the same null sentinel as the idle timeout so a half-open socket
145− // (no TCP RST) is dropped and reconnected promptly instead of waiting out
146− // idleTimeoutMs (~90s).
147− reconnectSignal.onReceive { consumedReconnectSignal = true; null }
148− }
149− if (result == null) {
150− if (consumedReconnectSignal) onReconnectConsumed()
151− throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
152− }
153− val sse = result.getOrNull()
154− if (sse == null) {
155− // The producer channel is closed. A non-null cause means `incoming`
156− // failed (e.g. a socket reset bridged from the producer) — rethrow it so
157− // the outer loop logs and retries with backoff. A clean close (null
158− // cause) just ends this attempt and lets the loop reconnect.
159− result.exceptionOrNull()?.let { throw it }
160− break
161− }
162− val data = sse.data
163− if (data == null) {
164− // A comment or keep-alive event (no data field) resets the idle watchdog
165− // — servers send `: ping` comments to keep the connection alive through
166− // proxies. Without this, a server that only sends comments would still
167− // hit the idle timeout and reconnect unnecessarily.
168− continue
169− }
170− val event = try {
171− OpencodeJson.decodeFromString(BusEvent.serializer(), data)
172− } catch (e: kotlinx.serialization.SerializationException) {
173− Log.d("EventStream", "Skipping unparseable SSE event", e)
174− continue
175− }
176− if (_state.value != ConnectionState.Connected) {
177− _state.value = ConnectionState.Connected
178− }
179− send(event)
180− }
181− }
182−
183− private fun stream(): Flow<BusEvent> = channelFlow {
184− val scope = this
185− // Drain any stale reconnect signal from a previous collection cycle so it
186− // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
187− // stopped the upstream while triggerReconnect() queued a signal).
188− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
189− var backoffMs = initialBackoffMs
190− // Tracks whether this is the first iteration (the initial connect) vs a reconnect,
191− // so the attempt counter is only incremented on reconnects (not the first connect).
192− var firstAttempt = true
193− while (isActive) {
194− // Increment the reconnect-attempt counter on every iteration except the initial
195− // connect, so the UI can show "Reconnecting (attempt N)…". Reset to 0 on success.
196− if (firstAttempt) firstAttempt = false else _reconnectAttempts.value++
197− _state.value = ConnectionState.Connecting
198− // Set by readSseEvents when a triggerReconnect() signal interrupted an active
199− // read. The signal is consumed to drop the socket, so the backoff select below
200− // can't also see it — without this flag the reconnect would wait the full
201− // backoff despite the caller asking for an immediate reconnect (network
202− // handoff, manual refresh, ...).
203− var reconnectConsumedDuringRead = false
204− try {
205− // The SSE stream is long-lived: disable the request-level and socket
206− // timeouts (the client default is 60s for REST) in the request config
207− // so the connection isn't killed mid-stream. The idle-timeout watchdog
208− // below handles half-open connections instead.
209− client.sse(
210− "event",
211− request = {
212− timeout {
213− requestTimeoutMillis = Long.MAX_VALUE
214− // Disable the socket (read) timeout entirely for the SSE stream.
215− // A finite value would kill the connection whenever the server
216− // is quiet for longer than that (idle sessions, slow agent runs),
217− // causing a reconnect storm. Half-open detection is handled by
218− // the idle-timeout watchdog below plus OkHttp's pingInterval on
219− // HTTP/2 connections.
220− socketTimeoutMillis = Long.MAX_VALUE
221− }
222− },
223− ) {
224− // The SSE block is open — the connection is established. Mark as
225− // Connected immediately so downstream re-seed logic fires even when
226− // the server only sends keep-alive comments (no data events).
227− if (_state.value != ConnectionState.Connected) {
228− _state.value = ConnectionState.Connected
229− }
230− // Clear any stale reconnect signal from a triggerReconnect() call
231− // made while the previous stream was healthy, so it doesn't suppress
232− // the next legitimate backoff.
233− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
234− // Bridge ktor's `incoming` flow to a channel so each element can be
235− // raced against the idle-timeout watchdog via select. We collect it
236− // ourselves instead of `incoming.produceIn(scope)`: produceIn launches
237− // the collector as a child of this channelFlow, so when `incoming`
238− // throws on an abnormal socket close — a TCP reset, i.e. the common
239− // mobile case: Wi-Fi⇄cellular handoff, server or proxy RST — the child's
240− // failure cancels the whole channelFlow and bypasses the retry `catch`
241− // below. That killed reconnect outright and surfaced the IOException as
242− // an uncaught crash through the (handler-less) shareIn scope. Collecting
243− // into our own channel and closing it with the cause keeps the failure
244− // local: readSseEvents sees the closed channel, rethrows the cause, and
245− // the normal retry/backoff path handles it. (A clean EOF closes the
246− // channel with no cause → readSseEvents breaks and reconnects as before.)
247− val events = Channel<ServerSentEvent>(Channel.BUFFERED)
248− val producer = scope.launch {
249− try {
250− incoming.collect { events.send(it) }
251− events.close()
252− } catch (c: CancellationException) {
253− events.close(c)
254− throw c
255− } catch (t: Throwable) {
256− // Deliver the failure via the channel rather than letting it
257− // propagate up and cancel the parent channelFlow.
258− events.close(t)
259− }
260− }
261− // Reset the backoff only once real data actually arrives, not on
262− // block entry: a server that accepts the connection then immediately
263− // drops it would otherwise loop forever at the initial backoff and
264− // never escalate the exponential ramp.
265− var receivedAny = false
266− try {
267− readSseEvents(scope, events, { reconnectConsumedDuringRead = true }) {
268− if (!receivedAny) {
269− receivedAny = true
270− backoffMs = initialBackoffMs
271− // A genuine event confirms the connection is healthy, so reset
272− // the reconnect-attempt counter for the next outage's count.
273− _reconnectAttempts.value = 0
274− }
275− send(it)
276− }
277− } finally {
278− producer.cancel()
279− events.cancel()
280− }
281− }
282− } catch (c: CancellationException) {
283− _state.value = ConnectionState.Disconnected
284− throw c
285− } catch (e: Exception) {
286− // If the scope was cancelled (e.g. connection close), the closed-client
287− // exception is expected — don't log a spurious "stream error" warning.
288− val failureKind = classifyNonRetryableSseFailure(e)
289− if (failureKind != null) {
290− // The server rejected the request with a non-retryable 4xx (bad
291− // credentials, missing endpoint, ...) — retrying won't help. Log a
292− // scrubbed summary (class + status) instead of the full exception, whose
293− // message carries the request URL and may include auth or paths.
294− Log.w("EventStream", "SSE non-retryable 4xx (${if (failureKind == SseFailureKind.Auth) "auth" else "endpoint"}), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
295− _state.value = if (failureKind == SseFailureKind.Auth) ConnectionState.AuthFailed else ConnectionState.Failed
296− if (!isActive) {
297− // Final teardown (last subscriber left) while a 4xx surfaced: end in
298− // Disconnected like every other loop-exit path. Failed must stay
299− // observable only while the flow is live/retrying, not after teardown.
300− _state.value = ConnectionState.Disconnected
301− break
302− }
303− // Don't complete the flow: a subscriber may fix the profile and call
304− // triggerReconnect(). Suspend until an explicit reconnect signal
305− // arrives (no backoff delay), then re-attempt the connection. This receive
306− // is outside the try above, so reset state to Disconnected if the last
307− // subscriber leaves (cancellation) while we're parked here — otherwise the
308− // stream would be torn down with state stuck at Failed.
309− try {
310− reconnectSignal.receive()
311− } catch (c: CancellationException) {
312− _state.value = ConnectionState.Disconnected
313− throw c
314− }
315− // Reset the reconnect-attempt counter: the user explicitly triggered this
316− // reconnect (fixed credentials, manual refresh, ...), so it's a fresh
317− // attempt — not a continuation of the pre-failure outage. Without this,
318− // firstAttempt is already false and line 196 increments the counter on
319− // every manual reconnect, growing "Reconnecting (attempt N)" unboundedly
320− // across repeated 4xx → fix → retry cycles.
321− _reconnectAttempts.value = 0
322− continue
323− } else if (isActive) {
324− Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
325− }
326− }
327− _state.value = ConnectionState.Disconnected
328− if (!isActive) break
329− // Wait for the backoff, but allow a reconnect signal to cut it short. When
330− // readSseEvents consumed a triggerReconnect() signal to drop an active read,
331− // the signal is already spent so the select below can't receive it — pass a
332− // zero backoff in that case so the reconnect is immediate (the whole point of
333− // triggerReconnect: network handoff, manual refresh, ...).
334− val signaled = waitForBackoffOrSignal(reconnectConsumedDuringRead, backoffMs)
335− backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
336− }
337− }
338−
339− /** Suspend for the backoff unless [reconnectConsumedDuringRead] (immediate) or a new
340− * [triggerReconnect] arrives during the wait. Returns true when the reconnect should
341− * skip the backoff ramp (signal-driven), false when the full backoff elapsed. */
342− private suspend fun waitForBackoffOrSignal(
343− reconnectConsumedDuringRead: Boolean,
344− backoffMs: Long,
345− ): Boolean {
346− if (reconnectConsumedDuringRead) return true
347− var signaled = false
348− val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
349− // Floor the wait at half the initial backoff: symmetric jitter can otherwise pull a
350− // small backoff down to 0ms, producing a sub-second reconnect storm right after a
351− // triggerReconnect reset on a connect-RST loop. A non-zero floor preserves the
352− // purpose of backoff (spacing retries) while still allowing jitter to shorten it.
353− val minWait = (initialBackoffMs / 2).coerceAtLeast(1L)
354− select {
355− reconnectSignal.onReceive { signaled = true }
356− onTimeout((backoffMs + jitter).coerceAtLeast(minWait)) { /* normal backoff elapsed */ }
357− }
358− return signaled
359− }
360−}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktdeleted−178
@@ -1,178 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import io.ktor.client.HttpClient
5−import io.ktor.client.engine.okhttp.OkHttp
6−import io.ktor.client.plugins.HttpTimeout
7−import io.ktor.client.plugins.api.Send
8−import io.ktor.client.plugins.api.createClientPlugin
9−import io.ktor.client.plugins.auth.Auth
10−import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
11−import io.ktor.client.plugins.auth.providers.basic
12−import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
13−import io.ktor.client.plugins.defaultRequest
14−import io.ktor.client.plugins.sse.SSE
15−import io.ktor.http.HttpHeaders
16−import io.ktor.http.URLBuilder
17−import io.ktor.http.takeFrom
18−import io.ktor.serialization.kotlinx.json.json
19−import okhttp3.CertificatePinner
20−import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
21−import java.util.concurrent.TimeUnit
22−
23−/**
24− * Builds a Ktor client bound to a single [ServerProfile]. One client per active
25− * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
26− * SSE plugin used for `GET /event`.
27− */
28−object HttpClientFactory {
29−
30− fun create(profile: ServerProfile): HttpClient {
31− val normalizedUrl = CertPinUtil.effectiveBaseUrl(profile)
32− val isHttps = normalizedUrl.lowercase().startsWith("https://")
33− // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
34− // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
35− // but the host can't be parsed, fail closed: never build an unpinned client — that would
36− // be a silent security downgrade for a user who opted into pinning.
37− val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
38− val pins = CertPinUtil.parseCertPins(profile.certPin)
39− if (pins.isNotEmpty() && isHttps && pinHost == null) {
40− throw IllegalStateException(
41− "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
42− )
43− }
44− // Fail closed for HTTP+auth with an unparseable host too: otherwise neither the HTTPS Auth
45− // plugin nor the HTTP host-scoped header plugin is installed (the HTTP branch below is
46− // gated on pinHost != null), so credentials would be configured but never transmitted —
47− // hasAuth reads true in the UI while every request goes out unauthenticated. An unparseable
48− // host generally can't connect anyway, but failing here surfaces a clear error instead of a
49− // silent auth bypass.
50− if (profile.hasAuth && !isHttps && pinHost == null) {
51− throw IllegalStateException(
52− "Auth is configured but the server host could not be parsed from $normalizedUrl",
53− )
54− }
55−
56− return HttpClient(OkHttp) {
57− expectSuccess = true
58−
59− engine {
60− config {
61− // A normal read timeout for REST calls. The SSE stream overrides this
62− // to infinite inside the sse {} request block (see EventStreamClient),
63− // so the long-lived event connection is never killed mid-stream.
64− readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
65− connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
66− retryOnConnectionFailure(true)
67− pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
68− // Pin the server certificate(s) when configured and connecting over TLS.
69− // A malformed pin throws here (surfaced as a connection failure), which is
70− // the right fail-closed behavior for a security control the user opted into.
71− if (pins.isNotEmpty() && pinHost != null && isHttps) {
72− certificatePinner(
73− CertificatePinner.Builder().apply { pins.forEach { add(pinHost, it) } }.build(),
74− )
75− }
76− }
77− }
78−
79− install(ContentNegotiation) {
80− json(OpencodeJson)
81− }
82−
83− install(SSE)
84−
85− // Request-level timeout for REST calls. The SSE stream overrides this to
86− // infinite inside the sse {} request block (see EventStreamClient) so the
87− // long-lived /event connection isn't killed after 60s. The engine's connect
88− // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
89− install(HttpTimeout) {
90− requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
91− }
92−
93− if (profile.hasAuth) {
94− if (isHttps) {
95− install(Auth) {
96− basic {
97− credentials {
98− BasicAuthCredentials(
99− username = profile.username.orEmpty(),
100− password = profile.password.orEmpty(),
101− )
102− }
103− // Send eagerly so opencode doesn't need a 401 challenge round-trip, but
104− // only to the configured host: a cross-host redirect must not carry the
105− // credentials off-origin (the plugin would still answer a genuine 401 from
106− // the real host reactively). The Auth plugin is only installed for HTTPS
107− // profiles — over HTTP, even reactive credential sending would leak
108− // passwords in cleartext, so we don't install it at all.
109− sendWithoutRequest { request ->
110− pinHost != null && request.url.host.equals(pinHost, ignoreCase = true)
111− }
112− }
113− }
114− // Defense in depth mirroring the HTTP branch below: sendWithoutRequest only gates
115− // *eager* attachment on the outgoing request — it neither strips an Authorization
116− // header that HttpRedirect copies from the original (configured-host) request onto
117− // a cross-origin redirect target, nor stops the reactive Basic provider answering a
118− // 401 from such a target. followRedirects is enabled by default, so re-check the
119− // host on every physical send and remove the header whenever it isn't the pinned
120− // host, guaranteeing credentials never leave the configured origin.
121− install(
122− createClientPlugin("HttpsBasicAuthHostScoped") {
123− on(Send) { request ->
124− if (pinHost == null || !request.url.host.equals(pinHost, ignoreCase = true)) {
125− request.headers.remove(HttpHeaders.Authorization)
126− }
127− proceed(request)
128− }
129− },
130− )
131− } else if (pinHost != null) {
132− // For HTTP profiles with auth, attach the Basic header proactively (the reactive
133− // Auth plugin is skipped for non-HTTPS so a 401 can't silently re-send credentials
134− // in cleartext), but scope it to the configured host and re-check on EVERY physical
135− // send. Redirects matter: Ktor builds the redirect request by copying the original
136− // headers, so an unconditional header would carry the cleartext credentials to a
137− // cross-host redirect target. Setting it here (and stripping it off-host) mirrors
138− // the HTTPS path's sendWithoutRequest host guard so credentials never go off-origin.
139− val credentials = java.util.Base64.getEncoder().encodeToString(
140− "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
141− )
142− install(
143− createClientPlugin("HttpBasicAuthHostScoped") {
144− on(Send) { request ->
145− if (request.url.host.equals(pinHost, ignoreCase = true)) {
146− request.headers[HttpHeaders.Authorization] = "Basic $credentials"
147− } else {
148− request.headers.remove(HttpHeaders.Authorization)
149− }
150− proceed(request)
151− }
152− },
153− )
154− }
155− }
156−
157− defaultRequest {
158− url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
159− }
160− }
161− }
162−
163− /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
164− * Defaults to [https] when no scheme is given so credentials aren't accidentally
165− * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
166− fun normalizeBaseUrl(raw: String): String {
167− val trimmed = raw.trim()
168− // Compare the scheme prefix case-insensitively (a lowercased copy) while
169− // preserving the original casing of the rest of the URL in the output.
170− val lowerTrimmed = trimmed.lowercase()
171− val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
172− trimmed
173− } else {
174− "https://$trimmed"
175− }
176− return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
177− }
178−}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktdeleted−464
@@ -1,464 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−/**
4− * Centralized tuning parameters for the network and presentation layers. Keeping these
5− * in one place makes them easy to find and adjust without hunting through source files,
6− * and makes the intent of each value explicit rather than a bare magic number.
7− */
8−object NetworkConfig {
9−
10− // --- OkHttp engine timeouts (HttpClientFactory) ---
11−
12− /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
13− const val connectTimeoutSeconds = 30L
14− /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
15− const val readTimeoutSeconds = 60L
16− /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
17− const val pingIntervalSeconds = 20L
18− /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
19− const val restRequestTimeoutMs = 60_000L
20−
21− // --- REST retry (OpencodeApiClient.withRetry) ---
22−
23− /** Maximum number of attempts for a retriable REST call. */
24− const val retryMaxAttempts = 3
25− /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26− const val retryInitialDelayMs = 500L
27− /** Cap on a single retry backoff delay, guarding against overflow at high attempt counts. */
28− const val retryMaxDelayMs = 30_000L
29− /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
30− const val retryJitterFactor = 0.2
31−
32− // --- SSE stream (EventStreamClient) ---
33−
34− /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
35− const val sseInitialBackoffMs = 500L
36− /** Cap on the SSE reconnect backoff. */
37− const val sseMaxBackoffMs = 10_000L
38− /** Reconnect-attempt count at which the connection banner switches from "Reconnecting…"
39− * to "Reconnecting (attempt N)…" — after a few retries the user benefits from seeing the
40− * system is actively retrying rather than stuck. Below this the plain label is calmer. */
41− const val sseReconnectAttemptLabelThreshold = 3
42− /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
43− const val sseIdleTimeoutMs = 90_000L
44− /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
45− const val sseEventBufferCapacity = 64
46−
47− // --- Connectivity monitor (AppContainer) ---
48−
49− /** Grace period after a network is lost before reporting offline. Absorbs the transient
50− * window during a Wi-Fi→cellular handoff where onLost(WiFi) fires before the replacement
51− * network becomes active (cm.activeNetwork is briefly null), so a handoff doesn't flash
52− * offline while a genuine loss (no active network after the grace) is still caught. */
53− const val networkOfflineGraceMs = 2_000L
54−
55− // --- Catalog cache (OpencodeApiClient) ---
56−
57− /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
58− const val catalogCacheTtlMs = 60_000L
59−
60− /** TTL for files written to the cache dir to hand to external apps via FileProvider
61− * (open-externally). Stale files past this age are pruned on the next open so repeated
62− * opens of large files don't accumulate unbounded. 24h covers a typical session. */
63− const val externalCacheTtlMs = 24L * 60 * 60 * 1000
64−
65− // --- Session list (SessionListViewModel) ---
66−
67− /** Max sessions to fetch previews for in one batch. */
68− const val maxPreviewSessions = 50
69− /** Initial render window for the session list — the number of rows composed before the
70− * user scrolls to reveal more. Grows by [sessionListPageStep] as the user nears the
71− * bottom, bounding the sort/filter work on a huge list while keeping scrolling seamless. */
72− const val sessionListInitialPage = 60
73− /** How many additional rows to reveal when the session list render window is exhausted. */
74− const val sessionListPageStep = 60
75− /** Initial render window for the file-browser directory listing. A huge directory
76− * (10k+ entries) sorts/filters in full on each recomposition; windowing the render
77− * bounds the composed-row count while the sort still runs over the full list (so the
78− * order is correct, just the visible slice is capped). Grows as the user scrolls. */
79− const val fileListInitialPage = 100
80− /** How many additional file rows to reveal when the render window is exhausted. */
81− const val fileListPageStep = 100
82− /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
83− const val maxConcurrentPreviews = 8
84− /** Max characters of a session's last message to keep as a list preview. */
85− const val previewTextMaxLength = 200
86− /** Debounce delay before fetching a session preview after an SSE update, so a burst
87− * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
88− * instead of launching one per event (each downloading the full message history). */
89− const val previewDebounceMs = 500L
90− /** Debounce for applying SessionUpdated SSE events to the session list state. During
91− * active streaming the server emits these frequently; without debouncing each one
92− * triggers a full list filter + sort + recomposition, causing scroll jank. */
93− const val sessionUpdateDebounceMs = 300L
94−
95− // --- Global search (GlobalSearchViewModel) ---
96−
97− /** Max sessions whose message history is scanned in one global-search pass. Global
98− * search downloads each session's messages, so this bounds the work; if there are more
99− * sessions, the result set notes that the search was truncated. */
100− const val maxSearchSessions = 50
101− /** Debounce before a global search fires, so typing doesn't launch a fetch per keystroke. */
102− const val searchDebounceMs = 350L
103− /** Minimum query length before a global search runs (shorter queries match too much). */
104− const val minSearchQueryLength = 2
105− /** Characters of context to show around a global-search match. */
106− const val searchSnippetLength = 160
107−
108− // --- Message cache (MessageCacheStore) ---
109−
110− /** Minimum interval between on-disk writes of a session's message snapshot, so a fast
111− * token stream doesn't hammer the disk. The cache only needs to be recent enough for an
112− * instant/offline first paint; the network corrects it on the next open. */
113− const val messageCacheWriteThrottleMs = 2_000L
114−
115− /** Max entries in MessageCacheStore's tombstone set. Tombstones only need to survive long
116− * enough to block a racing teardown flush (which resolves within seconds of deletion), so
117− * entries from long-ago deletions are useless. Capping prevents unbounded growth across
118− * many session deletions over the app's lifetime; when exceeded the set is cleared wholesale
119− * (coarse but safe — no racing flush can still be alive for a session deleted sessions ago). */
120− const val maxMessageCacheTombstones = 128
121−
122− /** Grace period before a deferred session delete actually fires, during which an
123− * Undo snackbar lets the user cancel it. */
124− const val undoDeleteDelayMs = 5_000L
125−
126− /** Grace period before a deferred server-profile delete actually fires, during which
127− * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
128− const val undoServerDeleteDelayMs = 5_000L
129−
130− /** Grace period before a deferred crash-report delete actually fires, during which
131− * an Undo snackbar lets the user cancel it. */
132− const val undoReportDeleteDelayMs = 5_000L
133−
134− // --- In-memory message store (SessionRepository.MessageStore) ---
135−
136− /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
137− const val maxInMemoryMessages = 500
138−
139− // --- File viewer (FileViewScreen) ---
140−
141− /** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
142− * split once (not twice) into this many lines at most, so it can't hold two full line
143− * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
144− const val maxRenderedFileLines = 5000
145−
146− // --- File browser (FileBrowserViewModel) ---
147−
148− /** Debounce delay before firing a file-search request after the user stops typing. */
149− const val fileSearchDebounceMs = 250L
150−
151− /** Debounce delay before re-running the in-conversation message search after the user
152− * stops typing. The search walks every part of every message (including stringifying
153− * tool state), so debouncing avoids jank on long conversations. The filter itself runs
154− * off the main thread; this delay coalesces rapid keystrokes. */
155− const val chatSearchDebounceMs = 200L
156−
157− // --- Attachments (chat composer) ---
158−
159− /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
160− * the payload ~33%, and the whole prompt body is held in memory, so cap generously but
161− * finitely to avoid OOM / oversized requests. */
162− const val maxAttachmentBytes = 10L * 1024 * 1024
163−
164− /** Max number of attachments staged for one prompt, to bound memory and request size. */
165− const val maxAttachments = 8
166−
167− /** Max cumulative size of all attachments staged for one prompt. The per-file
168− * [maxAttachmentBytes] cap alone allows [maxAttachments] max-size files (~80MB raw,
169− * ~106MB as base64) to pile up in memory and be re-serialized on every add/remove and
170− * again when the request body is built — a realistic OOM. This bounds the whole set. */
171− const val maxTotalAttachmentBytes = 25L * 1024 * 1024
172−
173− // --- StateFlow sharing (WhileSubscribed) ---
174−
175− /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
176− const val stateFlowSubscriptionTimeoutMs = 5_000L
177−
178− /** Maximum time a notification action BroadcastReceiver may hold its goAsync()
179− * PendingResult open. The system ANRs a receiver that doesn't call finish() within
180− * ~10s, so this must stay below that; a slow permission-respond retry (withRetry
181− * exponential backoff up to ~90s) or a blocked outbox enqueue could otherwise blow
182− * past the window. The watchdog finishes the PendingResult at this deadline so the
183− * receiver never ANRs; the underlying network call keeps running on the app scope. */
184− const val notificationReceiverTimeoutMs = 8_000L
185−
186− // --- Draft persistence (ChatViewModel) ---
187−
188− /** Debounce delay before persisting a draft to disk after the user stops typing. */
189− const val draftDebounceMs = 500L
190−
191− /** Maximum characters allowed in the chat input field. A generous cap that prevents
192− * a huge paste from stalling the UI (the field would otherwise buffer and lay out
193− * an unbounded string) while leaving plenty of room for long prompts. */
194− const val maxDraftLengthChars = 32_000
195−
196− /** Show the "remaining characters" supportingText under the chat input once the
197− * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
198− * visible before it silently kicks in but doesn't clutter a normal short prompt. */
199− const val draftCountdownThresholdFraction = 0.8f
200−
201− /** Maximum characters allowed in a session title (rename dialog). Prevents the
202− * server from rejecting an overly long title and keeps list rows readable. */
203− const val maxSessionTitleChars = 200
204−
205− // --- Profile store (ServerEditViewModel) ---
206−
207− /** Timeout for loading a profile from DataStore before giving up. */
208− const val profileLoadTimeoutMs = 5_000L
209−
210− /** Timeout for a credential test (probe) before giving up, so a hung/unresponsive server
211− * doesn't leave the "Testing…" spinner spinning indefinitely. */
212− const val testCredentialsTimeoutMs = 15_000L
213−
214− // --- SSE stream (EventStreamClient, AppContainer) ---
215−
216− /** Delay before retrying the message-activity observer after a failure. */
217− const val observerRetryDelayMs = 5_000L
218− /** Debounce before the run foreground-service is started/stopped in response to
219− * run-activity changes. A run that starts and then fails/idles almost immediately
220− * would otherwise dispatch startForegroundService() then stopService() back-to-back;
221− * if the stop wins the race before startForeground() runs, Android raises the
222− * "did not then call startForeground()" crash. Debouncing collapses that window.
223− * Also serves as a minimum-run-duration gate: a run shorter than this (a quick abort,
224− * a one-token reply) clears [anyRunActive] before the FGS ever posts, so the shade
225− * stays clean of non-dismissable notifications for trivially short runs. The Doze-
226− * resilience benefit only matters for long runs, so a few seconds of gate preserves
227− * the core benefit. */
228− const val runForegroundDebounceMs = 3_000L
229−
230− // --- Markdown rendering (MarkdownText) ---
231−
232− /** Throttle delay to coalesce streaming tokens into one re-parse. The markdown
233− * library re-parses the full AST on every content change, so during a long streaming
234− * response this is O(n²) work. 50ms (~20fps) is smooth for progressively appearing
235− * text while cutting parse work ~3x versus a per-frame (16ms) throttle. */
236− const val streamingThrottleMs = 50L
237−
238− // --- Snackbar one-shot events (ViewModels) ---
239−
240− /** Buffer capacity for transient error SharedFlows that drive snackbars. */
241− const val snackbarEventBufferCapacity = 16
242−
243− /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
244− * after a tap, giving immediate feedback that the refresh was triggered even when
245− * the connection state is already Connected (so the ConnectionBanner doesn't change). */
246− const val refreshFeedbackMs = 1_200L
247−
248− // --- UI layout constants ---
249−
250− /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
251− const val twoPaneWidthThresholdDp = 840
252− /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
253− const val userBubbleWidthFraction = 0.85f
254− /** Minimum number of options in a picker sheet (model/agent/command) before its search
255− * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
256− * eye, so popping the keyboard for it is just friction. */
257− const val pickerSearchAutofocusThreshold = 8
258−
259− /** Minimum number of saved server profiles before the server list shows its search/filter
260− * field. A typical user has one or two servers, where scanning by eye beats typing; power
261− * users juggling many profiles get the filter once it earns its space. */
262− const val serverListSearchThreshold = 5
263−
264− /** Minimum number of crash reports before the diagnostics list shows its search field. */
265− const val diagnosticsSearchThreshold = 8
266−
267− /** Initial render window for the diagnostics crash-report list — the number of rows
268− * composed before the user scrolls to reveal more. Bounds composition work on a device
269− * that has accumulated many crash reports. Grows by [diagnosticsListPageStep] as the
270− * user nears the bottom. */
271− const val diagnosticsListInitialPage = 60
272− /** How many additional crash-report rows to reveal when the render window is exhausted. */
273− const val diagnosticsListPageStep = 60
274−
275− /** Minimum number of sessions in the usage report before the per-session list shows its
276− * filter field. A short history is faster to scan than type into. */
277− const val usageSessionSearchThreshold = 8
278−
279− /** Minimum number of sessions before the session list shows its search/filter field.
280− * Matches the sibling screens' convention: a short list is faster to scan by eye than
281− * type into, so the field earns its vertical space only once the list grows. */
282− const val sessionListSearchThreshold = 5
283− /** Extra bottom content padding (dp) for the chat message list, reserving room for the
284− * jump-to-latest FAB so it never floats over the last message. */
285− const val chatListFabInsetDp = 80
286−
287− /** Extra bottom content padding (dp) for single-pane list screens that host a FAB
288− * (session list, server list, MCP), reserving room so the FAB never floats over the
289− * last row. Unified across screens so the inset doesn't drift (the session list
290− * previously hardcoded 96dp while the chat list used [chatListFabInsetDp]). */
291− const val listFabInsetDp = 96
292− /** Left pane weight in the two-pane layout (session list). */
293− const val twoPaneLeftWeight = 0.38f
294− /** Right pane weight in the two-pane layout (chat detail). */
295− const val twoPaneRightWeight = 0.62f
296−
297− /** Maximum width (dp) for the left (session-list) pane in the two-pane layout. On a wide
298− * tablet the list pane would otherwise stretch too wide for a readable row density; capping
299− * it keeps the list compact and gives the chat detail pane the remaining space. */
300− const val twoPaneLeftMaxWidthDp = 460
301−
302− /** Minimum window width (dp) at which a NavigationRail is shown alongside the
303− * two-pane layout for top-level destination discoverability on large screens. Sits below
304− * [twoPaneWidthThresholdDp] (M3's WindowWidthSizeClass.Medium breakpoint ~600dp) so a
305− * tablet or landscape phone gets the rail while staying single-pane until 840dp, where the
306− * two-pane master/detail split takes over. */
307− const val navigationRailThresholdDp = 600
308−
309− /** Maximum width (dp) for the chat message list on large screens. On a tablet or
310− * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
311− * readability — capping and centering the list (like Gmail/Telegram on tablets)
312− * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
313− const val chatContentMaxWidthDp = 800
314−
315− /** Maximum width (dp) for a single-pane list/form screen on large screens (Diagnostics,
316− * MCP, Usage, Global Search, File Browser). Mirrors [chatContentMaxWidthDp]'s rationale
317− * at a tighter width suited to dense list rows and form fields. Settings and ServerEdit
318− * use 600dp directly; this constant centralizes the same value for the screens that
319− * previously stretched edge-to-edge on tablets. */
320− const val listContentMaxWidthDp = 600
321−
322− /** Maximum width (dp) for the full-screen chat composer dialog on large screens, so
323− * the editor doesn't stretch edge-to-edge on a tablet (matching [chatContentMaxWidthDp]'s
324− * readability rationale for the message list). */
325− const val composerDialogMaxWidthDp = 800
326−
327− /** Maximum height (dp) for a modal bottom sheet's list area. Resolved against the screen
328− * (not the partial sheet) so a sheet that starts fully expanded shows a predictable list
329− * height regardless of the sheet's drag position. */
330− const val pickerSheetMaxHeightDp = 560
331−
332− /** Maximum inline height (dp) for an image attachment in the chat list. A tall image
333− * caps at this so it doesn't dominate the bubble; tapping opens the fullscreen viewer. */
334− const val inlineImageMaxHeightDp = 320
335−
336− /** Minimum inline height (dp) reserved for an image placeholder (loading/error/resolving),
337− * so the layout doesn't jump when the load completes and the image's intrinsic size is known. */
338− const val inlineImageMinHeightDp = 120
339−
340− /** Lines of a code fence rendered before collapsing to a head with a "show more" affordance.
341− * Capped low for mobile: a 200-line block rendered at once can jank a low-end phone, so the
342− * initial render is bounded and a "show more" reveals the rest in place. */
343− const val collapsedCodeLineThreshold = 80
344−
345− /** Lines of a unified diff rendered before collapsing to a head with a "show more" affordance.
346− * Same mobile-performance rationale as [collapsedCodeLineThreshold]. */
347− const val collapsedDiffLineThreshold = 80
348−
349− /** Lines of tool output rendered before collapsing to a head with a "show more" affordance. */
350− const val toolOutputCollapsedLimitChars = 4000
351−
352− /** Lines of tool output rendered before collapsing to a head with a "show more" affordance.
353− * A line-based threshold (rather than the char-based [toolOutputCollapsedLimitChars]) keeps
354− * the collapsed preview a predictable height on mobile — a 4000-char block of short lines
355− * can be 60+ lines and fill the whole screen, defeating the purpose of collapsing. The
356− * char cap still applies as a hard upper bound; this line cap kicks in first for typical
357− * multi-line output. */
358− const val toolOutputCollapsedLimitLines = 30
359−
360− /** Tool input (pretty-printed JSON) shorter than this many lines is shown expanded by
361− * default — for tools like bash/write/edit the command is the most important info and
362− * hiding it behind a tap inverts the priority. Longer inputs stay collapsed to avoid
363− * dominating the bubble. */
364− const val toolInputAutoExpandLineThreshold = 10
365−
366− /** Tool durations below this many ms are hidden from the tool-name row (sub-second runs
367− * are instant and the ms figure is noise). Set to 1000 to hide all sub-second durations,
368− * or 0 to always show. */
369− const val toolDurationHideBelowMs = 1_000L
370−
371− /** Characters of tool output included in a shared/exported transcript before truncation. */
372− const val exportToolOutputLimitChars = 2000
373−
374− /** Maximum zoom factor applied by pinch or double-tap in the fullscreen image viewer. */
375− const val imageViewerMaxZoom = 5f
376−
377− /** Zoom factor a double-tap jumps to when starting from 1× in the fullscreen image viewer. */
378− const val imageViewerDoubleTapZoom = 2.5f
379−
380− /** Duration (ms) of the animated double-tap zoom transition in the fullscreen image viewer. */
381− const val imageViewerZoomAnimMs = 220
382−
383− /** Vertical drag distance (px) in the fullscreen image viewer that triggers swipe-to-dismiss. */
384− const val imageViewerSwipeDismissThreshold = 200f
385−
386− /** Blink period (ms) of the streaming caret at the tail of a live assistant reply. */
387− const val streamingCaretPeriodMs = 500
388−
389− /** Target maximum length (chars) of a single TTS chunk. Android's TextToSpeech enforces a
390− * hard limit (getMaxSpeechInputLength, ~4000 chars) — this is a tighter cap so pause/
391− * resume restarts at a finer granularity. Without it, a pause mid-chunk restarts the
392− * whole chunk from its start; smaller chunks mean less replay on resume. Sentence
393− * boundaries are still preferred, so short sentences pack up to this cap. */
394− const val ttsChunkTargetMaxChars = 200
395−
396− /** Auto-reject a tool-permission prompt after this many ms if the user hasn't responded,
397− * so a forgotten prompt doesn't block the run indefinitely. The foreground service keeps
398− * the process alive, so without a timeout a prompt that the user walked away from holds
399− * the run open forever. The timeout is long (10 minutes) so a deliberate pause to read
400− * the prompt doesn't trip it; a reminder toast-style line appears in the dialog after
401− * [permissionReminderThresholdMs] to surface that the run is waiting. Set to 0 to disable. */
402− const val permissionAutoRejectMs = 10L * 60 * 1000
403−
404− /** Show a "still waiting" reminder in the permission dialog after this many ms. */
405− const val permissionReminderThresholdMs = 60L * 1000
406−
407− /** Duration (ms) the copy button shows its checkmark confirmation before reverting. */
408− const val copyFeedbackMs = 1200
409−
410− /** Gutter width (dp) for each side (old/new) of a unified-diff line-number column. */
411− const val diffGutterWidthDp = 36
412−
413− /** Width (dp) of the +/- prefix column in a unified-diff row. */
414− const val diffPrefixWidthDp = 16
415−
416− /** Maximum width (dp) of a breadcrumb segment in the file browser (non-last segments). */
417− const val breadcrumbSegmentMaxWidthDp = 120
418−
419− /** Maximum width (dp) of the last (current) breadcrumb segment in the file browser. */
420− const val breadcrumbLastSegmentMaxWidthDp = 220
421−
422− /** Vertical scroll threshold beyond which the chat "scroll to top" FAB appears. */
423− const val chatScrollToTopFabThreshold = 200
424−
425− /** Maximum width (dp) for an attachment chip's filename text before it truncates. */
426− const val attachmentChipNameMaxWidthDp = 120
427−
428− /** Maximum height (dp) for the expanded todo-plan checklist in a message bubble. */
429− const val todoPlanMaxHeightDp = 240
430−
431− /** Fallback height (dp) for the connection banner when no text is available (e.g. exit fade). */
432− const val connectionBannerFallbackHeightDp = 44
433−
434− /** Fixed height (dp) for the connection banner used for LazyColumn top content padding.
435− * onSizeChanged was previously used to measure the banner's actual height and feed it
436− * into the LazyColumn's contentPadding, but that created a feedback loop: the banner
437− * appears → onSizeChanged fires → contentPadding changes → LazyColumn remeasures all
438− * visible items during the same frame async content (DiffView, images) is settling
439− * post-mount → draw-phase remeasure NPE. A fixed height eliminates the loop. At large
440− * accessibility font scales the banner may slightly exceed this, but the 4dp margin
441− * above it in topPad covers the common case. */
442− const val connectionBannerHeightDp = 44
443−
444− /** Fixed height (dp) for the in-conversation search bar used for LazyColumn top
445− * content padding. Same rationale as connectionBannerHeightDp: onSizeChanged on the
446− * search bar fed contentPadding and triggered the draw-phase remeasure crash. */
447− const val chatSearchBarHeightDp = 56
448−
449− // --- Motion tokens (shared across screens) ---
450−
451− /** Duration (ms) for horizontal slide push/pop nav transitions. M3 spec recommends 200ms
452− * for medium-duration container transitions. */
453− const val motionSlideDurationMs = 200
454− /** Duration (ms) for fade in/out nav and content transitions. */
455− const val motionFadeDurationMs = 180
456−
457− // --- Font scaling ---
458−
459− /** Upper bound on the *combined* app×OS font scale. The chat text-size preference
460− * multiplies on top of the system font scale, so a user at a large system size and
461− * a large app size can reach ~2× and break tight layouts. The provided app scale is
462− * clamped so the product with the OS fontScale never exceeds this. */
463− const val maxCombinedFontScale = 1.8f
464−}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktdeleted−575
@@ -1,575 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.Agent
4−import soy.iko.opencode.data.model.Command
5−import soy.iko.opencode.data.model.CommandRequest
6−import soy.iko.opencode.data.model.CreateSessionRequest
7−import soy.iko.opencode.data.model.FileContent
8−import soy.iko.opencode.data.model.FileNode
9−import soy.iko.opencode.data.model.FileStatusEntry
10−import soy.iko.opencode.data.model.MessageWithParts
11−import soy.iko.opencode.data.model.ModelRef
12−import soy.iko.opencode.data.model.PathInfo
13−import soy.iko.opencode.data.model.FilePromptPart
14−import soy.iko.opencode.data.model.FindMatch
15−import soy.iko.opencode.data.model.PermissionReplyBody
16−import soy.iko.opencode.data.model.PermissionResponse
17−import soy.iko.opencode.data.model.Project
18−import soy.iko.opencode.data.model.PromptPart
19−import soy.iko.opencode.data.model.PromptRequest
20−import soy.iko.opencode.data.model.ProvidersResponse
21−import soy.iko.opencode.data.model.RevertRequest
22−import soy.iko.opencode.data.model.Session
23−import soy.iko.opencode.data.model.ShellRequest
24−import soy.iko.opencode.data.model.SummarizeRequest
25−import soy.iko.opencode.data.model.SymbolResult
26−import soy.iko.opencode.data.model.TextPromptPart
27−import soy.iko.opencode.data.model.UpdateSessionRequest
28−import io.ktor.client.HttpClient
29−import io.ktor.client.call.body
30−import io.ktor.client.plugins.ClientRequestException
31−import io.ktor.client.plugins.ResponseException
32−import io.ktor.client.plugins.ServerResponseException
33−import io.ktor.client.request.delete
34−import io.ktor.client.request.get
35−import io.ktor.client.request.header
36−import io.ktor.client.request.parameter
37−import io.ktor.client.request.patch
38−import io.ktor.client.request.post
39−import io.ktor.client.request.setBody
40−import io.ktor.http.ContentType
41−import io.ktor.http.contentType
42−import kotlinx.coroutines.delay
43−import kotlinx.coroutines.sync.Mutex
44−import kotlinx.coroutines.sync.withLock
45−import kotlinx.serialization.json.buildJsonObject
46−import kotlinx.serialization.json.put
47−import kotlin.random.Random
48−import java.net.URLEncoder
49−
50−/**
51− * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
52− * SSE stream lives in [EventStreamClient]; everything else is here.
53− */
54−open class OpencodeApiClient private constructor(
55− private val client: HttpClient?,
56− @Suppress("unused") private val testMode: Boolean,
57−) {
58− constructor(client: HttpClient) : this(client, false)
59− protected constructor() : this(null, true)
60−
61− // --- Catalog cache (providers/agents/commands) ---
62− // These change rarely, but the ChatViewModel re-fetches them on every creation.
63− // A short-lived in-memory cache avoids redundant network calls when navigating
64− // between sessions on the same server.
65− private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
66− // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
67− // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
68− // all callers and stall invalidation). To keep that correct, each catalog carries a
69− // generation counter, bumped under the mutex by its invalidateXCache(): a fetch captures
70− // the generation before it starts and, when it completes, only writes its result back if
71− // the generation is unchanged — so an invalidation that raced the in-flight fetch is not
72− // defeated by the pre-invalidation result being stored afterwards.
73− private val providersMutex = Mutex()
74− private val agentsMutex = Mutex()
75− private val commandsMutex = Mutex()
76− private var cachedProviders: CachedEntry<ProvidersResponse>? = null
77− private var cachedAgents: CachedEntry<List<Agent>>? = null
78− private var cachedCommands: CachedEntry<List<Command>>? = null
79− // Generation counters (guarded by the matching mutex above) for the invalidation-race guard.
80− private var providersGeneration = 0
81− private var agentsGeneration = 0
82− private var commandsGeneration = 0
83−
84− /** Lightweight connectivity check. Throws on non-2xx / network failure. */
85− open suspend fun ping() {
86− withRetry { client!!.get("global/health").body<String>() }
87− }
88−
89− open suspend fun listSessions(): List<Session> = withRetry {
90− client!!.get("session").body()
91− }
92−
93− /**
94− * Create a session, optionally in a specific worktree [directory]. The directory is a
95− * query parameter (not a body field): the server records it on the session and resolves
96− * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
97− * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
98− * its own launch cwd, preserving the pre-directory behavior.
99− */
100− open suspend fun createSession(title: String? = null, directory: String? = null): Session {
101− // Generate the idempotency key before entering withRetry so every retry attempt
102− // shares it. Without this, a POST that reached the server but whose response was
103− // lost (reset/timeout) would be retried and create a second orphan session.
104− val idempotencyKey = java.util.UUID.randomUUID().toString()
105− val dir = directory?.takeIf { it.isNotBlank() }
106− return withRetry {
107− client!!.post("session") {
108− contentType(ContentType.Application.Json)
109− header("Idempotency-Key", idempotencyKey)
110− if (dir != null) parameter("directory", dir)
111− setBody(CreateSessionRequest(title = title))
112− }.body()
113− }
114− }
115−
116− /** List the projects (worktree roots) the server knows about, for the directory picker. */
117− open suspend fun listProjects(): List<Project> = withRetry {
118− client!!.get("project").body()
119− }
120−
121− /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
122− * session with no directory override runs in. */
123− open suspend fun currentPath(): PathInfo = withRetry {
124− client!!.get("path").body()
125− }
126−
127− open suspend fun updateSession(id: String, title: String): Session = withRetry {
128− client!!.patch("session/${encode(id)}") {
129− contentType(ContentType.Application.Json)
130− setBody(UpdateSessionRequest(title = title))
131− }.body()
132− }
133−
134− open suspend fun deleteSession(id: String) {
135− try {
136− withRetry { client!!.delete("session/${encode(id)}").body<String>() }
137− } catch (t: ClientRequestException) {
138− // A DELETE that succeeded but whose response was lost gets retried; the retry
139− // hits an already-gone session and returns 404. Deleting a non-existent session
140− // is effectively success, so swallow 404 rather than surfacing a spurious failure.
141− if (t.response.status.value != 404) throw t
142− }
143− }
144−
145− open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
146− client!!.get("session/${encode(sessionId)}/message").body()
147− }
148−
149− open suspend fun sendPrompt(
150− sessionId: String,
151− text: String,
152− attachments: List<FilePromptPart> = emptyList(),
153− model: ModelRef? = null,
154− agent: String? = null,
155− // A caller-stable idempotency key. Callers that may re-invoke sendPrompt across
156− // *separate* calls for the same logical message (the offline outbox, which re-sends
157− // a message that stayed queued after an earlier flush failed) must pass a stable key
158− // derived from the persistent message id — otherwise each attempt mints a fresh key
159− // and a POST that reached the server but whose response was lost is re-created as a
160− // duplicate run. When null, a fresh key is generated (correct for one-shot sends).
161− idempotencyKey: String? = null,
162− ): MessageWithParts {
163− // Resolve the key *before* entering withRetry so all retry attempts share it. If the
164− // key were generated inside withRetry, each attempt would get a fresh UUID and the
165− // server couldn't deduplicate a request that reached it but whose response was lost.
166− val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
167− // A text part is only included when there's actual text, so an image-only prompt
168− // (attachments, empty text) doesn't send an empty text part. Attachments follow
169− // the text so the model reads the instruction first.
170− val parts = buildList<PromptPart> {
171− if (text.isNotEmpty()) add(TextPromptPart(text))
172− addAll(attachments)
173− }
174− return withRetry {
175− client!!.post("session/${encode(sessionId)}/message") {
176− contentType(ContentType.Application.Json)
177− header("Idempotency-Key", resolvedKey)
178− setBody(
179− PromptRequest(
180− parts = parts,
181− model = model,
182− agent = agent,
183− ),
184− )
185− }.body()
186− }
187− }
188−
189− /** Abort the active run in a session. NOT retried: a lost response followed by a
190− * retry would POST abort again, and if a new run started in the gap the retry
191− * would kill that *new* run. Unlike sendPrompt/runCommand/summarize there's no
192− * Idempotency-Key the server could use to dedupe, so a single attempt is the
193− * safe choice — the SSE stream reflects the actual run state regardless. */
194− open suspend fun abort(sessionId: String) {
195− withRetry(maxAttempts = 1) { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
196− }
197−
198− /** Invoke a slash-command by name via `POST /session/:id/command`. */
199− open suspend fun runCommand(
200− sessionId: String,
201− command: String,
202− arguments: String = "",
203− agent: String? = null,
204− ): MessageWithParts {
205− // Generate the idempotency key before entering withRetry so all retry
206− // attempts share the same key, preventing duplicate command execution if
207− // the server processed the request but the response was lost.
208− val idempotencyKey = java.util.UUID.randomUUID().toString()
209− return withRetry {
210− client!!.post("session/${encode(sessionId)}/command") {
211− contentType(ContentType.Application.Json)
212− header("Idempotency-Key", idempotencyKey)
213− setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
214− }.body()
215− }
216− }
217−
218− /** Revert the session to just before [messageId] (optionally part [partId]); the
219− * server hides everything after the checkpoint. Returns the updated session.
220− *
221− * NOT retried (single attempt), mirroring [abort]: a lost response followed by a retry
222− * would POST revert again, and if a new run started in the gap the retry would hide
223− * that new run's messages too (until the user noticed and called [unrevert]). There's
224− * no Idempotency-Key the server could dedupe on, so a single attempt is the safe
225− * choice — the SSE stream reflects the actual revert state regardless. */
226− open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry(maxAttempts = 1) {
227− client!!.post("session/${encode(sessionId)}/revert") {
228− contentType(ContentType.Application.Json)
229− setBody(RevertRequest(messageID = messageId, partID = partId))
230− }.body()
231− }
232−
233− /** Undo the active revert checkpoint, restoring the hidden messages.
234− *
235− * Mint an Idempotency-Key *before* withRetry so a retried POST (first request reached
236− * the server but its response was lost) doesn't trigger a second unrevert on a session
237− * whose checkpoint was already cleared — the second attempt would hit a session with no
238− * active revert and the server could return a non-retryable 4xx, surfacing a spurious
239− * error for an operation that actually succeeded. The key lets the server dedupe. */
240− open suspend fun unrevert(sessionId: String): Session {
241− val idempotencyKey = java.util.UUID.randomUUID().toString()
242− return withRetry {
243− client!!.post("session/${encode(sessionId)}/unrevert") {
244− header("Idempotency-Key", idempotencyKey)
245− }.body()
246− }
247− }
248−
249− /** Create a public share link for the session. Returns the session with [Session.share] set.
250− * Mint the Idempotency-Key *before* withRetry so a retried POST (first request reached the
251− * server but its response was lost) doesn't create a second, orphaned, still-valid share. */
252− open suspend fun shareSession(sessionId: String): Session {
253− val idempotencyKey = java.util.UUID.randomUUID().toString()
254− return withRetry {
255− client!!.post("session/${encode(sessionId)}/share") {
256− header("Idempotency-Key", idempotencyKey)
257− }.body()
258− }
259− }
260−
261− /** Revoke the session's public share link. Returns the session with share cleared.
262− * Like [deleteSession], a retried DELETE (first reached the server but the response was
263− * lost) hits an already-unshared session and gets 404 — revoking a non-shared session is
264− * effectively success, so swallow 404 rather than surfacing a spurious failure.
265− * The fallback `GET` re-reads the current session state so the caller sees the cleared
266− * share. If the session itself was deleted in the window between the DELETE-404 and the
267− * GET (race with another client / a concurrent deleteSession), the GET also 404s —
268− * swallow that too and surface a synthesized gone-session rather than an unexpected
269− * ClientRequestException, mirroring deleteSession's contract. */
270− open suspend fun unshareSession(sessionId: String): Session {
271− return try {
272− withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
273− } catch (t: ClientRequestException) {
274− if (t.response.status.value != 404) throw t
275− try {
276− withRetry { client!!.get("session/${encode(sessionId)}").body<Session>() }
277− } catch (t2: ClientRequestException) {
278− if (t2.response.status.value != 404) throw t2
279− Session(id = sessionId, title = null, share = null)
280− }
281− }
282− }
283−
284− /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
285− * summary streams back via SSE like any run; this just triggers it. */
286− open suspend fun summarize(sessionId: String, model: ModelRef) {
287− val idempotencyKey = java.util.UUID.randomUUID().toString()
288− withRetry {
289− client!!.post("session/${encode(sessionId)}/summarize") {
290− contentType(ContentType.Application.Json)
291− header("Idempotency-Key", idempotencyKey)
292− setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
293− }.body<String>()
294− }
295− }
296−
297− /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
298− * assigns the message id and uses its default model; the run streams back via SSE. */
299− open suspend fun initSession(sessionId: String) {
300− val idempotencyKey = java.util.UUID.randomUUID().toString()
301− withRetry {
302− client!!.post("session/${encode(sessionId)}/init") {
303− header("Idempotency-Key", idempotencyKey)
304− }.body<String>()
305− }
306− }
307−
308− /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
309− * [agent] scopes the run (the server requires it). */
310− open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
311− val idempotencyKey = java.util.UUID.randomUUID().toString()
312− withRetry {
313− client!!.post("session/${encode(sessionId)}/shell") {
314− contentType(ContentType.Application.Json)
315− header("Idempotency-Key", idempotencyKey)
316− setBody(ShellRequest(agent = agent, command = command, model = model))
317− }.body<String>()
318− }
319− }
320−
321− open suspend fun providers(): ProvidersResponse {
322− val generation: Int
323− providersMutex.withLock {
324− val cached = cachedProviders
325− if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
326− return cached.value
327− }
328− generation = providersGeneration
329− }
330− // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
331− // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
332− // for a rarely-fetched catalog — and the last store wins.
333− val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
334− providersMutex.withLock {
335− // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
336− // result predates the invalidation and would otherwise resurrect stale data.
337− if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
338− }
339− return value
340− }
341−
342− /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
343− * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
344− open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
345− client!!.get("config").body()
346− }
347−
348− /** Live MCP server status (`GET /mcp`): a map of server name to its status (connected,
349− * available tools, any error). Decoded as a raw JSON object so a schema change on the
350− * server never breaks the viewer. */
351− open suspend fun mcpStatus(): kotlinx.serialization.json.JsonObject = withRetry {
352− client!!.get("mcp").body()
353− }
354−
355− /** Dynamically register an MCP server (`POST /mcp`). [config] is the server's config object
356− * (type/command-or-url/env…), sent as `{ "name": [name], "config": [config] }`. Mint the
357− * Idempotency-Key *before* withRetry so a retried POST (first reached the server but the
358− * response was lost) doesn't register a duplicate. */
359− open suspend fun addMcp(name: String, config: kotlinx.serialization.json.JsonObject): kotlinx.serialization.json.JsonObject {
360− val idempotencyKey = java.util.UUID.randomUUID().toString()
361− return withRetry {
362− client!!.post("mcp") {
363− contentType(ContentType.Application.Json)
364− header("Idempotency-Key", idempotencyKey)
365− setBody(
366− buildJsonObject {
367− put("name", name)
368− put("config", config)
369− },
370− )
371− }.body()
372− }
373− }
374−
375− open suspend fun agents(): List<Agent> {
376− val generation: Int
377− agentsMutex.withLock {
378− val cached = cachedAgents
379− if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
380− return cached.value
381− }
382− generation = agentsGeneration
383− }
384− // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
385− // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
386− val value = withRetry { client!!.get("agent").body<List<Agent>>() }
387− agentsMutex.withLock {
388− // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
389− if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
390− }
391− return value
392− }
393−
394− open suspend fun commands(): List<Command> {
395− val generation: Int
396− commandsMutex.withLock {
397− val cached = cachedCommands
398− if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
399− return cached.value
400− }
401− generation = commandsGeneration
402− }
403− // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
404− // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
405− val value = withRetry { client!!.get("command").body<List<Command>>() }
406− commandsMutex.withLock {
407− // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
408− if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
409− }
410− return value
411− }
412−
413− /** Invalidate the catalog cache (e.g. on server switch). */
414− open suspend fun invalidateCache() {
415− // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
416− providersMutex.withLock { cachedProviders = null; providersGeneration++ }
417− agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
418− commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
419− }
420−
421− open suspend fun invalidateProvidersCache() {
422− providersMutex.withLock { cachedProviders = null; providersGeneration++ }
423− }
424−
425− open suspend fun invalidateAgentsCache() {
426− agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
427− }
428−
429− open suspend fun invalidateCommandsCache() {
430− commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
431− }
432−
433− /** Respond to a permission request so a paused tool run can proceed. */
434− open suspend fun respondPermission(
435− sessionId: String,
436− permissionId: String,
437− response: PermissionResponse,
438− ): String = try {
439− withRetry {
440− client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
441− contentType(ContentType.Application.Json)
442− setBody(PermissionReplyBody(response.wire))
443− }.body<String>()
444− }
445− } catch (t: ClientRequestException) {
446− // A response that reached the server but whose reply was lost gets retried; the
447− // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
448− // the answer landed (or the session/permission no longer exists, in which case the
449− // response is moot anyway), so treat them as success instead of a spurious failure
450− // that would re-queue the dialog against a gone session. Log the swallowed status
451− // so a 404 caused by a deleted session is debuggable rather than fully silent.
452− val code = t.response.status.value
453− if (code != 404 && code != 409) throw t
454− android.util.Log.d("OpencodeApi", "respondPermission swallowed $code for session=$sessionId permission=$permissionId")
455− ""
456− }
457−
458− // --- Files ---
459−
460− open suspend fun findFiles(query: String): List<String> = withRetry {
461− client!!.get("find/file") { parameter("query", query) }.body()
462− }
463−
464− /** Ripgrep content search across the project (`GET /find?pattern=`). */
465− open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
466− client!!.get("find") { parameter("pattern", pattern) }.body()
467− }
468−
469− /** LSP workspace symbol search (`GET /find/symbol?query=`). */
470− open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
471− client!!.get("find/symbol") { parameter("query", query) }.body()
472− }
473−
474− open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
475− client!!.get("file") { parameter("path", path) }.body()
476− }
477−
478− open suspend fun readFile(path: String): FileContent = withRetry {
479− client!!.get("file/content") { parameter("path", path) }.body()
480− }
481−
482− open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
483− client!!.get("file/status").body()
484− }
485−}
486−
487−private suspend fun <T> withRetry(
488− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
489− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
490− block: suspend () -> T,
491−): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
492−
493−/**
494− * Retry a suspending block up to [maxAttempts] times with exponential backoff.
495− * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
496− * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
497− * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
498− * server errors are retried. Exposed as `internal` so the retry/skip rules are
499− * unit-testable without an HTTP server.
500− */
501−internal suspend fun <T> withRetryInternal(
502− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
503− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
504− jitterFactor: Double = NetworkConfig.retryJitterFactor,
505− block: suspend () -> T,
506−): T {
507− var lastError: Throwable? = null
508− for (attempt in 1..maxAttempts) {
509− try {
510− return block()
511− } catch (c: kotlinx.coroutines.CancellationException) {
512− throw c
513− } catch (t: ClientRequestException) {
514− // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
515− // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
516− val status = t.response.status.value
517− if (status != 408 && status != 429) throw t
518− lastError = t
519− if (attempt < maxAttempts) {
520− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
521− }
522− } catch (t: ServerResponseException) {
523− // 5xx server errors are retryable with backoff.
524− lastError = t
525− if (attempt < maxAttempts) {
526− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
527− }
528− } catch (t: ResponseException) {
529− // 3xx (redirect limit exceeded) is non-retryable.
530− throw t
531− } catch (t: io.ktor.serialization.ContentConvertException) {
532− // A malformed/unexpected response body (deserialization failure via content
533− // negotiation) is deterministic — it fails identically on every attempt — so
534− // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
535− // fast instead.
536− throw t
537− } catch (t: IllegalArgumentException) {
538− // Also deterministic: a bad argument, or a raw kotlinx SerializationException
539− // (which extends IllegalArgumentException) thrown outside content negotiation.
540− throw t
541− } catch (t: Exception) {
542− lastError = t
543− if (attempt < maxAttempts) {
544− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
545− }
546− }
547− }
548− throw lastError ?: IllegalStateException("withRetry failed without error")
549−}
550−
551−/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
552− * so the backoff formula is unit-testable without an HTTP server. */
553−internal fun jitteredBackoff(
554− attempt: Int,
555− initialDelayMs: Long,
556− jitterFactor: Double,
557−): Long {
558− // Guard against Int overflow from the shift and Long overflow from
559− // the multiplication at high attempt counts.
560− val shift = (attempt - 1).coerceAtMost(30)
561− val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
562− // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
563− // shorter, sometimes longer — spreads concurrent client retries and
564− // prevents thundering-herd reconnection storms.
565− val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
566− return (baseDelay + jitter).coerceAtLeast(0)
567−}
568−
569−/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
570− * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
571− * path-traversal component; percent-encode a segment that is nothing but dots. */
572−private fun encode(segment: String): String {
573− val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
574− return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded
575−}
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktdeleted−37
@@ -1,37 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.MessageInfo
5−import soy.iko.opencode.data.model.Part
6−import soy.iko.opencode.data.model.ToolState
7−import soy.iko.opencode.data.model.UnknownEvent
8−import soy.iko.opencode.data.model.UnknownMessage
9−import soy.iko.opencode.data.model.UnknownPart
10−import soy.iko.opencode.data.model.ToolUnknown
11−import kotlinx.serialization.ExperimentalSerializationApi
12−import kotlinx.serialization.json.Json
13−import kotlinx.serialization.modules.SerializersModule
14−
15−/**
16− * Single shared Json for the whole app.
17− *
18− * The polymorphic default deserializers are the resilience layer: when the server
19− * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20− * Unknown variant instead of throwing — so a new opencode release can't break the
21− * event stream or message decoding.
22− */
23−@OptIn(ExperimentalSerializationApi::class)
24−val OpencodeJson: Json = Json {
25− ignoreUnknownKeys = true
26− isLenient = true
27− coerceInputValues = true
28− encodeDefaults = true
29− explicitNulls = false
30− classDiscriminator = "type"
31− serializersModule = SerializersModule {
32− polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
33− polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
34− polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
35− polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
36− }
37−}
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.ktdeleted−121
@@ -1,121 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.sync.Mutex
7−import kotlinx.coroutines.sync.withLock
8−import kotlinx.coroutines.withContext
9−import kotlinx.serialization.Serializable
10−import kotlinx.serialization.decodeFromString
11−import kotlinx.serialization.encodeToString
12−import kotlinx.serialization.json.Json
13−import soy.iko.opencode.util.runCatchingCancellable
14−import java.io.File
15−
16−/**
17− * One staged (not-yet-sent) attachment, in the minimal form needed to re-send and preview
18− * it after a process restart. Only the self-contained wire payload is kept — the base64
19− * `data:` [url] — because the original content Uri's read permission does NOT survive a
20− * process restart, whereas the data URL is self-sufficient (and Coil re-renders it as the
21− * thumbnail for images).
22− */
23−@Serializable
24−data class PersistedAttachment(
25− val id: String,
26− val name: String,
27− val mime: String,
28− val url: String,
29− val filename: String? = null,
30−)
31−
32−/**
33− * Persists attachments staged for each session so an interrupted compose survives process
34− * death, mirroring how [DraftStore] persists the text draft.
35− *
36− * Backed by one small JSON file per session under filesDir (not cacheDir, which the OS may
37− * evict) rather than SharedPreferences: a base64 payload can be several megabytes and
38− * SharedPreferences loads its whole file into memory on first access.
39− */
40−open class AttachmentDraftStore private constructor(
41− private val appContext: Context?,
42− @Suppress("unused") private val testMode: Boolean,
43−) {
44− constructor(context: Context) : this(context.applicationContext, false)
45− protected constructor() : this(null, true)
46−
47− private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
48−
49− /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
50− * escape the directory. */
51− private val idRegex = Regex("[^A-Za-z0-9_-]")
52−
53− private val dir: File? by lazy {
54− appContext?.let { File(it.filesDir, "attachment_drafts").apply { mkdirs() } }
55− }
56−
57− private fun fileFor(sessionId: String): File? =
58− dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
59−
60− /** Per-session write lock so rapid add/remove/clear mutations (each launched as an
61− * independent coroutine by the ViewModel) serialize their writes to the same file
62− * instead of racing — otherwise an older snapshot could land last and overwrite the
63− * newer one on disk, or two writes could tear the file. */
64− private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
65− private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
66−
67− /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
68− open suspend fun load(sessionId: String): List<PersistedAttachment> = lockFor(sessionId).withLock {
69− withContext(Dispatchers.IO) {
70− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
71− runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
72− .getOrDefault(emptyList())
73− }
74− }
75−
76− /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
77− open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = lockFor(sessionId).withLock {
78− withContext(Dispatchers.IO) {
79− val file = fileFor(sessionId) ?: return@withContext
80− runCatchingCancellable {
81− if (attachments.isEmpty()) file.delete()
82− else {
83− // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
84− // process kill mid-write can't leave a truncated file that fails to decode
85− // and silently drops the staged attachments on reload.
86− val encoded = json.encodeToString(attachments)
87− val tmp = File(file.parentFile, file.name + ".tmp")
88− // One try/finally covers BOTH write paths: a throw from the initial
89− // tmp.writeText (disk full, permission) is cleaned up too — otherwise the
90− // inner finally below only ran after the rename failed, orphaning *.tmp
91− // files across failed first writes.
92− try {
93− tmp.writeText(encoded)
94− if (!tmp.renameTo(file)) {
95− file.writeText(encoded)
96− }
97− } finally {
98− tmp.delete()
99− }
100− }
101− }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
102− Unit
103− }
104− }
105−
106− /** Remove staged attachments for a session (call on session deletion). */
107− open suspend fun remove(sessionId: String) {
108− // Keep the per-session Mutex in [locks] rather than evicting it: remove() is not always
109− // terminal — a concurrent teardown save() for the same session must serialize against this
110− // delete under the *same* Mutex instance. Evicting here would let that save() acquire a
111− // fresh Mutex, so the two operations no longer serialize (resurrecting an orphan file, or
112− // theoretically tearing a write). The bounded per-session map growth matches the other
113− // lock-keyed stores.
114− lockFor(sessionId).withLock {
115− withContext(Dispatchers.IO) {
116− runCatchingCancellable { fileFor(sessionId)?.delete() }
117− Unit
118− }
119− }
120− }
121−}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.ktdeleted−201
@@ -1,201 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import kotlinx.coroutines.flow.first
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.decodeFromString
6−import kotlinx.serialization.encodeToString
7−import kotlinx.serialization.json.Json
8−import soy.iko.opencode.data.model.ServerProfile
9−import soy.iko.opencode.util.runCatchingCancellable
10−
11−/** A server entry in a backup. [password] is present only when the user opted to include it. */
12−@Serializable
13−data class BackupServer(
14− val id: String,
15− val label: String,
16− val baseUrl: String,
17− val username: String? = null,
18− val password: String? = null,
19− val requireHttps: Boolean = false,
20− val certPin: String? = null,
21−)
22−
23−/** App preferences in a backup. */
24−@Serializable
25−data class BackupSettings(
26− // Every field is defaulted (to SettingsStore's own defaults) so a backup written by an older
27− // app version — one whose settings block predates a field added later — still decodes. Without
28− // defaults a missing field throws MissingFieldException in import() BEFORE the server-restore
29− // loop, aborting the ENTIRE restore (servers/pins/archives included) despite the best-effort
30− // contract. ignoreUnknownKeys only tolerates EXTRA keys, not missing required ones.
31− val themeMode: String = ThemeMode.SYSTEM.name,
32− val dynamicColor: Boolean = false,
33− val sendOnEnter: Boolean = true,
34− val appLock: Boolean = false,
35− val appLockReLockSeconds: Int = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS,
36− val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
37− val codeWrap: Boolean = false,
38− val sessionSortMode: String = "RECENT",
39− val sessionSortDescending: Boolean = true,
40− val sessionShowArchived: Boolean = false,
41− val preferredModelId: String = "",
42− val preferredAgentName: String = "",
43− val compactMessageSpacing: Boolean = false,
44− val hapticsEnabled: Boolean = true,
45− val reducedMotion: Boolean = false,
46− val languageOverride: String = "",
47− val notifRunComplete: Boolean = true,
48− val notifPermission: Boolean = true,
49− val notifError: Boolean = true,
50− val swipeLeftAction: String = SwipeAction.DELETE.name,
51− val swipeRightAction: String = SwipeAction.ARCHIVE.name,
52−)
53−
54−/** The full backup document. [version] guards against future format changes: [import] rejects
55− * an unknown major version before any writes so a newer-schema backup can't silently
56− * mis-import or leave a partial restore. */
57−@Serializable
58−data class BackupData(
59− val version: Int = 1,
60− val servers: List<BackupServer> = emptyList(),
61− val settings: BackupSettings? = null,
62− val pinned: List<String> = emptyList(),
63− val archived: List<String> = emptyList(),
64−)
65−
66−/**
67− * Exports/imports the app's servers, settings, and per-session flags as a single JSON
68− * document (via the Storage Access Framework in the UI). Passwords are only written when the
69− * user explicitly opts in, since the exported file is plaintext and leaves the app's encrypted
70− * store.
71− */
72−class BackupManager(
73− private val profileStore: ProfileStore,
74− private val settingsStore: SettingsStore,
75− private val sessionPrefsStore: SessionPrefsStore,
76−) {
77− private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
78−
79− /** Backup format versions [import] knows how to restore. Bump when the schema changes
80− * incompatibly, and add the new value here once the import path handles it. An unknown
81− * version is rejected before any writes so a newer-schema backup can't silently
82− * mis-import or leave a partial restore (servers upserted, then a missing required field
83− * throws mid-settings). */
84− private val supportedVersions = setOf(1)
85−
86− /** Build the backup JSON. Reads the current profiles/settings/flags. */
87− suspend fun export(includePasswords: Boolean): String {
88− val servers = profileStore.profiles.first().map {
89− BackupServer(
90− id = it.id,
91− label = it.label,
92− baseUrl = it.baseUrl,
93− username = it.username,
94− password = if (includePasswords) it.password else null,
95− requireHttps = it.requireHttps,
96− certPin = it.certPin,
97− )
98− }
99− val settings = BackupSettings(
100− themeMode = settingsStore.themeMode.first().name,
101− dynamicColor = settingsStore.dynamicColor.first(),
102− sendOnEnter = settingsStore.sendOnEnter.first(),
103− appLock = settingsStore.appLock.first(),
104− appLockReLockSeconds = settingsStore.appLockReLockSeconds.first(),
105− chatTextScale = settingsStore.chatTextScale.first(),
106− codeWrap = settingsStore.codeWrap.first(),
107− sessionSortMode = settingsStore.sessionSortMode.first(),
108− sessionSortDescending = settingsStore.sessionSortDescending.first(),
109− sessionShowArchived = settingsStore.sessionShowArchived.first(),
110− preferredModelId = settingsStore.preferredModelId.first(),
111− preferredAgentName = settingsStore.preferredAgentName.first(),
112− compactMessageSpacing = settingsStore.compactMessageSpacing.first(),
113− hapticsEnabled = settingsStore.hapticsEnabled.first(),
114− reducedMotion = settingsStore.reducedMotion.first(),
115− languageOverride = settingsStore.languageOverride.first(),
116− notifRunComplete = settingsStore.notifRunComplete.first(),
117− notifPermission = settingsStore.notifPermission.first(),
118− notifError = settingsStore.notifError.first(),
119− swipeLeftAction = settingsStore.swipeLeftAction.first(),
120− swipeRightAction = settingsStore.swipeRightAction.first(),
121− )
122− val data = BackupData(
123− servers = servers,
124− settings = settings,
125− pinned = sessionPrefsStore.pinned.first().toList(),
126− archived = sessionPrefsStore.archived.first().toList(),
127− )
128− return json.encodeToString(data)
129− }
130−
131− /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
132− * are upserted (existing ids overwritten); settings and flags are restored best-effort.
133− * An unknown [BackupData.version] is rejected BEFORE any writes so a newer-schema backup
134− * can't silently mis-import or leave a partial restore (servers upserted, then a missing
135− * required field throws mid-settings). */
136− suspend fun import(text: String) {
137− val data = json.decodeFromString<BackupData>(text)
138− require(data.version in supportedVersions) {
139− "Unsupported backup version ${data.version}; supported: $supportedVersions"
140− }
141− var hadError = false
142− for (s in data.servers) {
143− runCatchingCancellable {
144− val username = s.username?.takeIf { it.isNotBlank() }
145− val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
146− val password = incomingPassword ?: username?.let {
147− profileStore.resolve(
148− ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
149− ).password
150− }
151− profileStore.save(
152− ServerProfile(
153− id = s.id,
154− label = s.label,
155− baseUrl = s.baseUrl,
156− username = username,
157− password = password,
158− lastUsed = 0,
159− requireHttps = s.requireHttps,
160− certPin = s.certPin?.takeIf { it.isNotBlank() },
161− ),
162− )
163− }.onFailure { hadError = true }
164− }
165− data.settings?.let { settings ->
166− runCatchingCancellable {
167− ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
168− settingsStore.setDynamicColor(settings.dynamicColor)
169− settingsStore.setSendOnEnter(settings.sendOnEnter)
170− settingsStore.setAppLock(settings.appLock)
171− settingsStore.setAppLockReLockSeconds(settings.appLockReLockSeconds)
172− settingsStore.setChatTextScale(settings.chatTextScale)
173− settingsStore.setCodeWrap(settings.codeWrap)
174− settingsStore.setSessionSortDescending(settings.sessionSortDescending)
175− settingsStore.setSessionShowArchived(settings.sessionShowArchived)
176− settingsStore.setPreferredModelId(settings.preferredModelId)
177− settingsStore.setPreferredAgentName(settings.preferredAgentName)
178− settingsStore.setCompactMessageSpacing(settings.compactMessageSpacing)
179− settingsStore.setHapticsEnabled(settings.hapticsEnabled)
180− settingsStore.setReducedMotion(settings.reducedMotion)
181− soy.iko.opencode.ui.session.SessionSortMode.entries
182− .find { it.name == settings.sessionSortMode }
183− ?.let { settingsStore.setSessionSortMode(it.name) }
184− val lang = settings.languageOverride
185− if (lang.isEmpty() || lang.matches(Regex("^[a-z]{2,3}(-[A-Za-z0-9]+)*$"))) {
186− settingsStore.setLanguageOverride(lang)
187− }
188− settingsStore.setNotifRunComplete(settings.notifRunComplete)
189− settingsStore.setNotifPermission(settings.notifPermission)
190− settingsStore.setNotifError(settings.notifError)
191− SwipeAction.entries.find { it.name == settings.swipeLeftAction }?.let { settingsStore.setSwipeLeftAction(it) }
192− SwipeAction.entries.find { it.name == settings.swipeRightAction }?.let { settingsStore.setSwipeRightAction(it) }
193− }.onFailure { hadError = true }
194− }
195− runCatchingCancellable {
196− data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
197− data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
198− }.onFailure { hadError = true }
199− if (hadError) throw IllegalStateException("Backup import completed with errors")
200− }
201−}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktdeleted−323
@@ -1,323 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.content.pm.PackageManager
5−import android.os.Build
6−import android.util.Log
7−import androidx.core.content.pm.PackageInfoCompat
8−import kotlinx.coroutines.CoroutineScope
9−import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.Job
11−import kotlinx.coroutines.SupervisorJob
12−import kotlinx.coroutines.cancel
13−import kotlinx.coroutines.delay
14−import kotlinx.coroutines.flow.MutableStateFlow
15−import kotlinx.coroutines.flow.StateFlow
16−import kotlinx.coroutines.flow.asStateFlow
17−import kotlinx.coroutines.launch
18−import java.io.File
19−import java.io.PrintWriter
20−import java.io.StringWriter
21−import java.text.SimpleDateFormat
22−import java.util.Date
23−import java.util.Locale
24−import soy.iko.opencode.util.runCatchingCancellable
25−
26−/**
27− * A self-contained crash reporter with no network or third-party backend. An uncaught
28− * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
29− * text report (timestamp, device/app metadata, and the full stack trace), then the
30− * previous handler is re-invoked so the process still dies normally.
31− *
32− * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
33− * shared, or deleted. This closes the "no idea what's crashing in the field" gap
34− * without pulling in a hosted analytics SDK.
35− */
36−class CrashLogger private constructor(private val appContext: Context) {
37−
38− data class CrashReport(
39− val fileName: String,
40− val timestamp: Long,
41− val preview: String,
42− )
43−
44− private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45− private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46− private val crashPrefs = appContext.getSharedPreferences("crash_logger", android.content.Context.MODE_PRIVATE)
47−
48− private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
49− val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
50−
51− @Volatile private var installed = false
52−
53− fun install() {
54− // Guard against double-install: a second call would capture this handler as `previous`
55− // and chain it to itself, double-writing crash reports and double-invoking the original
56− // handler on every crash. The singleton get() mitigates this in production, but install()
57− // itself must be idempotent (tests, a misconfigured init) so the chain can't form.
58− if (installed) return
59− installed = true
60− val previous = Thread.getDefaultUncaughtExceptionHandler()
61− Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
62− runCatching { writeReport(thread, throwable) }
63− .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
64− previous?.uncaughtException(thread, throwable)
65− }
66− // Load crash reports off the main thread so startup isn't blocked by file I/O
67− // (listFiles + reading the first line of each report). The StateFlow updates
68− // whenever the scan completes, so the Diagnostics screen reflects the result.
69− scope.launch { runCatchingCancellable { refresh() } }
70− }
71−
72− /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
73− * refresh() runs on the multi-threaded IO dispatcher from several call sites
74− * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
75− private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
76−
77− fun refresh() {
78− val generation = refreshGeneration.incrementAndGet()
79− // Trim over-capacity reports here (off the crashing thread, on the IO dispatcher) so
80− // the UncaughtExceptionHandler path does only the single writeText it must to land the
81− // report before the OS kills the process. Trimming on refresh keeps the cap enforced
82− // across normal app launches (install calls refresh, and every delete/clear calls it).
83− trimOldReports()
84− val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
85− ?.sortedByDescending { it.lastModified() }
86− ?.map { f ->
87− CrashReport(
88− fileName = f.name,
89− timestamp = f.lastModified(),
90− preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
91− )
92− }
93− .orEmpty()
94− // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
95− // that started before a delete) can't momentarily re-show a just-removed report.
96− if (generation == refreshGeneration.get()) _reports.value = scanned
97− }
98−
99− fun readReport(fileName: String): String? {
100− val file = File(crashDir, fileName).canonicalFile
101− if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
102− return runCatching { file.readText() }.getOrNull()
103− }
104−
105− fun deleteReport(fileName: String) {
106− scope.launch {
107− val file = File(crashDir, fileName).canonicalFile
108− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
109− file.delete()
110− }
111− refresh()
112− }
113− }
114−
115− /** Whether any crash report is newer than the last-acknowledged timestamp — i.e. a
116− * crash happened in a previous run that the user hasn't been prompted about yet.
117− * Called on cold start to surface a "crashed last time" dialog. */
118− fun hasUnacknowledgedCrash(): Boolean {
119− val acknowledgedAt = crashPrefs.getLong(KEY_ACKNOWLEDGED_AT, 0L)
120− return crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
121− ?.any { it.lastModified() > acknowledgedAt } == true
122− }
123−
124− /** Mark all current crash reports as acknowledged so the cold-start prompt doesn't
125− * re-fire on the next launch. */
126− fun acknowledgeCrashes() {
127− crashPrefs.edit().putLong(KEY_ACKNOWLEDGED_AT, System.currentTimeMillis()).apply()
128− }
129−
130− /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
131− private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
132−
133− /** Files whose scheduled delete has already claimed (passed its delay and removed its
134− * map entry) and is about to delete or has just deleted. [cancelScheduledDelete]
135− * consults this to honor its contract: once a delete has fired, undo returns false
136− * even if a reschedule installed a newer job in the map in the gap between the claim
137− * and the cancel. Without this, the cancel would remove the newer job and report
138− * "undo succeeded" while the older job's delete proceeds — contradicting the user
139− * who tapped Undo and the Diagnostics screen that then shows the file gone. */
140− private val claimedDeletes = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
141−
142− /**
143− * Schedule [fileName] for deletion after [delayMs], cancellable via
144− * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
145− * Diagnostics screen's composition scope — so navigating away during the undo window
146− * still commits the delete instead of silently dropping it. A repeated schedule for
147− * the same name replaces the prior timer.
148− */
149− fun scheduleDelete(fileName: String, delayMs: Long) {
150− val job = scope.launch {
151− delay(delayMs)
152− // Atomically claim the delete by removing our own map entry BEFORE deleting.
153− // The remove is the synchronization point with cancelScheduledDelete: whichever
154− // removes the entry first wins. If an undo raced the timer and removed it first,
155− // this returns false and we skip the delete — so the file survives, matching the
156− // `true` that cancel returned to the UI. Deleting first (then removing) would let
157− // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
158− // remove(name, thisJob) also no-ops for a stale job after a reschedule.
159− val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
160− if (!claimed) return@launch
161− // Mark this delete as fired so a cancel that arrives AFTER the claim (but before
162− // the file.delete() completes) honestly reports "already fired" instead of
163− // "undo succeeded". A reschedule that installed a newer job in the map in that
164− // gap would otherwise make cancelScheduledDelete remove the newer job and return
165− // true, while this coroutine still deletes — violating the documented contract.
166− claimedDeletes[fileName] = true
167− val file = File(crashDir, fileName).canonicalFile
168− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
169− file.delete()
170− }
171− refresh()
172− // Clear the fired marker after the delete + refresh so a future scheduleDelete
173− // for the same name (e.g. a re-created report) starts with a clean slate.
174− claimedDeletes.remove(fileName)
175− }
176− pendingDeletes.put(fileName, job)?.cancel()
177− }
178−
179− /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
180− * pending (undo succeeded), false if the delete had already fired. */
181− fun cancelScheduledDelete(fileName: String): Boolean {
182− // If the delete has already claimed (passed its delay), the undo is too late —
183− // report false even if a reschedule installed a newer job that's still in the map.
184− if (claimedDeletes.containsKey(fileName)) return false
185− val job = pendingDeletes.remove(fileName) ?: return false
186− job.cancel()
187− return true
188− }
189−
190− fun clearAll() {
191− scope.launch {
192− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
193− refresh()
194− }
195− }
196−
197− /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
198− * action gets the same undo window as a single-report delete. Runs on the logger's own
199− * scope so it commits even if the user leaves the Diagnostics screen. */
200− // AtomicReference (not a bare @Volatile var): the timer-fired claim must be a CAS,
201− // not an unconditional null, else a reschedule that installed a newer job loses its
202− // entry to the old coroutine's null and the clear runs immediately instead of after
203− // the new delay (and cancelScheduledClearAll then falsely reports "already fired").
204− // Mirrors scheduleDelete's ConcurrentHashMap.remove(name, job) claim.
205− private val pendingClearAll = java.util.concurrent.atomic.AtomicReference<Job?>(null)
206−
207− fun scheduleClearAll(delayMs: Long) {
208− val job = scope.launch {
209− delay(delayMs)
210− // Atomically claim the clear by CAS-ing our own entry to null, mirroring
211− // scheduleDelete's claim pattern: a reschedule (or cancel) that nulls first
212− // makes this a no-op. Without the CAS, an unconditional null would wipe a
213− // newer job installed by a reschedule.
214− val mine = coroutineContext[Job]
215− if (mine == null || !pendingClearAll.compareAndSet(mine, null)) return@launch
216− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
217− refresh()
218− }
219− pendingClearAll.getAndSet(job)?.cancel()
220− }
221−
222− /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
223− fun cancelScheduledClearAll(): Boolean {
224− val job = pendingClearAll.getAndSet(null) ?: return false
225− job.cancel()
226− return true
227− }
228−
229− fun reportCount(): Int = _reports.value.size
230−
231− /** Cancel the internal coroutine scope so background work doesn't outlive the
232− * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
233− * intentionally left installed — a crash during shutdown still needs to be logged. */
234− fun shutdown() {
235− scope.cancel()
236− }
237−
238− private fun writeReport(thread: Thread, throwable: Throwable) {
239− val now = Date()
240− val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
241− // Append a short random suffix so two crashes in the same millisecond don't
242− // overwrite each other.
243− val suffix = java.lang.Long.toHexString(System.nanoTime())
244− val file = File(crashDir, "crash-$stamp-$suffix.txt")
245− val sw = StringWriter()
246− PrintWriter(sw).use { pw ->
247− pw.println("opencode-android crash report")
248− pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
249− pw.println("Thread: ${thread.name}")
250− pw.println("App version: ${appVersion()}")
251− pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
252− pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
253− pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
254− pw.println()
255− pw.println(throwable.javaClass.name + ": " + throwable.message)
256− throwable.printStackTrace(pw)
257− }
258− // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
259− // message (and every "Caused by:" message) verbatim, so scrubbing only the
260− // top-level message above is futile — Ktor/OkHttp embed the full request URL
261− // in those messages, which may contain auth or internal paths. Scrubbing the
262− // assembled text guarantees no URL survives anywhere in the stored report.
263− //
264− // NOTE: trimOldReports() is intentionally NOT called here. writeReport runs on the
265− // crashing thread inside the UncaughtExceptionHandler, where the OS gives ~5s before
266− // SIGKILL; trimOldReports does listFiles + sortedByDescending + N deletes that could
267− // exceed that window on a slow device with many old reports, losing THIS report. The
268− // trim is driven by refresh() instead (IO dispatcher, off the crashing thread), so the
269− // crash path does only the single writeText it must to preserve the report.
270− file.writeText(scrubUrls(sw.toString()))
271− }
272−
273− /** Keep only the [MAX_REPORTS] most recent crash reports, deleting older ones. Runs inside
274− * the uncaught-exception handler (the whole call is wrapped in runCatching upstream), so it
275− * must not throw; each delete is guarded. */
276− private fun trimOldReports() {
277− val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return
278− if (files.size <= MAX_REPORTS) return
279− files.sortedByDescending { it.lastModified() }
280− .drop(MAX_REPORTS)
281− .forEach { runCatching { it.delete() } }
282− }
283−
284− private fun appVersion(): String = runCatching {
285− val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
286− appContext.packageManager.getPackageInfo(
287− appContext.packageName,
288− PackageManager.PackageInfoFlags.of(0),
289− )
290− } else {
291− @Suppress("DEPRECATION")
292− appContext.packageManager.getPackageInfo(appContext.packageName, 0)
293− }
294− "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
295− }.getOrDefault("unknown")
296−
297− companion object {
298− /** Cap on retained crash report files. A deterministic crash-loop (e.g. a crash on
299− * startup) writes one report per crash with no other bound, so without this the
300− * crashes/ dir grows without limit until the user manually clears it. */
301− private const val MAX_REPORTS = 20
302−
303− private const val KEY_ACKNOWLEDGED_AT = "acknowledged_at"
304−
305− /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
306− internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
307−
308− /**
309− * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
310− * redaction (used on the whole crash report, including exception messages and
311− * stack traces produced by printStackTrace) can be unit-tested in isolation.
312− */
313− internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
314−
315− @Volatile
316− private var instance: CrashLogger? = null
317−
318− fun get(context: Context): CrashLogger =
319− instance ?: synchronized(this) {
320− instance ?: CrashLogger(context.applicationContext).also { instance = it }
321− }
322− }
323−}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktdeleted−295
@@ -1,295 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.CoroutineScope
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.flow.MutableStateFlow
7−import kotlinx.coroutines.flow.StateFlow
8−import kotlinx.coroutines.flow.asStateFlow
9−import kotlinx.coroutines.flow.update
10−import android.util.Log
11−import kotlinx.coroutines.launch
12−import soy.iko.opencode.util.runCatchingCancellable
13−import java.util.concurrent.Executors
14−
15−/**
16− * Persists the in-progress message draft for each session so it survives
17− * back-navigation and process death. Backed by SharedPreferences because the
18− * UI needs the initial value synchronously and drafts are low-stakes data.
19− *
20− * The prefs file is loaded on a background thread at construction time. Until
21− * the load completes, [drafts] reports an empty map and [get] returns "" rather
22− * than blocking the caller (previously a [CountDownLatch] could stall the main
23− * thread for up to 2 seconds on a slow disk).
24− */
25−open class DraftStore private constructor(
26− private val appContext: Context?,
27− private val scope: CoroutineScope?,
28− @Suppress("unused") private val testMode: Boolean,
29−) {
30− constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
31− protected constructor() : this(null, null, true)
32−
33− private val prefs by lazy {
34− appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
35− ?: error("No context — override methods in test subclass")
36− }
37−
38− /** Single-thread executor for flushDraft when prefs aren't initialized yet,
39− * so the main thread is never blocked on disk I/O. */
40− private val flushExecutor by lazy {
41− Executors.newSingleThreadExecutor { r ->
42− Thread(r, "DraftStore-flush").apply { isDaemon = true }
43− }
44− }
45−
46− private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
47− /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
48− open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
49−
50− /** Queued follow-up messages per session, persisted under a namespaced key so a
51− * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
52− * ChatViewModel would otherwise lose it, along with the cleared draft). */
53− private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
54−
55− private val _ready = MutableStateFlow(false)
56− /** True once the background prefs load has completed. */
57− open val ready: StateFlow<Boolean> = _ready.asStateFlow()
58−
59− /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
60− * finished. For these the in-memory state is authoritative — the load must not
61− * resurrect a persisted value the user has since cleared, which a plain putAll(current)
62− * merge cannot express (it can add/override, but not delete). Consulted once, in the
63− * init merge. Separate sets because drafts and follow-ups share the session-id key space
64− * but live under different prefs keys. */
65− // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
66− // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
67− private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
68− private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
69−
70− private fun markDraftTouched(sessionId: String) {
71− if (!_ready.value) touchedDraftKeys.add(sessionId)
72− }
73− private fun markFollowUpTouched(sessionId: String) {
74− if (!_ready.value) touchedFollowUpKeys.add(sessionId)
75− }
76−
77− init {
78− // Eagerly load the prefs file on a background thread so the first main-thread
79− // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
80− // so callers can observe readiness without blocking.
81− if (scope != null && appContext != null) {
82− scope.launch(Dispatchers.IO) {
83− // runCatchingCancellable (not plain runCatching): this block runs in a
84− // coroutine, and plain runCatching swallows CancellationException — if a
85− // future suspend point is added here, cancellation would leave _ready
86− // forever false and drafts stuck loading. Matches the convention used by
87− // the sibling stores (MessageCacheStore, OutboxStore, AttachmentDraftStore).
88− val all = runCatchingCancellable {
89− prefs.all.mapValues { it.value as? String ?: "" }
90− }.getOrDefault(emptyMap())
91− // Partition persisted keys: follow-up keys carry a prefix, everything else
92− // is a plain per-session draft.
93− val drafts = mutableMapOf<String, String>()
94− val followUps = mutableMapOf<String, String>()
95− all.forEach { (k, v) ->
96− if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
97− else drafts[k] = v
98− }
99− // Merge instead of overwriting: any in-memory writes that arrived before
100− // the background load completed (e.g. a share-intent draft injected via
101− // setImmediate) take priority over the persisted snapshot so they aren't
102− // clobbered. For keys the user explicitly touched before the load finished,
103− // drop the persisted entry first so a value they *cleared* isn't resurrected
104− // (the cleared key is absent from `current`, so putAll alone can't remove it).
105− _drafts.update { current ->
106− drafts.apply {
107− touchedDraftKeys.forEach { remove(it) }
108− putAll(current)
109− }
110− }
111− _followUps.update { current ->
112− followUps.apply {
113− touchedFollowUpKeys.forEach { remove(it) }
114− putAll(current)
115− }
116− }
117− _ready.value = true
118− }
119− } else {
120− _ready.value = true
121− }
122− }
123−
124− /** Returns the draft for [sessionId], or "" if not found or still loading. */
125− open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
126−
127− /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
128− open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
129−
130− /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
131− * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
132− * so a follow-up queued just before process death is flushed before the process exits.
133− * A blank [text] clears the stored follow-up. */
134− open fun flushFollowUp(sessionId: String, text: String) {
135− markFollowUpTouched(sessionId)
136− _followUps.update { current ->
137− val updated = current.toMutableMap()
138− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
139− updated
140− }
141− writeAsync(FOLLOWUP_PREFIX + sessionId, text)
142− }
143−
144− /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
145− * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
146− private fun writeAsync(key: String, text: String) {
147− val write = {
148− runCatching {
149− prefs.edit().apply {
150− if (text.isBlank()) remove(key) else putString(key, text)
151− }.apply()
152− }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
153− Unit
154− }
155− // FIX: always submit to the single-thread flushExecutor (never apply directly) so
156− // writes persist in submission order. Previously the post-init fast path applied
157− // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
158− // write, persisting stale text. The task resolves the prefs lazy and calls apply().
159− submitFlush(write, key)
160− }
161−
162− /** Update the in-memory draft immediately without persisting to disk.
163− * Used when navigation must see the new value synchronously (e.g. share-intent
164− * injection before ChatScreen composes). Call [set] afterwards to persist. */
165− open fun setImmediate(sessionId: String, text: String) {
166− markDraftTouched(sessionId)
167− _drafts.update { current ->
168− val updated = current.toMutableMap()
169− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
170− updated
171− }
172− }
173−
174− open suspend fun set(sessionId: String, text: String) {
175− markDraftTouched(sessionId)
176− _drafts.update { current ->
177− val updated = current.toMutableMap()
178− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
179− updated
180− }
181− // Route persistence through the single-thread flushExecutor (like the other writers)
182− // rather than the shared IO pool, so this write can't race ahead of an earlier queued
183− // flush and land stale. writeAsync persists sessionId=text (or removes it when blank),
184− // which is exactly this draft's persistence.
185− writeAsync(sessionId, text)
186− }
187−
188− /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
189− * Also clears any queued follow-up so a deleted session leaves nothing behind. */
190− open suspend fun remove(sessionId: String) {
191− markDraftTouched(sessionId)
192− markFollowUpTouched(sessionId)
193− _drafts.update { current ->
194− val updated = current.toMutableMap()
195− updated.remove(sessionId)
196− updated
197− }
198− _followUps.update { current ->
199− val updated = current.toMutableMap()
200− updated.remove(sessionId)
201− updated
202− }
203− // Submit the (combined draft + follow-up) removal to the same single-thread
204− // flushExecutor the other writers use, so it can't race ahead of an earlier queued
205− // write and resurrect stale text. Mirrors flushDraft's submission pattern.
206− submitFlush(
207− {
208− runCatching {
209− prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
210− }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
211− },
212− "remove($sessionId)",
213− )
214− }
215−
216− /**
217− * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
218− * viewModelScope is already cancelled and a suspending write can't be used.
219− *
220− * If the SharedPreferences file has already been opened (the normal case — the
221− * background init load touches it shortly after construction), the write uses
222− * [apply] (asynchronous) and returns immediately without blocking the main thread.
223− *
224− * If the prefs file has NOT been opened yet (a rare race where onCleared fires
225− * before the background init), the write is dispatched to a background thread
226− * instead of blocking the main thread on a synchronous disk read. The draft
227− * may be lost if the process exits before the write completes, but this is
228− * preferable to an ANR.
229− */
230− open fun flushDraft(sessionId: String, text: String) {
231− markDraftTouched(sessionId)
232− _drafts.update { current ->
233− val updated = current.toMutableMap()
234− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
235− updated
236− }
237− // FIX: always route the persistence through the single-thread flushExecutor so writes
238− // land in submission order. Previously, once prefs were open this applied synchronously
239− // on the calling (main) thread, so a newer direct write could persist BEFORE an older
240− // still-queued write completed, clobbering the draft with stale text. The executor task
241− // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
242− // lost only if the process exits before the queued task runs (as previously).
243− submitFlush(
244− {
245− runCatching {
246− prefs.edit().apply {
247− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
248− }.apply()
249− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
250− },
251− "flushDraft($sessionId)",
252− )
253− }
254−
255− /** Submit a flush task to the single-thread executor, surfacing a post-shutdown submit as a
256− * logged warning instead of silently swallowing the RejectedExecutionException. Previously
257− * the bare `runCatching { flushExecutor.execute { ... } }` swallowed RejectedExecutionException
258− * after [shutdown], so the in-memory _drafts was already updated but the disk write never
259− * landed with no signal — a draft silently lost. Logging makes the failure observable so
260− * ordering bugs (shutdown before all VM onCleared flushes) are diagnosable. */
261− private fun submitFlush(task: () -> Unit, label: String) {
262− if (flushExecutor.isShutdown) {
263− Log.w("DraftStore", "Dropping flush '$label': executor is shut down")
264− return
265− }
266− runCatching { flushExecutor.execute { task() } }
267− .onFailure { Log.w("DraftStore", "Failed to submit flush '$label'", it) }
268− }
269−
270− private companion object {
271− /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
272− * prefs file. The leading/trailing spaces can't collide with a real opencode session
273− * id (which are non-blank, space-free tokens like "ses_..."). */
274− const val FOLLOWUP_PREFIX = " followup "
275− }
276−
277− /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
278− open fun shutdown() {
279− flushExecutor.shutdown()
280− // Await pending flushDraft writes so drafts aren't lost if the process exits
281− // immediately after shutdown (e.g. ANR-triggered process kill). Loop until the
282− // executor is truly idle (or 5s elapses) rather than a single 2s wait: a slow disk
283− // with a deep queue of keystroke-triggered flushes can exceed 2s, and dropping the
284− // tail would lose the user's most recent edits. The JVM shutdown hook's own deadline
285− // is the hard limit; this loop just gives the executor the best chance to drain.
286− var waitedMs = 0L
287− while (waitedMs < 5000L) {
288− if (flushExecutor.isTerminated) break
289− runCatching {
290− flushExecutor.awaitTermination(500, java.util.concurrent.TimeUnit.MILLISECONDS)
291− }
292− waitedMs += 500L
293− }
294− }
295−}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktdeleted−81
@@ -1,81 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import io.ktor.client.plugins.ClientRequestException
4−import io.ktor.client.plugins.HttpRequestTimeoutException
5−import io.ktor.client.plugins.RedirectResponseException
6−import io.ktor.client.plugins.ResponseException
7−import io.ktor.client.plugins.ServerResponseException
8−import java.io.IOException
9−import java.net.ConnectException
10−import java.net.SocketTimeoutException
11−import java.net.UnknownHostException
12−
13−/**
14− * User-facing categories for a thrown error. Extracted so the classification logic is
15− * unit-testable without an Android [android.content.Context] (the mapping from a kind
16− * to a localized string lives in [friendlyError]).
17− */
18−enum class ErrorKind {
19− /** Server host could not be resolved / connection refused. */
20− NOT_REACHABLE,
21−
22− /** Connect or read timed out. */
23− TIMEOUT,
24−
25− /** Server returned a 5xx response. */
26− SERVER,
27−
28− /** Server returned a 4xx response (auth, not-found, etc.). */
29− CLIENT,
30−
31− /** Any other network-level I/O failure. */
32− NETWORK,
33−
34− /** Anything that isn't network-related. */
35− UNKNOWN,
36−}
37−
38−/**
39− * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
40− * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
41− * by a library still classifies correctly.
42− */
43−fun classifyError(t: Throwable): ErrorKind {
44− var current: Throwable? = t
45− // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
46− // IOException) still classify by their root.
47− var hops = 0
48− while (current != null && hops < 8) {
49− when (current) {
50− is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
51− is SocketTimeoutException, is HttpRequestTimeoutException ->
52− return ErrorKind.TIMEOUT
53− is ServerResponseException -> return ErrorKind.SERVER
54− // RedirectResponseException extends ClientRequestException, so it must be checked
55− // first: a 3xx response is semantically distinct from a 4xx, and matching the
56− // parent class first would make this branch unreachable dead code.
57− is RedirectResponseException -> return ErrorKind.CLIENT
58− is ClientRequestException -> return ErrorKind.CLIENT
59− is IOException -> return ErrorKind.NETWORK
60− else -> {
61− // Any other ResponseException (unrecognized status family).
62− if (current is ResponseException) return ErrorKind.SERVER
63− }
64− }
65− current = current.cause
66− hops++
67− }
68− return ErrorKind.UNKNOWN
69−}
70−
71−/** The HTTP status code carried by a client/server error, if any. */
72−fun responseStatusCode(t: Throwable): Int? {
73− var current: Throwable? = t
74− var hops = 0
75− while (current != null && hops < 8) {
76− if (current is ResponseException) return current.response.status.value
77− current = current.cause
78− hops++
79− }
80− return null
81−}
app/src/main/java/soy/iko/opencode/data/repo/FileBrowserPrefs.ktdeleted−90
@@ -1,90 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.booleanPreferencesKey
5−import androidx.datastore.preferences.core.edit
6−import androidx.datastore.preferences.core.stringPreferencesKey
7−import androidx.datastore.preferences.preferencesDataStore
8−import kotlinx.coroutines.flow.Flow
9−import kotlinx.coroutines.flow.flowOf
10−import kotlinx.coroutines.flow.map
11−
12−private val Context.fileBrowserPrefsDataStore by preferencesDataStore(name = "file_browser_prefs")
13−
14−/** Sort axis for the file-browser directory listing. Name is always available; Size/Modified
15− * depend on the server emitting size/mtime (otherwise they sort nulls to the bottom). */
16−enum class FileSortKey { NAME, SIZE, MODIFIED }
17−
18−/**
19− * Persisted file-browser display prefs (folders-first, show-hidden, sort key + direction,
20− * changed-only filter) so a user who prefers size-sort or hidden-files-visible keeps it
21− * across restarts, not just rotation.
22− *
23− * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
24− * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
25− * eager initializers) and null-guard the context so the test constructor never touches disk.
26− */
27−@Suppress("TooManyFunctions")
28−open class FileBrowserPrefs private constructor(
29− private val appContext: Context?,
30− @Suppress("unused") private val testMode: Boolean,
31−) {
32− constructor(context: Context) : this(context.applicationContext, false)
33− protected constructor() : this(null, true)
34−
35− private val foldersFirstKey = booleanPreferencesKey("folders_first")
36− private val showHiddenKey = booleanPreferencesKey("show_hidden")
37− private val sortKeyPref = stringPreferencesKey("sort_key")
38− private val sortDescKey = booleanPreferencesKey("sort_desc")
39− private val changedOnlyKey = booleanPreferencesKey("changed_only")
40−
41− // Folders-first is the most useful default; the toggle flips between folders-first name
42− // sort and the server's raw order.
43− open val foldersFirst: Flow<Boolean>
44− get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[foldersFirstKey] ?: true }
45− ?: flowOf(true)
46−
47− // Default off so the listing isn't cluttered with dotfiles; the user reveals .env / .git
48− // / .opencode on demand.
49− open val showHidden: Flow<Boolean>
50− get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[showHiddenKey] ?: false }
51− ?: flowOf(false)
52−
53− // Stored as the enum name; an unknown value falls back to NAME (forward-compatible with a
54− // future rename/removal of an enum entry).
55− open val sortKey: Flow<FileSortKey>
56− get() = appContext?.fileBrowserPrefsDataStore?.data?.map { prefs ->
57− runCatching { FileSortKey.valueOf(prefs[sortKeyPref] ?: FileSortKey.NAME.name) }
58− .getOrDefault(FileSortKey.NAME)
59− } ?: flowOf(FileSortKey.NAME)
60−
61− open val sortDesc: Flow<Boolean>
62− get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[sortDescKey] ?: false }
63− ?: flowOf(false)
64−
65− // Default off: the changed-only filter narrows the listing to git-tracked changes, which is
66− // useful when reviewing a diff but not the default browsing view.
67− open val changedOnly: Flow<Boolean>
68− get() = appContext?.fileBrowserPrefsDataStore?.data?.map { it[changedOnlyKey] ?: false }
69− ?: flowOf(false)
70−
71− open suspend fun setFoldersFirst(value: Boolean) {
72− appContext?.fileBrowserPrefsDataStore?.edit { it[foldersFirstKey] = value }
73− }
74−
75− open suspend fun setShowHidden(value: Boolean) {
76− appContext?.fileBrowserPrefsDataStore?.edit { it[showHiddenKey] = value }
77− }
78−
79− open suspend fun setSortKey(key: FileSortKey) {
80− appContext?.fileBrowserPrefsDataStore?.edit { it[sortKeyPref] = key.name }
81− }
82−
83− open suspend fun setSortDesc(value: Boolean) {
84− appContext?.fileBrowserPrefsDataStore?.edit { it[sortDescKey] = value }
85− }
86−
87− open suspend fun setChangedOnly(value: Boolean) {
88− appContext?.fileBrowserPrefsDataStore?.edit { it[changedOnlyKey] = value }
89− }
90−}
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.ktdeleted−153
@@ -1,153 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.sync.Mutex
7−import kotlinx.coroutines.sync.withLock
8−import kotlinx.coroutines.withContext
9−import kotlinx.serialization.builtins.ListSerializer
10−import soy.iko.opencode.data.model.MessageWithParts
11−import soy.iko.opencode.data.network.OpencodeJson
12−import soy.iko.opencode.util.runCatchingCancellable
13−import java.io.File
14−
15−/**
16− * On-disk cache of a session's reduced message list, so a conversation shows instantly on
17− * open (and remains readable while offline / before the network responds) instead of a blank
18− * screen until `GET /session/:id/message` returns.
19− *
20− * One JSON file per session under filesDir, encoded with [OpencodeJson] so the same sealed
21− * polymorphism used on the wire round-trips (unknown parts/messages are concrete
22− * `__unknown` variants, so they encode too). Every operation is best-effort — a cache
23− * miss or a write failure just falls back to the network — so problems never surface to
24− * the user. No annotation processing (Room/KSP) per the project's manual-DI convention.
25− */
26−open class MessageCacheStore private constructor(
27− private val appContext: Context?,
28− @Suppress("unused") private val testMode: Boolean,
29−) {
30− constructor(context: Context) : this(context.applicationContext, false)
31− protected constructor() : this(null, true)
32−
33− private val serializer = ListSerializer(MessageWithParts.serializer())
34−
35− /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
36− * escape the directory. */
37− private val idRegex = Regex("[^A-Za-z0-9_-]")
38−
39− private val dir: File? by lazy {
40− appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
41− }
42−
43− /** Per-profile subdirectory: two servers with a colliding session id must not share a cache
44− * file, so entries are namespaced by profile id under [dir]. */
45− private fun profileDir(profileId: String): File? =
46− dir?.let { File(it, idRegex.replace(profileId, "_")).apply { mkdirs() } }
47−
48− private fun fileFor(profileId: String, sessionId: String): File? =
49− profileDir(profileId)?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
50−
51− /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
52− * panes of two-pane mode saving the same session, or a save racing a delete's remove)
53− * can't tear or half-write the cache file. Keyed by session id; the map grows only with
54− * the number of distinct sessions touched, and each [Mutex] is a few bytes. */
55− private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
56− private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
57−
58− /** Sessions whose cache file has been removed by [remove]. A teardown flush of an
59− * observer (CacheWriter.flushOnTeardown) runs under NonCancellable and can race with
60− * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
61− * If the flush's save lands after remove, it re-creates the deleted session's cache
62− * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
63− * tombstone lets a post-remove save detect the deletion and skip. Keyed by
64− * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
65− * saves if two servers share a colliding session id (the very case the per-profile
66− * namespacing at [profileDir] exists to defend against). Cleared by [load] so
67− * re-opening a session re-enables saving. Capped at [NetworkConfig.maxMessageCacheTombstones]
68− * entries (see [remove]) to prevent unbounded growth across many deletions. */
69− private val tombstones: MutableSet<String> =
70− java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
71−
72− private fun tombstoneKey(profileId: String, sessionId: String): String = "$profileId/$sessionId"
73−
74− /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
75− open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
76− // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
77− // write isn't suppressed by a prior deletion of the same (reused) id.
78− tombstones.remove(tombstoneKey(profileId, sessionId))
79− withContext(Dispatchers.IO) {
80− val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
81− runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
82− .getOrDefault(emptyList())
83− }
84− }
85−
86− /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
87− open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
88− // Skip if this session was deleted since the observer started: a teardown flush racing
89− // after remove() must not re-create the file. (Held under the same per-session lock as
90− // remove, so the tombstone is visible to a save that lost the race.)
91− if (tombstoneKey(profileId, sessionId) in tombstones) return@withLock
92− withContext(Dispatchers.IO) {
93− val file = fileFor(profileId, sessionId) ?: return@withContext
94− runCatchingCancellable {
95− if (messages.isEmpty()) file.delete()
96− else {
97− // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
98− // process kill mid-write can't leave a truncated file that fails to decode,
99− // wiping the cached conversation on the next open.
100− val encoded = OpencodeJson.encodeToString(serializer, messages)
101− val tmp = File(file.parentFile, file.name + ".tmp")
102− // One try/finally covers BOTH write paths: a throw from the initial
103− // tmp.writeText (disk full, permission) is cleaned up too — otherwise the
104− // inner finally below only ran after the rename failed, orphaning *.tmp
105− // files across failed first writes.
106− try {
107− tmp.writeText(encoded)
108− if (!tmp.renameTo(file)) {
109− file.writeText(encoded)
110− }
111− } finally {
112− tmp.delete()
113− }
114− }
115− }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
116− Unit
117− }
118− }
119−
120− /** Remove a session's cached messages (call on deletion). Deletes only the calling
121− * profile's cache file — iterating every profile namespace would nuke a *different*
122− * server's cache for a colliding (but distinct) session id, the very case per-profile
123− * namespacing exists to defend against. The per-session lock is intentionally NOT
124− * evicted — a concurrent teardown save() for this id must serialize against the same
125− * [Mutex] instance, and the map is bounded by the number of distinct sessions touched.
126− * [profileId] namespaces the tombstone so a delete on one server doesn't suppress another
127− * server's saves for a colliding session id. The back-compat flat file (pre-namespacing)
128− * is also cleared since it carries no profile namespace. */
129− open suspend fun remove(profileId: String, sessionId: String) {
130− lockFor(sessionId).withLock {
131− // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
132− // instead of re-creating the file we're about to delete.
133− tombstones.add(tombstoneKey(profileId, sessionId))
134− // Cap the tombstone set so it doesn't grow unbounded across many session deletions.
135− // Tombstones only protect against a racing teardown flush (which resolves within
136− // seconds), so stale entries are useless. Clearing wholesale is safe because no
137− // racing flush can still be alive for a session deleted long ago.
138− if (tombstones.size > soy.iko.opencode.data.network.NetworkConfig.maxMessageCacheTombstones) {
139− tombstones.clear()
140− tombstones.add(tombstoneKey(profileId, sessionId))
141− }
142− withContext(Dispatchers.IO) {
143− runCatchingCancellable {
144− val name = idRegex.replace(sessionId, "_") + ".json"
145− profileDir(profileId)?.let { File(it, name).delete() }
146− // Back-compat: pre-namespacing caches were written flat under [dir].
147− dir?.let { File(it, name).delete() }
148− }
149− Unit
150− }
151− }
152− }
153−}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.ktdeleted−192
@@ -1,192 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.flow.MutableStateFlow
7−import kotlinx.coroutines.flow.StateFlow
8−import kotlinx.coroutines.flow.asStateFlow
9−import kotlinx.coroutines.sync.Mutex
10−import kotlinx.coroutines.sync.withLock
11−import kotlinx.coroutines.withContext
12−import kotlinx.serialization.Serializable
13−import kotlinx.serialization.decodeFromString
14−import kotlinx.serialization.encodeToString
15−import kotlinx.serialization.json.Json
16−import soy.iko.opencode.data.model.ModelRef
17−import soy.iko.opencode.util.runCatchingCancellable
18−import java.io.File
19−
20−/**
21− * One prompt composed while offline (or with no active connection), queued to send when
22− * connectivity returns. Tagged with the [profileId] it was composed for so a reconnect to a
23− * *different* server doesn't misfire it against a session id that only exists on the
24− * original server. [attachments] carry their self-contained base64 data URLs, so a queued
25− * image survives a process restart just like a staged attachment.
26− */
27−@Serializable
28−data class OutboxMessage(
29− val id: String,
30− val profileId: String,
31− val sessionId: String,
32− val text: String,
33− val attachments: List<PersistedAttachment> = emptyList(),
34− val model: ModelRef? = null,
35− val agent: String? = null,
36− val createdAt: Long,
37−)
38−
39−/**
40− * A persisted send-queue: prompts the user composed while disconnected, flushed on
41− * reconnect (see [soy.iko.opencode.di.AppContainer.observeOutbox]). Backed by one JSON file
42− * under filesDir (not SharedPreferences) because a queued attachment's base64 payload can be
43− * several megabytes. The in-memory [messages] StateFlow is the source of truth the UI and the
44− * flusher observe; disk is written through under a mutex on every mutation.
45− */
46−open class OutboxStore private constructor(
47− private val appContext: Context?,
48− @Suppress("unused") private val testMode: Boolean,
49−) {
50− constructor(context: Context) : this(context.applicationContext, false)
51− protected constructor() : this(null, true)
52−
53− private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
54− private val ioMutex = Mutex()
55−
56− private val _messages = MutableStateFlow<List<OutboxMessage>>(emptyList())
57− /** All queued messages across sessions/servers, in enqueue order. */
58− open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
59−
60− /** Set once the on-disk queue has been read into [_messages] (under [ioMutex]). Until this
61− * is true, a [mutate] must read disk first — otherwise a mutation that beats [load] would
62− * persist from an empty in-memory snapshot and overwrite (or, for a removal, delete) the
63− * persisted queue, losing every message the user composed offline in a previous run. */
64− private var loaded = false
65−
66− private val file: File? by lazy {
67− appContext?.let { File(it.filesDir, "outbox.json") }
68− }
69−
70− /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
71− open suspend fun load() {
72− ioMutex.withLock { loadLocked() }
73− }
74−
75− /** Read the on-disk queue into memory. MUST be called while holding [ioMutex]. Idempotent
76− * (guarded by [loaded]) so a [mutate] that arrives before [load] can call it to guarantee
77− * disk is read before any write, then a later [load] is a no-op. Returns true once the queue
78− * is in memory (or the file is genuinely absent), false if an existing file couldn't be
79− * read — the caller MUST NOT persist on false or it would overwrite the still-valid queue. */
80− private suspend fun loadLocked(): Boolean {
81− if (loaded) return true
82− val f = file?.takeIf { it.exists() }
83− if (f == null) {
84− // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
85− loaded = true
86− return true
87− }
88− // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
89− // cancelled read) of an *existing* file must not clobber the still-valid on-disk queue:
90− // leave loaded=false and report failure so the caller skips its write and a later
91− // mutate/load retries the read, instead of persisting an empty snapshot over the user's
92− // offline-composed messages.
93− val diskList = withContext(Dispatchers.IO) {
94− runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
95− }.getOrElse {
96− // A corrupt or unreadable outbox.json would permanently brick the queue: every
97− // subsequent mutate retries the read, fails, and skips its persist. Delete the
98− // corrupt file so the queue can recover — the data is already unreadable, and
99− // preserving any in-memory messages enqueued since the failure keeps new work.
100− Log.w("OutboxStore", "Corrupt outbox.json; deleting and starting fresh", it)
101− runCatchingCancellable { f.delete() }
102− loaded = true
103− return true
104− }
105− // Don't clobber items enqueued while the load was in flight: merge by id, keeping
106− // the in-memory version on conflict (it's newer).
107− if (_messages.value.isEmpty()) {
108− _messages.value = diskList.sortedBy { it.createdAt }
109− } else {
110− val known = _messages.value.mapTo(mutableSetOf()) { it.id }
111− val merged = (diskList.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
112− _messages.value = merged
113− // The merge reconciled items enqueued while this load was in flight with the
114− // on-disk set; write the merged set back so a later process kill can't drop the
115− // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
116− persist(merged)
117− }
118− loaded = true
119− return true
120− }
121−
122− open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
123− current.filterNot { it.id == message.id } + message
124− }
125−
126− open suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
127−
128− /** Remove every queued message for [sessionId] (e.g. the user discarded them, or the
129− * session was deleted). */
130− open suspend fun removeForSession(sessionId: String) =
131− mutate { current -> current.filterNot { it.sessionId == sessionId } }
132−
133− private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
134− // Persist *under* the lock so concurrent mutations (e.g. the flusher removing sent
135− // messages while the UI enqueues a new one) can't race their disk writes: without
136− // this, the older snapshot could land last and drop/resurrect a queued prompt, or two
137− // overlapping writeText/delete calls could tear the file. The lock serializes both the
138− // in-memory update and the write, guaranteeing on-disk order matches memory order.
139− ioMutex.withLock {
140− // Ensure the persisted queue has been read into memory before mutating: a mutation
141− // that races ahead of load() would otherwise transform an empty in-memory snapshot
142− // and persist()/delete() the file, destroying messages queued in a previous run.
143− val loadedOk = loadLocked()
144− val updated = transform(_messages.value).sortedBy { it.createdAt }
145− _messages.value = updated
146− // Only write when the existing on-disk queue was actually read. If the read of an
147− // existing file failed (loadedOk == false), _messages is missing whatever it held, so
148− // persisting `updated` would overwrite the still-valid file with an incomplete
149− // snapshot — losing the user's prior offline-composed messages. Keep the transform in
150− // memory (so the UI reflects the new item) but leave disk untouched; loaded stays
151− // false, so the next mutate/load retries the read and then merges + persists.
152− if (loadedOk) persist(updated)
153− }
154− }
155−
156− private suspend fun persist(list: List<OutboxMessage>) {
157− val f = file ?: return
158− withContext(Dispatchers.IO) {
159− runCatchingCancellable {
160− if (list.isEmpty()) {
161− f.delete()
162− } else {
163− // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
164− // process kill mid-write can't leave a truncated file — a torn file fails to
165− // decode and load() would then drop ALL queued offline messages.
166− val encoded = json.encodeToString(list)
167− val tmp = File(f.parentFile, f.name + ".tmp")
168− // One try/finally covers BOTH write paths: a throw from the initial
169− // tmp.writeText (disk full, permission) is cleaned up too — otherwise the
170− // inner finally below only ran after the rename failed, orphaning *.tmp
171− // files across failed first writes.
172− try {
173− tmp.writeText(encoded)
174− if (!tmp.renameTo(f)) {
175− try {
176− java.nio.file.Files.move(
177− tmp.toPath(), f.toPath(),
178− java.nio.file.StandardCopyOption.REPLACE_EXISTING,
179− )
180− } catch (e: Exception) {
181− Log.w("OutboxStore", "NIO move failed; falling back to direct write", e)
182− f.writeText(encoded)
183− }
184− }
185− } finally {
186− tmp.delete()
187− }
188− }
189− }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
190− }
191− }
192−}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktdeleted−285
@@ -1,285 +0,0 @@
1−// Jetpack Security (androidx.security:security-crypto) was deprecated wholesale in 1.1.0 with
2−// no first-party drop-in replacement; we intentionally keep using EncryptedSharedPreferences /
3−// MasterKey for the encrypted password store (on-disk-compatible, still functional). Suppress
4−// the expected deprecation warnings here so the build log stays clean until a future migration.
5−@file:Suppress("DEPRECATION")
6−
7−package soy.iko.opencode.data.repo
8−
9−import android.content.Context
10−import android.content.SharedPreferences
11−import android.util.Log
12−import androidx.datastore.preferences.core.edit
13−import androidx.datastore.preferences.core.stringPreferencesKey
14−import androidx.datastore.preferences.preferencesDataStore
15−import androidx.security.crypto.EncryptedSharedPreferences
16−import androidx.security.crypto.MasterKey
17−import soy.iko.opencode.data.model.ServerProfile
18−import soy.iko.opencode.data.network.OpencodeJson
19−import kotlinx.coroutines.Dispatchers
20−import kotlinx.coroutines.sync.Mutex
21−import kotlinx.coroutines.sync.withLock
22−import kotlinx.coroutines.flow.Flow
23−import kotlinx.coroutines.flow.flowOn
24−import kotlinx.coroutines.flow.map
25−import kotlinx.coroutines.withContext
26−import kotlinx.serialization.Serializable
27−import kotlinx.serialization.builtins.ListSerializer
28−
29−private val Context.dataStore by preferencesDataStore(name = "server_profiles")
30−
31−/**
32− * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
33− * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
34− * by profile id, and merged back in on read.
35− */
36−open class ProfileStore private constructor(
37− private val appContext: Context?,
38− @Suppress("unused") private val testMode: Boolean,
39−) {
40− constructor(context: Context) : this(context.applicationContext, false)
41− protected constructor() : this(null, true)
42−
43− private val profilesKey = stringPreferencesKey("profiles_json")
44−
45− /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
46− * DataStore emission. The mutex serializes concurrent callers so a second collector waits
47− * for the migration's writes to land (rather than skipping ahead and reading empty secure
48− * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
49− private val migrationMutex = Mutex()
50− @Volatile private var migrationDone = false
51−
52− private val securePrefs: SharedPreferences? by lazy {
53− val ctx = appContext ?: return@lazy null
54− runCatching {
55− val masterKey = MasterKey.Builder(ctx)
56− .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
57− .build()
58− EncryptedSharedPreferences.create(
59− ctx,
60− "server_secrets",
61− masterKey,
62− EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
63− EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
64− )
65− }.getOrElse {
66− Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
67− null
68− }
69− }
70−
71− /** True when EncryptedSharedPreferences could not be initialized. */
72− open val securePrefsUnavailable: Boolean get() = securePrefs == null
73−
74− /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
75− * can still connect — but passwords won't survive an app reinstall. */
76− private val fallbackPrefs: SharedPreferences by lazy {
77− appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
78− }
79−
80− private fun prefsForPasswords(): SharedPreferences =
81− securePrefs ?: fallbackPrefs
82−
83− /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
84− * then clear the fallback. Also reaps orphaned password keys from BOTH stores whose
85− * `pw_<id>` matches no profile in [knownProfileIds] — a prior [delete] that raced a
86− * process death (DataStore write landed, password removal didn't) would otherwise leave
87− * the encrypted password stranded in `server_secrets` forever, since nothing else cleans
88− * it up. Runs at most once per process — subsequent calls are no-ops — so DataStore
89− * re-emissions don't repeatedly scan SharedPreferences. */
90− private suspend fun migrateFallbackPasswords(knownProfileIds: Set<String>) {
91− if (migrationDone) return
92− migrationMutex.withLock {
93− // Re-check inside the lock: a concurrent caller may have completed the migration
94− // while we were waiting. Serializing here (rather than a bare compareAndSet that
95− // marks done before doing the work) ensures a second collector doesn't read the
96− // secure store before the first caller's write has populated it.
97− if (migrationDone) return
98− val secure = securePrefs
99− withContext(Dispatchers.IO) {
100− val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
101− if (secure != null && fallbackKeys.isNotEmpty()) {
102− // Write passwords to the secure store FIRST, then remove them from the
103− // plaintext fallback. SharedPreferences.apply() persists to disk
104− // asynchronously, so removing from fallback before the secure batch lands
105− // could lose the password if the process dies between the two writes.
106− // Writing secure first makes the worst case a harmless duplicate (both
107− // stores hold it; the migration is idempotent) rather than data loss.
108− secure.edit().apply {
109− for (key in fallbackKeys) {
110− val pw = fallbackPrefs.getString(key, null) ?: continue
111− putString(key, pw)
112− }
113− }.apply()
114− fallbackPrefs.edit().apply {
115− for (key in fallbackKeys) remove(key)
116− }.apply()
117− }
118− // Reap orphaned password keys: a prior delete whose password removal raced a
119− // process death leaves the encrypted entry stranded forever (nothing else
120− // cleans it up, and the id may never be reused). Keyed by pw_<id>, so an orphan
121− // is any key whose id isn't in the current profile set. Reap from both stores
122− // since the fallback is written alongside secure in save().
123− reapOrphanPasswords(secure, fallbackPrefs, knownProfileIds)
124− }
125− // Only mark done once the writes are in the secure store's in-memory cache (apply()
126− // updates it synchronously), so the next collector past the mutex reads real values.
127− migrationDone = true
128− }
129− }
130−
131− /** Remove `pw_<id>` keys from [secure] and [fallback] whose id matches no profile in
132− * [knownProfileIds]. A prior [delete] whose password removal raced a process death leaves
133− * the encrypted password stranded forever; this sweep (called once per process from
134− * [migrateFallbackPasswords]) reaps them so the secure/fallback stores don't accumulate
135− * orphaned credentials for deleted profiles. No-op when a store is null or has no orphans. */
136− private fun reapOrphanPasswords(
137− secure: SharedPreferences?,
138− fallback: SharedPreferences,
139− knownProfileIds: Set<String>,
140− ) {
141− val secureOrphans = secure?.all?.keys
142− ?.filter { it.startsWith("pw_") && it.removePrefix("pw_") !in knownProfileIds }
143− ?: emptyList()
144− if (secureOrphans.isNotEmpty()) {
145− secure!!.edit().apply { secureOrphans.forEach { remove(it) } }.apply()
146− }
147− val fallbackOrphans = fallback.all.keys
148− .filter { it.startsWith("pw_") && it.removePrefix("pw_") !in knownProfileIds }
149− if (fallbackOrphans.isNotEmpty()) {
150− fallback.edit().apply { fallbackOrphans.forEach { remove(it) } }.apply()
151− }
152− }
153−
154− /** Stored shape on DataStore (everything except the secret password). */
155− @Serializable
156− private data class StoredProfile(
157− val id: String,
158− val label: String,
159− val baseUrl: String,
160− val username: String? = null,
161− val lastUsed: Long = 0,
162− // Non-secret TLS options; default to prior behavior for profiles saved before these
163− // fields existed (deserialization uses these defaults for missing keys).
164− val requireHttps: Boolean = false,
165− val certPin: String? = null,
166− )
167−
168− open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
169− // Decode the stored profiles first so the migration sweep can reap orphaned password
170− // keys (whose pw_<id> matches no stored profile) in the same once-per-process pass.
171− val json = prefs[profilesKey]
172− val stored = json?.let {
173− runCatching {
174− OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it)
175− }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
176− .getOrDefault(emptyList())
177− } ?: emptyList()
178− // Migrate any orphaned plaintext passwords to secure prefs, and reap any secure-pref
179− // password keys whose profile was deleted while the process was gone (see
180− // [migrateFallbackPasswords] for the sweep rationale).
181− migrateFallbackPasswords(stored.mapTo(mutableSetOf()) { it.id })
182− val sorted = stored.sortedByDescending { it.lastUsed }
183− // Batch all password lookups into a single IO dispatch instead of one
184− // withContext round-trip per profile (N dispatches → 1). DataStore already
185− // runs this map block on IO, but each passwordFor() call still pays the
186− // suspend + redispatch overhead.
187− val pwPrefs = prefsForPasswords()
188− sorted.map { it.toProfile(pwPrefs) }
189− }
190− // The .map above runs in the *collector's* context, and every consumer collects on
191− // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
192− // AES decryption would run on the UI thread on each emission. flowOn moves that work to IO.
193− ?.flowOn(Dispatchers.IO)
194− ?: kotlinx.coroutines.flow.flowOf(emptyList())
195−
196− private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
197− val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
198− return ServerProfile(
199− id = id,
200− label = label,
201− baseUrl = baseUrl,
202− username = username,
203− password = pw,
204− lastUsed = lastUsed,
205− requireHttps = requireHttps,
206− certPin = certPin,
207− )
208− }
209−
210− open suspend fun resolve(profile: ServerProfile): ServerProfile {
211− val pw = if (profile.hasAuth) {
212− withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
213− } else {
214− null
215− }
216− return profile.copy(password = pw)
217− }
218−
219− open suspend fun save(profile: ServerProfile) {
220− // Write the password first so that if the DataStore write fails, we're left
221− // with an orphaned password (harmless, cleaned up on next save/delete) rather
222− // than a profile with no password (which would break authentication).
223− withContext(Dispatchers.IO) {
224− prefsForPasswords().edit().apply {
225− val pw = profile.password
226− if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
227− putString(passwordKey(profile.id), pw)
228− } else {
229− remove(passwordKey(profile.id))
230− }
231− }.apply()
232− }
233− appContext!!.dataStore.edit { prefs ->
234− val current = prefs[profilesKey]?.let {
235− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
236− .getOrDefault(emptyList())
237− } ?: emptyList()
238− // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
239− // couldn't read the existing value due to a DataStore timeout), preserve
240− // the existing nonzero lastUsed so saving an edit doesn't reset the
241− // profile's sort position in the server list.
242− val preservedLastUsed = if (profile.lastUsed == 0L) {
243− current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
244− } else {
245− profile.lastUsed
246− }
247− val stored = StoredProfile(
248− id = profile.id,
249− label = profile.label,
250− baseUrl = profile.baseUrl,
251− username = profile.username?.takeIf { it.isNotBlank() },
252− lastUsed = preservedLastUsed,
253− requireHttps = profile.requireHttps,
254− certPin = profile.certPin?.takeIf { it.isNotBlank() },
255− )
256− val updated = current.filterNot { it.id == profile.id } + stored
257− prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
258− }
259− }
260−
261− open suspend fun delete(id: String) {
262− // Delete the profile from DataStore first, then the password. If the process
263− // dies between the two, an orphaned password remains (harmless, cleaned up on
264− // next save/delete) rather than a profile with no password.
265− appContext!!.dataStore.edit { prefs ->
266− val current = prefs[profilesKey]?.let {
267− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
268− .getOrDefault(emptyList())
269− } ?: emptyList()
270− prefs[profilesKey] = OpencodeJson.encodeToString(
271− ListSerializer(StoredProfile.serializer()),
272− current.filterNot { it.id == id },
273− )
274− }
275− // Clean up the password in both secure and fallback prefs so no orphaned
276− // plaintext password lingers if secure prefs were temporarily unavailable.
277− withContext(Dispatchers.IO) {
278− val key = passwordKey(id)
279− securePrefs?.edit()?.remove(key)?.apply()
280− fallbackPrefs.edit().remove(key).apply()
281− }
282− }
283−
284− private fun passwordKey(id: String) = "pw_$id"
285−}
app/src/main/java/soy/iko/opencode/data/repo/RecentModelsStore.ktdeleted−80
@@ -1,80 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.flow.MutableStateFlow
5−import kotlinx.coroutines.flow.StateFlow
6−import kotlinx.coroutines.flow.asStateFlow
7−
8−/**
9− * Persists the most recently picked models so the model picker can surface them as a
10− * "Recent" section above the full catalog. Backed by SharedPreferences because the UI
11− * needs the initial value synchronously and the list is tiny (≤ [MAX_ENTRIES]), mirroring
12− * the low-stakes-persistence rationale of [SearchHistoryStore]. `open` with a `protected`
13− * no-arg constructor so tests can subclass and override without a real Context.
14− *
15− * Entries are stored as `providerID\modelID` composite keys (the separator is chosen so it
16− * can't collide with provider or model ids, which are URL/path-safe identifiers).
17− */
18−open class RecentModelsStore private constructor(
19− private val appContext: Context?,
20− @Suppress("unused") private val testMode: Boolean,
21−) {
22− constructor(context: Context) : this(context.applicationContext, false)
23− protected constructor() : this(null, true)
24−
25− private val prefs by lazy {
26− appContext?.getSharedPreferences("recent_models", Context.MODE_PRIVATE)
27− ?: error("No context — override methods in test subclass")
28− }
29−
30− private val _entries = MutableStateFlow<List<String>>(emptyList())
31− open val entries: StateFlow<List<String>> = _entries.asStateFlow()
32−
33− init {
34− if (appContext != null) {
35− _entries.value = runCatching {
36− prefs.getString(KEY, null)
37− ?.takeIf { it.isNotBlank() }
38− ?.split(SEPARATOR)
39− ?.filter { it.isNotBlank() }
40− ?.take(MAX_ENTRIES)
41− }.getOrDefault(emptyList()) ?: emptyList()
42− }
43− }
44−
45− /** Insert [providerId]/[modelId] at the head of the recents, deduped and capped at
46− * [MAX_ENTRIES]. Blank ids are ignored. */
47− open fun add(providerId: String, modelId: String) {
48− val p = providerId.trim()
49− val m = modelId.trim()
50− if (p.isBlank() || m.isBlank()) return
51− val key = "$p$SEPARATOR$m"
52− val updated = (listOf(key) + _entries.value.filter { it != key }).take(MAX_ENTRIES)
53− _entries.value = updated
54− runCatching {
55− prefs.edit().putString(KEY, updated.joinToString(SEPARATOR)).apply()
56− }
57− }
58−
59− /** Split a stored composite key back into its provider/model id pair. Returns null if
60− * the entry is malformed (shouldn't happen, but a corrupt prefs value must never crash
61− * the picker). */
62− fun split(entry: String): Pair<String, String>? {
63− val parts = entry.split(SEPARATOR)
64− return if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank())
65− parts[0] to parts[1] else null
66− }
67−
68− open fun clear() {
69− _entries.value = emptyList()
70− runCatching { prefs.edit().remove(KEY).apply() }
71− }
72−
73− private companion object {
74− const val KEY = "models"
75− // A newline can't appear in a provider/model id (they're URL/path-safe), so it's a
76− // safe list separator that won't collide with id contents.
77− const val SEPARATOR = "\n"
78− const val MAX_ENTRIES = 5
79− }
80−}
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.ktdeleted−100
@@ -1,100 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.Dispatchers
5−import kotlinx.coroutines.sync.Mutex
6−import kotlinx.coroutines.sync.withLock
7−import kotlinx.coroutines.withContext
8−import kotlinx.serialization.Serializable
9−import kotlinx.serialization.decodeFromString
10−import kotlinx.serialization.encodeToString
11−import kotlinx.serialization.json.Json
12−import soy.iko.opencode.util.runCatchingCancellable
13−import java.io.File
14−
15−/** A minimal session summary persisted for the home-screen widget and launcher shortcuts.
16− * Carries the originating [profileId] so a widget tap routes back to the server that ran
17− * the session even after the user has switched to a different connection — without it,
18− * the session would open on whichever server is currently active and 404 or show empty. */
19−@Serializable
20−data class RecentSession(
21− val id: String,
22− val title: String,
23− // Defaulted so a pre-profileId persisted file (from an older app version) still decodes:
24− // the field is absent → default "" → the widget falls back to the active connection.
25− val profileId: String = "",
26−)
27−
28−/**
29− * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
30− * surfaces (the home-screen widget's [android.widget.RemoteViewsService] and the "Resume last"
31− * launcher shortcut) can read them without a live connection or in-memory state. Both the app
32− * and the widget's RemoteViews factory run in the same process, so a plain file is sufficient.
33− */
34−object RecentSessionsStore {
35−
36− private const val FILE = "recent_sessions.json"
37− const val MAX = 8
38−
39− private val json = Json { ignoreUnknownKeys = true }
40−
41− /** Serializes concurrent [write]s. The synchronous [read] (called from the widget's
42− * RemoteViewsService) can't take a suspend lock, so [write] instead swaps the file in
43− * atomically via a temp-file rename — the reader always sees either the old or the new
44− * complete file, never a half-written one. */
45− private val writeMutex = Mutex()
46−
47− private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
48−
49− /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
50− fun read(context: Context): List<RecentSession> {
51− val f = file(context).takeIf { it.exists() } ?: return emptyList()
52− return runCatching { json.decodeFromString<List<RecentSession>>(f.readText()) }
53− .getOrDefault(emptyList())
54− }
55−
56− /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
57− suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
58− writeMutex.withLock {
59− // runCatchingCancellable (not plain runCatching): this runs inside a coroutine, and
60− // plain runCatching swallows CancellationException — if the caller is cancelled
61− // mid-write, cancellation wouldn't propagate and the write could complete (or
62− // partially complete) on a dying scope. Matches the AGENTS.md convention.
63− runCatchingCancellable {
64− val target = file(context)
65− val encoded = json.encodeToString(sessions.take(MAX))
66− val tmp = File(target.parentFile, "$FILE.tmp")
67− tmp.writeText(encoded)
68− // Atomic replace on POSIX filesystems (app's internal storage); the reader
69− // never observes a torn file. renameTo can fail when the target exists on
70− // some OEM filesystems — delete the target first so the rename is a pure
71− // create. If that still fails, copy to a SECOND temp file and atomically
72− // rename THAT to target — writing tmp.copyTo(target) directly would expose a
73− // half-written target to a concurrent reader (read takes no mutex), breaking
74− // the documented invariant. The second-rename leaves target either old or new,
75− // never torn.
76− if (!tmp.renameTo(target)) {
77− target.delete()
78− if (!tmp.renameTo(target)) {
79− val tmp2 = File(target.parentFile, "$FILE.tmp2")
80− tmp2.writeText(encoded)
81− if (!tmp2.renameTo(target)) {
82− // Truly last resort: some OEM filesystems reject even a create-rename.
83− // Write directly to target, accepting a brief non-atomic window (a
84− // concurrent reader could see a partially-written file). The previous
85− // delete-then-rename here was worse: between the delete and the rename
86− // a reader saw NO file (empty recents), and if the rename failed too,
87− // target was left missing and tmp2 orphaned. writeText at least keeps
88− // either the old or the new content visible, never a gap. Clean up
89− // both temps afterwards.
90− target.writeText(encoded)
91− }
92− tmp.delete()
93− tmp2.delete()
94− }
95− }
96− }
97− Unit
98− }
99− }
100−}
app/src/main/java/soy/iko/opencode/data/repo/SearchHistoryStore.ktdeleted−68
@@ -1,68 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.flow.MutableStateFlow
5−import kotlinx.coroutines.flow.StateFlow
6−import kotlinx.coroutines.flow.asStateFlow
7−import soy.iko.opencode.data.network.NetworkConfig
8−
9−/**
10− * Persists the most recent unique global-search queries so the search screen can
11− * surface them as suggestions. Backed by SharedPreferences because the UI needs the
12− * initial value synchronously and the list is tiny (≤ [MAX_ENTRIES]), mirroring the
13− * low-stakes-persistence rationale of [DraftStore]. `open` with a `protected`
14− * no-arg constructor so tests can subclass and override without a real Context.
15− */
16−open class SearchHistoryStore private constructor(
17− private val appContext: Context?,
18− @Suppress("unused") private val testMode: Boolean,
19−) {
20− constructor(context: Context) : this(context.applicationContext, false)
21− protected constructor() : this(null, true)
22−
23− private val prefs by lazy {
24− appContext?.getSharedPreferences("search_history", Context.MODE_PRIVATE)
25− ?: error("No context — override methods in test subclass")
26− }
27−
28− private val _queries = MutableStateFlow<List<String>>(emptyList())
29− open val queries: StateFlow<List<String>> = _queries.asStateFlow()
30−
31− init {
32− if (appContext != null) {
33− _queries.value = runCatching {
34− prefs.getString(KEY, null)
35− ?.takeIf { it.isNotBlank() }
36− ?.split(SEPARATOR)
37− ?.filter { it.isNotBlank() }
38− ?.take(MAX_ENTRIES)
39− }.getOrDefault(emptyList()) ?: emptyList()
40− }
41− }
42−
43− /** Insert [query] at the head of the history, deduped and capped at [MAX_ENTRIES].
44− * Blank or sub-min-length queries are ignored so accidental empties and stray
45− * keystrokes don't pollute the suggestions. */
46− open fun add(query: String) {
47− val trimmed = query.trim()
48− if (trimmed.isBlank() || trimmed.length < NetworkConfig.minSearchQueryLength) return
49− val updated = (listOf(trimmed) + _queries.value.filter { it != trimmed }).take(MAX_ENTRIES)
50− _queries.value = updated
51− runCatching {
52− prefs.edit().putString(KEY, updated.joinToString(SEPARATOR)).apply()
53− }
54− }
55−
56− open fun clear() {
57− _queries.value = emptyList()
58− runCatching { prefs.edit().remove(KEY).apply() }
59− }
60−
61− private companion object {
62− const val KEY = "queries"
63− // The search field is single-line, so a newline can't appear in a stored query
64− // and is safe to use as the list separator.
65− const val SEPARATOR = "\n"
66− const val MAX_ENTRIES = 10
67− }
68−}
app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.ktdeleted−73
@@ -1,73 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.edit
5−import androidx.datastore.preferences.core.stringSetPreferencesKey
6−import androidx.datastore.preferences.preferencesDataStore
7−import kotlinx.coroutines.flow.Flow
8−import kotlinx.coroutines.flow.flowOf
9−import kotlinx.coroutines.flow.map
10−
11−private val Context.sessionPrefsDataStore by preferencesDataStore(name = "session_prefs")
12−
13−/**
14− * Local, per-session organization flags the opencode server doesn't track: **pinned**
15− * (sorted to the top of the session list) and **archived** (hidden from the list unless the
16− * user opts to show them). Keyed by opencode session id.
17− *
18− * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
19− * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
20− * eager initializers) and null-guard the context so the test constructor never touches disk.
21− */
22−open class SessionPrefsStore private constructor(
23− private val appContext: Context?,
24− @Suppress("unused") private val testMode: Boolean,
25−) {
26− constructor(context: Context) : this(context.applicationContext, false)
27− protected constructor() : this(null, true)
28−
29− private val pinnedKey = stringSetPreferencesKey("pinned")
30− private val archivedKey = stringSetPreferencesKey("archived")
31− private val mutedKey = stringSetPreferencesKey("muted")
32−
33− open val pinned: Flow<Set<String>>
34− get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
35− ?: flowOf(emptySet())
36−
37− open val archived: Flow<Set<String>>
38− get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
39− ?: flowOf(emptySet())
40−
41− /** Session ids the user has muted (suppress unread badge + completion notification). */
42− open val muted: Flow<Set<String>>
43− get() = appContext?.sessionPrefsDataStore?.data?.map { it[mutedKey] ?: emptySet() }
44− ?: flowOf(emptySet())
45−
46− open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
47−
48− open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
49−
50− open suspend fun setMuted(sessionId: String, muted: Boolean) = toggle(mutedKey, sessionId, muted)
51−
52− /** Drop a session from all sets — call on deletion so ids don't accumulate forever. */
53− open suspend fun forget(sessionId: String) {
54− val ctx = appContext ?: return
55− ctx.sessionPrefsDataStore.edit { prefs ->
56− prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
57− prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
58− prefs[mutedKey] = (prefs[mutedKey] ?: emptySet()) - sessionId
59− }
60− }
61−
62− private suspend fun toggle(
63− key: androidx.datastore.preferences.core.Preferences.Key<Set<String>>,
64− sessionId: String,
65− on: Boolean,
66− ) {
67− val ctx = appContext ?: return
68− ctx.sessionPrefsDataStore.edit { prefs ->
69− val current = prefs[key] ?: emptySet()
70− prefs[key] = if (on) current + sessionId else current - sessionId
71− }
72− }
73−}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktdeleted−835
@@ -1,835 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.BusEvent
5−import soy.iko.opencode.data.model.FilePromptPart
6−import soy.iko.opencode.data.model.MessageInfo
7−import soy.iko.opencode.data.model.MessagePartRemoved
8−import soy.iko.opencode.data.model.MessagePartUpdated
9−import soy.iko.opencode.data.model.MessageRemoved
10−import soy.iko.opencode.data.model.MessageUpdated
11−import soy.iko.opencode.data.model.MessageWithParts
12−import soy.iko.opencode.data.model.ModelRef
13−import soy.iko.opencode.data.model.Part
14−import soy.iko.opencode.data.model.SessionError
15−import soy.iko.opencode.data.model.SessionIdle
16−import soy.iko.opencode.data.model.StepFinishPart
17−import soy.iko.opencode.data.model.UnknownMessage
18−import soy.iko.opencode.data.network.EventStreamClient
19−import soy.iko.opencode.data.network.NetworkConfig
20−import soy.iko.opencode.data.network.OpencodeApiClient
21−import soy.iko.opencode.util.runCatchingCancellable
22−import soy.iko.opencode.util.safeExceptionSummary
23−import android.util.Log
24−import kotlinx.coroutines.CoroutineScope
25−import kotlinx.coroutines.Dispatchers
26−import kotlinx.coroutines.Job
27−import kotlinx.coroutines.NonCancellable
28−import kotlinx.coroutines.delay
29−import kotlinx.coroutines.withContext
30−import kotlinx.coroutines.channels.Channel
31−import kotlinx.coroutines.flow.Flow
32−import kotlinx.coroutines.flow.channelFlow
33−import kotlinx.coroutines.flow.consumeAsFlow
34−import kotlinx.coroutines.flow.conflate
35−import kotlinx.coroutines.flow.flowOn
36−import kotlinx.coroutines.launch
37−import kotlinx.coroutines.sync.Mutex
38−import kotlinx.coroutines.sync.withLock
39−
40−/**
41− * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
42− * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
43− * each keyed by message id with parts keyed by part id (idempotent upserts).
44− */
45−open class SessionRepository(
46− private val api: OpencodeApiClient,
47− private val eventStream: EventStreamClient,
48− // Optional on-disk cache so a conversation renders instantly on open and stays readable
49− // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
50− private val messageCache: MessageCacheStore? = null,
51− // Profile the cache is namespaced under, so two servers with a colliding session id don't
52− // share a cache file. Empty in tests / when no cache is wired.
53− private val profileId: String = "",
54−) {
55− open suspend fun listSessions() = api.listSessions()
56− open suspend fun createSession(title: String? = null, directory: String? = null) =
57− api.createSession(title, directory)
58− open suspend fun deleteSession(id: String) = api.deleteSession(id)
59− open suspend fun abort(sessionId: String) = api.abort(sessionId)
60−
61− open suspend fun sendPrompt(
62− sessionId: String,
63− text: String,
64− attachments: List<FilePromptPart> = emptyList(),
65− model: ModelRef?,
66− agent: String? = null,
67− // Stable key for retriable/re-sendable messages (the offline outbox). See
68− // [OpencodeApiClient.sendPrompt]. Null generates a fresh key per call.
69− idempotencyKey: String? = null,
70− ) = api.sendPrompt(sessionId, text, attachments, model, agent, idempotencyKey)
71−
72− open suspend fun runCommand(
73− sessionId: String,
74− command: String,
75− arguments: String = "",
76− agent: String? = null,
77− ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
78−
79− /**
80− * A live, ordered view of [sessionId]'s messages. Begins collecting the event
81− * stream before the initial REST load so no streamed part is missed, then reduces
82− * events into the in-memory state. The flow is conflated so a fast burst of
83− * streaming tokens (each publishing a fresh snapshot) never back-pressures the
84− * event reducer — a slow collector simply sees the most recent snapshot.
85− */
86− /** Per-subscription helper that persists reduced snapshots to [messageCache], throttled so a
87− * fast token stream doesn't hammer the disk, plus a NonCancellable teardown flush. Extracted
88− * from [observeMessages] so this cache-write branching lives in its own methods. [scope] is
89− * the channelFlow scope, so scheduled saves are torn down with the flow — the teardown flush
90− * re-persists a trailing write the cancellation dropped. */
91− private inner class CacheWriter(private val scope: CoroutineScope, private val sessionId: String) {
92− private var lastWrite = 0L
93− private var pending: Job? = null
94− // Most recent snapshot handed to [schedule], and the most recent one whose on-disk write
95− // completed. `saved` is assigned from inside the launched save *after* it returns, so a
96− // save cancelled mid-IO by teardown is NOT marked saved and [flushOnTeardown] re-persists
97− // it. Written from the save coroutines, read from the collector/teardown; the only race is
98− // a stale read causing one redundant, idempotent re-save (atomic temp+rename makes a
99− // duplicate write of identical data harmless) — never data loss.
100− private var latest: List<MessageWithParts>? = null
101− private var saved: List<MessageWithParts>? = null
102−
103− fun schedule(snapshot: List<MessageWithParts>) {
104− val cache = messageCache ?: return
105− // Don't early-return on an empty snapshot: MessageCacheStore.save deletes the file
106− // for an empty list, which is exactly what we want when a conversation becomes empty
107− // via MessageRemoved events. Skipping here would leave stale messages cached on disk
108− // until an explicit remove() on session deletion.
109− latest = snapshot
110− val now = System.currentTimeMillis()
111− pending?.cancel()
112− // Immediate write once the throttle window has elapsed; otherwise a trailing-edge
113− // write so a burst that ends inside the window (a run's final tokens right after a
114− // write) still persists its last snapshot. A newer snapshot cancels and replaces a
115− // pending trailing write, so continuous streaming coalesces to one write per window.
116− val delayMs = if (now - lastWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
117− lastWrite = now
118− 0L
119− } else {
120− NetworkConfig.messageCacheWriteThrottleMs
121− }
122− pending = scope.launch {
123− if (delayMs > 0) delay(delayMs)
124− cache.save(profileId, sessionId, snapshot)
125− saved = snapshot
126− }
127− }
128−
129− suspend fun flushOnTeardown() {
130− // Scheduled saves are children of [scope], so teardown cancels a pending trailing
131− // write before its delay elapses — a run's final tokens would never reach disk. Flush
132− // the latest un-saved snapshot under NonCancellable so it survives the cancellation.
133− val cache = messageCache ?: return
134− val pendingSnapshot = latest ?: return
135− if (pendingSnapshot !== saved) {
136− withContext(NonCancellable) { cache.save(profileId, sessionId, pendingSnapshot) }
137− }
138− }
139− }
140−
141− open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
142− val store = MessageStore()
143− val lock = Mutex()
144− // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
145− // single snapshot+send per demand cycle. Previously every changed event called
146− // snapshot() — allocating an O(N) copy of the whole message list — and ~97% of
147− // those copies were immediately discarded by the outer .conflate() during fast
148− // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
149− // drain loop means N events in a burst allocate ~1 snapshot, not N.
150− val dirty = Channel<Unit>(Channel.CONFLATED)
151−
152− // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
153− // collapses repeated marks into a single pending signal, so a tight burst of
154− // events doesn't even allocate N marks.
155− fun publish() { dirty.trySend(Unit) }
156−
157− // Subscribe to events first so we don't miss early deltas during the initial load.
158− launch {
159− eventStream.events.collect { event ->
160− if (lock.withLock { store.reduce(sessionId, event) }) publish()
161− }
162− }
163−
164− // Seed generation counter, shared between the initial load and the reconnect
165− // re-seed. Guards against a stale fetch (from an earlier reconnect, or from a slow
166− // initial load) clobbering a newer seed. Only mutated/read under [lock].
167− var seedGeneration = 0
168−
169− // Message ids introduced by the on-disk cache seed below. The initial REST seed uses
170− // these to prune ghosts: a cache-seeded message absent from the authoritative REST
171− // snapshot was deleted server-side while the app was closed. Restricting the prune to
172− // cache-seeded ids means live SSE messages added since subscribe (not yet indexed by
173− // REST) are never dropped. Written/read only in this collector coroutine.
174− var cacheSeededIds: Set<String> = emptySet()
175−
176− // Seed from the on-disk cache first so the conversation renders instantly — and stays
177− // readable offline — before the network responds. Guarded on generation 0 so a
178− // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
179− // (also generation 0) then overrides this with authoritative data when it succeeds.
180− messageCache?.let { cache ->
181− val cached = cache.load(profileId, sessionId)
182− if (cached.isNotEmpty()) {
183− val changed = lock.withLock {
184− if (seedGeneration == 0) {
185− // Store the synthetic key for UnknownMessages (which have empty ids) so
186− // pruneStaleCacheSeeded's messages.remove(id) hits the actual map key
187− // the store used ("unknown-c<created>" / "unknown-h<hash>") instead of
188− // the empty id that never matches and leaves ghost entries un-pruned.
189− cacheSeededIds = cached.mapTo(mutableSetOf()) { store.syntheticKeyFor(it) }
190− store.seed(cached, clearPivot = false)
191− } else false
192− }
193− if (changed) publish()
194− }
195− }
196−
197− // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
198− // is performed *outside* the lock so events arriving via SSE during the fetch are
199− // not blocked. The seed itself is under the lock; a seed generation counter ensures
200− // a stale fetch (from an earlier reconnect) doesn't clobber a newer one.
201− launch {
202− // Re-seed from REST when the SSE stream reconnects after a drop. The
203− // hasConnectedBefore flag is NOT reset on disconnect — it stays true after
204− // the first successful connection so a subsequent reconnect triggers the
205− // re-seed. Resetting it on disconnect would make the condition always false
206− // after the first cycle, turning the re-seed into dead code.
207− //
208− // A seed generation counter guards against a stale re-seed clobbering a
209− // newer one: if two reconnects fire in quick succession, the older fetch
210− // (which returns later) is discarded because its generation no longer matches.
211− var hasConnectedBefore = false
212− eventStream.state.collect { state ->
213− if (state == EventStreamClient.ConnectionState.Connected) {
214− if (hasConnectedBefore) {
215− // Pivot before the fetch: parts already in memory predate this
216− // snapshot and must yield to it, while parts that stream in during
217− // the fetch (added to streamedSincePivot via reduce) outrank it.
218− // Bump the generation under the same lock so the compare in the
219− // initial seed sees a consistent value.
220− val generation = lock.withLock {
221− store.beginReseed()
222− ++seedGeneration
223− }
224− val freshResult = runCatchingCancellable { api.listMessages(sessionId) }
225− .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
226− val fresh = freshResult.getOrNull()
227− var seedChanged = false
228− lock.withLock {
229− if (generation == seedGeneration) {
230− // Only prune when the fetch SUCCEEDED. A failed fetch yields
231− // fresh == null; treating null as an empty REST snapshot would
232− // make pruneStaleCacheSeeded remove EVERY cache-seeded message
233− // (none are in the empty retain-set), wiping the whole cached
234− // conversation on a transient network error — the cache is the
235− // only thing keeping the session readable until SSE repopulates.
236− var pruned = false
237− if (fresh != null) {
238− // Prune cache-seeded ghosts here too, not only in the initial
239− // REST seed. If the SSE stream connected→dropped→reconnected
240− // before the initial listMessages returned, seedGeneration was
241− // already >0 by the time the initial seed ran, so its prune was
242− // skipped (the `seedGeneration == 0` guard below). Without this
243− // prune, messages deleted server-side while the app was closed
244− // would survive as ghosts until a future app restart where the
245− // timing happened not to recur.
246− pruned = store.pruneStaleCacheSeeded(cacheSeededIds, fresh)
247− }
248− // Merge without pruning: SSE events arriving during the
249− // REST fetch may have added messages not yet indexed by
250− // the REST endpoint. Pruning would silently delete them.
251− // Stale messages from deletions during the disconnect
252− // gap are eventually removed via MessageRemoved events
253− // or on the next app restart.
254− val seeded = if (fresh != null) store.seed(fresh, prune = false) else false
255− seedChanged = pruned || seeded
256− }
257− }
258− if (seedChanged) publish()
259− }
260− hasConnectedBefore = true
261− }
262− }
263− }
264−
265− // Load the authoritative message list concurrently with the drain loop below so the
266− // cache seed and any early SSE-reduced parts are emitted immediately. Running it inline
267− // here (before the drain starts) would withhold the already-reduced cached conversation
268− // until this fetch resolved — a blank screen for the whole withRetry backoff when offline.
269− launch {
270− val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
271− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
272− // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
273− // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
274− // returns after one has applied, seeding this older REST data would clobber the newer
275− // state — reverting a completed run to "running", regressing cost/token/part state —
276− // until the next SSE event or reconnect healed it. Only seed on SUCCESS: on failure
277− // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
278− val seeded = lock.withLock {
279− val list = initialResult.getOrNull()
280− if (seedGeneration == 0 && list != null) {
281− // Prune ghosts first: cache-seeded messages absent from this authoritative
282− // REST snapshot were deleted server-side while the app was closed. Without
283− // this they'd survive restart forever (re-seeded from cache, never pruned by
284− // the reconnect path, and re-persisted by the drain loop).
285− val pruned = store.pruneStaleCacheSeeded(cacheSeededIds, list)
286− val changed = store.seed(list)
287− pruned || changed
288− } else false
289− }
290− if (seeded) publish()
291− }
292−
293− // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
294− // taken under the lock so a reader never sees a half-reduced state; send() runs
295− // outside the lock so a slow downstream collector can't stall the drain. This
296− // loop also keeps the flow alive until the collector cancels; the launched jobs
297− // above are children of this scope and are torn down with it.
298− // Per-subscription cache writer: throttled snapshot persistence + a NonCancellable
299− // teardown flush. `this` is the channelFlow scope, so its scheduled saves are torn down
300− // with the flow (the teardown flush re-persists a cancelled trailing write).
301− val cacheWriter = CacheWriter(this, sessionId)
302− // Guarantee at least one emission even when nothing seeds the store: with no on-disk
303− // cache, a failing initial REST load (swallowed above), and a quiet SSE stream, none of
304− // the seed paths publish, so the flow would emit nothing at all. The sole collector
305− // (ChatViewModel) only clears its loading spinner on an emission, so without this a
306− // first-open of an erroring/empty session spins forever with no way to reach the
307− // "Failed to load / Retry" affordance. CONFLATED dirty coalesces this with any seed
308− // publish into a single send.
309− publish()
310− try {
311− dirty.consumeAsFlow().collect {
312− val snapshot = lock.withLock { store.snapshot() }
313− send(snapshot)
314− // Persist to the on-disk cache, throttled so a fast token stream doesn't hammer
315− // the disk — the cache only needs to be "recent enough" for an instant/offline
316− // first paint; the network corrects it on the next open.
317− cacheWriter.schedule(snapshot)
318− }
319− } finally {
320− cacheWriter.flushOnTeardown()
321− }
322− // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
323− // snapshot copy — on Dispatchers.Default rather than the collector's context. The
324− // sole consumer collects via stateIn(viewModelScope), i.e. Main.immediate, so without
325− // this every streamed token's reduce()/snapshot() would execute on the UI thread
326− // (hundreds/sec on a fast run). The seed REST calls here only suspend, so moving them
327− // off Main is harmless. JSON decoding already runs off-Main upstream in EventStreamClient.
328− }.flowOn(Dispatchers.Default).conflate()
329−
330− companion object {
331− /** Convenience: is this event a run-completion signal for [sessionId]?
332− * A null sessionID is NOT treated as a wildcard — an idle event with no
333− * session id must not reset the running state of every open chat. */
334− fun isIdle(event: BusEvent, sessionId: String): Boolean =
335− event is SessionIdle && event.properties.sessionID == sessionId
336−
337− fun isError(event: BusEvent, sessionId: String): Boolean =
338− event is SessionError && event.properties.sessionID == sessionId
339−
340− /** Does this event indicate an in-progress run for [sessionId]? A live streamed part or
341− * an assistant message that isn't yet complete/errored means the agent is still working.
342− * Used to restore the "running" indicator after an SSE reconnect: if the run is still
343− * going, such live events keep arriving; a run that finished during the outage produces
344− * none (its completion arrives via the REST re-seed, not the event stream). */
345− fun isRunActivity(event: BusEvent, sessionId: String): Boolean = when (event) {
346− // The session id rides on the part for message.part.updated; properties.sessionID
347− // is null on the wire. Mirror reduce()'s `part.sessionID ?: properties.sessionID`
348− // so a run that's only streaming parts still re-lights the indicator on reconnect.
349− // A step-finish part is the trailing completion marker for a run (it carries the
350− // final cost/token totals) and arrives after the last stream delta. Treating it as
351− // live activity would re-light the running indicator after a reconnect when the run
352− // already finished during the outage — with no following SessionIdle to clear it,
353− // pinning the spinner (and the foreground service) indefinitely. Mirrors
354− // AppContainer.isLiveRunActivity's exclusion.
355− is MessagePartUpdated -> {
356− val part = event.properties.part
357− part !is StepFinishPart &&
358− (part.sessionID ?: event.properties.sessionID) == sessionId
359− }
360− is MessageUpdated -> {
361− val info = event.properties.info ?: return false
362− info.sessionID == sessionId && info is AssistantMessage && !info.isComplete && info.error == null
363− }
364− else -> false
365− }
366− }
367−}
368−
369−/**
370− * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
371− * Exposed as `internal` so the reducer logic can be unit-tested directly.
372− */
373−internal class MessageStore {
374− // messageId -> mutable holder (info + insertion-ordered parts); the map itself is
375− // insertion-ordered.
376− private val messages = LinkedHashMap<String, Holder>()
377−
378− /**
379− * Mutable per-message holder. Parts live in an insertion-ordered map keyed by part id, so a
380− * streamed part update is an O(1) put instead of an O(P) list rebuild. The immutable
381− * [MessageWithParts] view is materialized lazily and cached until the next mutation — so a
382− * burst of streamed token updates rebuilds a message's part list at most once per published
383− * [snapshot], not once per token. The cached view is also reference-stable across snapshots
384− * while unchanged, so Compose skips recomposing bubbles that didn't change.
385− */
386− private class Holder(var info: MessageInfo, val parts: LinkedHashMap<String, Part>) {
387− private var cached: MessageWithParts? = null
388− fun view(): MessageWithParts =
389− cached ?: MessageWithParts(info, parts.values.toList()).also { cached = it }
390− /** Adopt an existing immutable instance as the cache — avoids an immediate rebuild and
391− * preserves its identity (e.g. when seeding an authoritative REST snapshot). */
392− fun adopt(m: MessageWithParts) { cached = m }
393− fun invalidate() { cached = null }
394− }
395−
396− /** Build a [Holder] from an immutable message, reusing [m] itself as the cached view. */
397− private fun holderOf(m: MessageWithParts): Holder {
398− val parts = LinkedHashMap<String, Part>(m.parts.size)
399− for (p in m.parts) parts[p.id] = p
400− return Holder(m.info, parts).also { it.adopt(m) }
401− }
402−
403− /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
404− * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
405− * so they're newer than the snapshot and must win over it; every other part is taken
406− * from the authoritative REST snapshot. Without this distinction the merge kept every
407− * overlapping in-memory part, so a part that changed server-side during the disconnect
408− * (e.g. a tool going running -> completed) stayed stale until app restart. */
409− private val streamedSincePivot = mutableSetOf<String>()
410−
411− /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
412− * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
413− * on a reconnect re-seed these are the messages whose info changed *during* the REST
414− * fetch, so the in-memory info is newer than the snapshot and must win; every other
415− * message's info is taken from the authoritative REST snapshot. Without this the merge
416− * kept the in-memory info, so a run that finished server-side during the disconnect
417− * (e.g. cost/token totals, completion time) stayed stale until app restart. */
418− private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
419−
420− /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
421− * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
422− fun beginReseed() {
423− streamedSincePivot.clear()
424− messageInfoUpdatedSincePivot.clear()
425− }
426−
427− /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
428− internal val maxMessages = NetworkConfig.maxInMemoryMessages
429−
430− fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
431−
432− fun seed(initial: List<MessageWithParts>, prune: Boolean = false, clearPivot: Boolean = true): Boolean {
433− var changed = false
434− // seed() does NOT prune by default. Pruning is intentionally NOT wired into the
435− // reconnect re-seed (the only path that would want it): the live SSE stream — via
436− // MessageRemoved events — drives deletions, and pruneStaleCacheSeeded() handles
437− // ghosts from the on-disk cache on restart. Pruning on re-seed would race with
438− // just-arrived SSE events whose messages may not yet be indexed by REST, dropping
439− // live messages. The `prune = true` branch below is retained as a tested capability
440− // for callers that can guarantee no concurrent SSE (e.g. a future explicit
441− // "discard and reload from REST" entry point); the current production re-seed
442− // callsite passes `prune = false` for the race reason above.
443− if (prune) {
444− // Build the retain-set from the synthetic key (matching the map's keying for
445− // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>"
446− // isn't wrongly evicted and re-inserted under "" — which would lose its
447− // insertion-order position and force a spurious snapshot emission.
448− val incomingKeys = initial.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
449− val before = messages.size
450− messages.keys.retainAll(incomingKeys)
451− if (messages.size != before) changed = true
452− }
453− // An authoritative seed supersedes any eviction record: a message re-introduced by
454− // this seed is back in memory, so a later MessagePartUpdated should update it, not
455− // be dropped as "late for an evicted message". Clearing keeps the set bounded too.
456− evictedMessageIds.clear()
457−
458− for (m in initial) {
459− val key = syntheticKeyFor(m)
460− val existing = messages[key]
461− if (existing == null) {
462− messages[key] = holderOf(m)
463− changed = true
464− } else {
465− // A part streamed in between subscribe and this initial load may already
466− // have populated this message (see observeMessages). Merge instead of
467− // overwriting so that newer streamed part isn't discarded: take the
468− // snapshot's part order, swap in the streamed version where ids overlap,
469− // append any streamed-only parts, and adopt the authoritative REST info.
470− val streamedById = existing.parts
471− val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
472− // Prefer the in-memory version of an overlapping part ONLY if it streamed
473− // in since the reseed pivot (i.e. during this fetch), so it's newer than
474− // the snapshot. Otherwise the REST snapshot is authoritative and wins,
475− // discarding an in-memory part that went stale during a disconnect.
476− val ordered = m.parts.map { p ->
477− if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
478− }.toMutableList()
479− // Append only parts that streamed in *since the pivot* and are absent from the
480− // snapshot — i.e. genuinely newer than this REST fetch. A part in memory from
481− // before the pivot that the snapshot omits was removed server-side; the
482− // authoritative REST snapshot must be allowed to drop it, else it reappears as
483− // a ghost and gets baked into the on-disk cache forever.
484− for (p in existing.parts.values) {
485− if (p.id !in snapshotIds && p.id in streamedSincePivot) ordered.add(p)
486− }
487− // Adopt the authoritative REST info unless the in-memory info was itself
488− // updated live since the pivot (i.e. during this fetch), in which case it's
489− // newer than the snapshot. Otherwise the REST snapshot wins. The pivot set is
490− // keyed by the same synthetic key used in the map (see handleMessageUpdated),
491− // so test that here instead of m.info.id — which is "" for an UnknownMessage
492− // and would never match, leaving the pivot protection inert for that case.
493− val info = if (existing.info is UnknownMessage || key !in messageInfoUpdatedSincePivot) m.info else existing.info
494− val merged = MessageWithParts(info = info, parts = ordered)
495− if (merged != existing.view()) {
496− // Store under `key` (the synthetic key for an empty-id UnknownMessage),
497− // not `m.info.id` (which is "" for such a message and would collide).
498− messages[key] = holderOf(merged)
499− changed = true
500− }
501− }
502− }
503− // A part that streamed in *before* this initial load (observeMessages subscribes to
504− // SSE before fetching) inserts its message at the front of the insertion-ordered map,
505− // and the merge above keeps that position — pinning a running message above the older
506− // history that seed() inserts afterward. Restore chronological order by creation time
507− // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
508− // removes from the map's front, which before reordering is the newest streamed message.
509− if (reorderByTime()) changed = true
510− if (evictOldMessages()) changed = true
511− // Reset the pivot: everything just merged is now the baseline, so only parts/info
512− // that stream in *after* this seed can outrank the next reconnect's snapshot. The
513− // on-disk cache seed passes clearPivot = false: it runs *before* the authoritative
514− // initial REST seed, so clearing here would strip pivot protection from SSE parts/info
515− // that streamed in since subscribe, letting that REST seed regress them.
516− if (clearPivot) {
517− streamedSincePivot.clear()
518− messageInfoUpdatedSincePivot.clear()
519− }
520− return changed
521− }
522−
523− /** Remove messages that were seeded from the on-disk cache ([cacheSeededIds]) but are
524− * absent from the authoritative REST snapshot [rest] — i.e. deleted server-side while the
525− * app was closed. Restricted to cache-seeded ids so a message added by a live SSE event
526− * since subscribe (which the REST endpoint may not have indexed yet) is never pruned.
527− * This is what actually removes ghosts on restart; the reconnect re-seed deliberately
528− * keeps prune=false to avoid racing just-arrived SSE. Call under the same lock as [seed].
529− * Returns true if anything was removed. */
530− fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
531− if (cacheSeededIds.isEmpty()) return false
532− // Build the retain-set from the synthetic key (matching the map's keying for
533− // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>" isn't
534− // wrongly pruned just because its raw id ("") isn't in the REST snapshot's raw-id set.
535− val restIds = rest.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
536− var changed = false
537− for (id in cacheSeededIds) {
538− if (id !in restIds && messages.remove(id) != null) changed = true
539− }
540− return changed
541− }
542−
543− /** Resolve the map key for a seed entry. An UnknownMessage with an empty id (from an
544− * unrecognized server role with no id field) was stored by handleMessageUpdated under a
545− * synthetic key ("unknown-c<created>" or "unknown-h<hash>"); derive the same key here so
546− * a REST seed merges onto the existing holder instead of missing it and inserting a
547− * duplicate under key "". */
548− internal fun syntheticKeyFor(m: MessageWithParts): String =
549− if (m.info.id.isEmpty() && m.info is UnknownMessage) {
550− m.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${m.info.hashCode()}"
551− } else {
552− m.info.id
553− }
554−
555− /** Resolve the actual map key for a raw message id from a server event. A non-empty id is
556− * the map key as-is. An empty id ("") can't be the map key (handleMessageUpdated stores
557− * UnknownMessages with empty ids under a synthetic "unknown-c<created>" / "unknown-h<hash>"
558− * key), so search the map for the holder whose info is an UnknownMessage with an empty id.
559− * Returns null when no holder matches — callers treat this as "not in the store" and skip.
560− *
561− * This is O(N) only for the rare empty-id case (UnknownMessages from unrecognized server
562− * roles); the common non-empty-id path is an O(1) map lookup. Without this, handleMessageRemoved
563− * / handlePartRemoved / the null-session MessagePartUpdated guard would all miss synthetic-keyed
564− * holders, leaving ghost entries and dropped part removals. */
565− private fun resolveMapKey(messageId: String): String? {
566− if (messageId.isNotEmpty()) return messageId
567− // Empty id: find a holder stored under a synthetic key whose info is an UnknownMessage
568− // with an empty id. There's normally at most one such holder per session (an unrecognized
569− // server role with no id is rare), so the linear scan is acceptable.
570− return messages.entries.firstOrNull { entry ->
571− entry.value.info is UnknownMessage && entry.value.info.id.isEmpty()
572− }?.key
573− }
574−
575− /** Reorder the message map by message creation time (stable). Ties and entries with no
576− * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
577− * relative order, so such a message stays last. Preserves each entry's map key so the
578− * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
579− private fun reorderByTime(): Boolean {
580− val entries = messages.entries.map { it.key to it.value }
581− val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
582− // Detect a reordering by comparing keys positionally, instead of allocating two more
583− // N-sized key lists just to compare them.
584− var changed = false
585− for (i in sorted.indices) {
586− if (sorted[i].first != entries[i].first) { changed = true; break }
587− }
588− if (!changed) return false
589− messages.clear()
590− for ((k, v) in sorted) messages[k] = v
591− return true
592− }
593−
594− /** Returns true if the state changed (and a new snapshot should be published). */
595− fun reduce(sessionId: String, event: BusEvent): Boolean {
596− return when (event) {
597− is MessageUpdated -> handleMessageUpdated(sessionId, event)
598−
599− is MessagePartUpdated -> {
600− val part = event.properties.part
601− val rawMessageId = part.messageID ?: event.properties.messageID
602− val partSession = part.sessionID ?: event.properties.sessionID
603− when {
604− rawMessageId == null -> false
605− // Resolve the map key: an UnknownMessage with an empty id is stored under a
606− // synthetic key, so upsertPart(rawMessageId, part) would miss it and fork a
607− // duplicate holder under key "". resolveMapKey returns the synthetic key when
608− // the holder exists, or the raw id (possibly "") when it doesn't — in which
609− // case upsertPart creates a fresh holder under that id, matching the prior
610− // behavior for a genuinely new message.
611− else -> {
612− val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
613− // An evicted UnknownMessage with an empty id was recorded in
614− // evictedMessageIds under its synthetic "unknown-c<created>" /
615− // "unknown-h<hash>" key (see evictOldMessages). resolveMapKey("")
616− // returns null (the holder is gone), so mapKey falls back to "" —
617− // which isn't in the set. Drop the late part instead of re-inserting
618− // a partial bubble. The set could contain synthetic keys for any of
619− // possibly-several evicted unknown messages; without the message's
620− // creation time on the Part we can't disambiguate, so treat ANY
621− // evicted synthetic key as a match. This is over-broad (a genuinely-
622− // new unknown message's first part would be dropped if an unrelated
623− // one was just evicted), but UnknownMessages from unrecognized server
624− // roles are rare, and a following MessageUpdated (which always
625− // carries full info) re-creates the holder correctly either way.
626− if (rawMessageId.isEmpty() && mapKey.isEmpty() &&
627− evictedMessageIds.any { it.startsWith("unknown-") }) {
628− false
629− } else if (partSession != null) {
630− if (partSession == sessionId) upsertPart(mapKey, part) else false
631− } else {
632− // No session id anywhere on the event: a null session is NOT a wildcard
633− // (mirroring isIdle/isError). Every open session's store sees the shared
634− // event stream, so accepting it unconditionally would leak the part into
635− // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
636− // only if this store already holds the parent message — MessageUpdated,
637− // which always carries a session id, will have created it here first.
638− if (messages.containsKey(mapKey)) upsertPart(mapKey, part) else false
639− }
640− }
641− }
642− }
643−
644− is MessagePartRemoved -> handlePartRemoved(sessionId, event)
645−
646− is MessageRemoved -> handleMessageRemoved(sessionId, event)
647−
648− else -> false
649− }
650− }
651−
652− private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
653− val info = event.properties.info ?: return false
654− if (info.sessionID != sessionId) return false
655− // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
656− // field) would collide with other such messages in the map. It carries no id, only
657− // sessionID/time, so derive a synthetic key. Prefer the immutable creation timestamp: it's
658− // stable across updates, so a later update to the SAME unknown message (whose `time.updated`
659− // or `time.completed` changes) coalesces onto its holder via the existing==info guard below
660− // instead of forking a duplicate — the failure mode a full-content hash has, since any
661− // content change moves the hash. Distinct unknown messages still separate by their distinct
662− // creation times; only when even `created` is absent do we fall back to the content hash.
663− val key = if (info.id.isEmpty() && info is UnknownMessage) {
664− info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${info.hashCode()}"
665− } else {
666− info.id
667− }
668− val existing = messages[key]
669− // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
670− // redundant snapshot (mirrors upsertPart's identical-part guard).
671− if (existing != null && existing.info == info) return false
672− // A MessageUpdated re-introduces the message into the store (it was either evicted
673− // by evictOldMessages or genuinely new). Drop any eviction record so a following
674− // MessagePartUpdated updates this holder instead of being dropped as "late".
675− // For an empty-id UnknownMessage the record was stored under the synthetic key (see
676− // evictOldMessages), so remove that too — otherwise the over-broad empty-id guard in
677− // reduce() would keep dropping parts for this message even after it's back in memory.
678− info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
679− if (info.id.isEmpty()) evictedMessageIds.remove(key)
680− if (existing == null) {
681− messages[key] = Holder(info, LinkedHashMap())
682− } else {
683− existing.info = info
684− existing.invalidate()
685− }
686− // Record that this message's info streamed in live, so a re-seed after a reconnect knows
687− // it's newer than the REST snapshot and keeps it (see seed()/beginReseed()).
688− messageInfoUpdatedSincePivot.add(key)
689− if (existing == null) evictOldMessages()
690− return true
691− }
692−
693− private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
694− val rawMessageId = event.properties.messageID ?: return false
695− val partId = event.properties.partID ?: return false
696− val eventSession = event.properties.sessionID
697− // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
698− // synthetic key, so a bare messages.containsKey(rawMessageId) would miss it and the
699− // part removal would be silently dropped.
700− val mapKey = resolveMapKey(rawMessageId) ?: return false
701− when {
702− eventSession != null && eventSession != sessionId -> return false
703− // Null session id: only act if this store already holds the parent message, so a
704− // null-session part removal can't drop a part from an unrelated session's store.
705− eventSession == null && !messages.containsKey(mapKey) -> return false
706− }
707− return removePart(mapKey, partId)
708− }
709−
710− private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
711− val rawId = event.properties.messageID ?: return false
712− val eventSession = event.properties.sessionID
713− // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
714− // synthetic key ("unknown-c<created>" / "unknown-h<hash>"), so messages.remove(rawId)
715− // would miss it and leave the ghost entry stranded — the pivotKey cleanup below would
716− // be dead code (the early return fires before it runs).
717− val id = resolveMapKey(rawId) ?: return false
718− when {
719− eventSession != null && eventSession != sessionId -> return false
720− // Null session id: only act if this store already holds the message (message ids
721− // are globally unique, so this attributes the removal to the owning session only).
722− eventSession == null && !messages.containsKey(id) -> return false
723− }
724− val removed = messages.remove(id) ?: return false
725− // The message is gone from the store; drop any eviction record for it so the set
726− // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
727− // but a quiet session that never reconnects could otherwise accumulate entries.)
728− // For an empty-id UnknownMessage the record was stored under the synthetic key (see
729− // evictOldMessages), so remove that too — keep the set bounded and prevent the
730− // over-broad empty-id guard in reduce() from dropping parts for a reused id.
731− removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
732− // Derive the same key handleMessageUpdated would have stored this holder under. For an
733− // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
734− // "unknown-h<hash>" key (not the raw empty id) — removing the raw id here would leave
735− // the synthetic entry stranded in messageInfoUpdatedSincePivot forever (no correctness
736− // impact today — seed() only consults it when the holder exists — but unbounded growth
737− // across many add/remove cycles in a long-lived session). Mirrors evictOldMessages,
738− // which already uses the map key (oldestKey) for its pivot cleanup.
739− val pivotKey = if (rawId.isEmpty() && removed.info is UnknownMessage) {
740− removed.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${removed.info.hashCode()}"
741− } else {
742− id
743− }
744− if (removed.info.id.isEmpty()) evictedMessageIds.remove(pivotKey)
745− messageInfoUpdatedSincePivot.remove(pivotKey)
746− removed.parts.keys.forEach { streamedSincePivot.remove(it) }
747− return true
748− }
749−
750− private fun upsertPart(messageId: String, part: Part): Boolean {
751− val current = messages[messageId]
752− if (current != null) {
753− // O(1) map lookup + put, vs the former O(P) indexOfFirst scan and full-list copy
754− // on every streamed token. A LinkedHashMap re-put keeps the part's original
755− // position (replace-in-place); a new id is appended — matching the old list order.
756− if (current.parts[part.id] == part) return false
757− current.parts[part.id] = part
758− current.invalidate()
759− } else {
760− // A late part update for a message that was evicted by evictOldMessages() (the
761− // store caps at maxMessages) would otherwise re-insert the message with only
762− // this single part — a partial, contextless bubble at the end of the list. The
763− // evicted message's other parts are gone from memory, so re-inserting can't
764− // restore them; ignore the late part instead. (A later MessageUpdated for the
765− // same id — which always carries full info — re-creates the holder correctly.)
766− if (evictedMessageIds.contains(messageId)) return false
767− val parts = LinkedHashMap<String, Part>()
768− parts[part.id] = part
769− messages[messageId] = Holder(
770− info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
771− parts = parts,
772− )
773− }
774− // Record that this part streamed in, so a re-seed after a reconnect knows it's
775− // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
776− streamedSincePivot.add(part.id)
777− if (current == null) evictOldMessages()
778− return true
779− }
780−
781− private fun removePart(messageId: String, partId: String): Boolean {
782− val current = messages[messageId] ?: return false
783− if (current.parts.remove(partId) == null) return false
784− // Drop the pivot entry too so the set doesn't accumulate ids of removed parts for
785− // the rest of the session (it's otherwise only cleared on reseed/eviction). No
786− // correctness impact today — a stale entry just falls back to the REST snapshot's
787− // version in seed() — but keeping it symmetric with handleMessageRemoved/eviction
788− // bounds the set's growth over a long-lived session.
789− streamedSincePivot.remove(partId)
790− current.invalidate()
791− return true
792− }
793−
794− /** Message ids evicted by [evictOldMessages] (capped at [maxMessages]). A late
795− * [MessagePartUpdated] for one of these would otherwise re-insert the message under
796− * [upsertPart] with only the late part — a partial, contextless bubble at the end of
797− * the list. Membership here tells upsertPart to drop the late part instead. Bounded by
798− * the same cap (entries are removed when the message is re-seeded via [seed] or removed
799− * via [handleMessageRemoved]); a long-lived session that cycles past maxMessages many
800− * times never grows this beyond the most recent eviction batch. */
801− private val evictedMessageIds = mutableSetOf<String>()
802−
803− /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
804− * Returns true if any messages were evicted. */
805− private fun evictOldMessages(): Boolean {
806− var evicted = false
807− while (messages.size > maxMessages) {
808− val oldestKey = messages.keys.iterator().next()
809− val removed = messages.remove(oldestKey)
810− // Record the evicted message's id so a late MessagePartUpdated for it is dropped
811− // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
812− // prior eviction record for this id from an older cycle (the message re-entered
813− // via seed/MessageUpdated and has now been evicted again). Record the MAP KEY
814− // (oldestKey), not just info.id: an UnknownMessage with an empty id is stored
815− // under a synthetic "unknown-c<created>" / "unknown-h<hash>" key, so recording
816− // only info.id (which is "") would never match — a late MessagePartUpdated for
817− // it would resolveMapKey("") → null → fall back to upsertPart("") and re-insert
818− // a partial bubble. Recording the synthetic key lets the empty-id guard below
819− // (in reduce) catch it.
820− removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
821− evictedMessageIds.add(id)
822− }
823− if (removed != null && removed.info.id.isEmpty()) {
824− evictedMessageIds.add(oldestKey)
825− }
826− // Prune the evicted message's pivot bookkeeping so these sets don't grow
827− // unbounded over a long-running session that never reconnects (they're only
828− // otherwise cleared on reseed/seed).
829− messageInfoUpdatedSincePivot.remove(oldestKey)
830− removed?.parts?.keys?.forEach { streamedSincePivot.remove(it) }
831− evicted = true
832− }
833− return evicted
834− }
835−}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktdeleted−279
@@ -1,279 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.booleanPreferencesKey
5−import androidx.datastore.preferences.core.edit
6−import androidx.datastore.preferences.core.floatPreferencesKey
7−import androidx.datastore.preferences.core.intPreferencesKey
8−import androidx.datastore.preferences.core.stringPreferencesKey
9−import androidx.datastore.preferences.preferencesDataStore
10−import kotlinx.coroutines.flow.Flow
11−import kotlinx.coroutines.flow.map
12−
13−enum class ThemeMode { SYSTEM, LIGHT, DARK, AMOLED }
14−
15−private val Context.settingsDataStore by preferencesDataStore(name = "settings")
16−
17−/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
18−@Suppress("TooManyFunctions")
19−class SettingsStore(context: Context) {
20−
21− private val appContext = context.applicationContext
22− private val themeKey = stringPreferencesKey("theme_mode")
23− private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
24− private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
25− private val appLockKey = booleanPreferencesKey("app_lock")
26− private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
27− private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
28− private val codeWrapKey = booleanPreferencesKey("code_wrap")
29− private val sessionSortModeKey = stringPreferencesKey("session_sort_mode")
30− private val sessionSortDescendingKey = booleanPreferencesKey("session_sort_descending")
31− private val sessionShowArchivedKey = booleanPreferencesKey("session_show_archived")
32− private val preferredModelIdKey = stringPreferencesKey("preferred_model_id")
33− private val preferredAgentNameKey = stringPreferencesKey("preferred_agent_name")
34− private val compactMessageSpacingKey = booleanPreferencesKey("compact_message_spacing")
35− private val hapticsEnabledKey = booleanPreferencesKey("haptics_enabled")
36− private val reducedMotionKey = booleanPreferencesKey("reduced_motion")
37− private val languageOverrideKey = stringPreferencesKey("language_override")
38− private val notifRunCompleteKey = booleanPreferencesKey("notif_run_complete")
39− private val notifPermissionKey = booleanPreferencesKey("notif_permission")
40− private val notifErrorKey = booleanPreferencesKey("notif_error")
41− private val swipeLeftActionKey = stringPreferencesKey("swipe_left_action")
42− private val swipeRightActionKey = stringPreferencesKey("swipe_right_action")
43−
44− companion object {
45− /** Bounds for the chat text-size multiplier so the UI can't be scaled into
46− * unreadability or an unusable micro-font. 1.0 is the design default. */
47− const val MIN_CHAT_TEXT_SCALE = 0.8f
48− const val MAX_CHAT_TEXT_SCALE = 1.6f
49− const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
50− /** 0 means re-lock the instant the app backgrounds. Other entries are the grace
51− * period (seconds) during which a quick app-switch does NOT re-prompt for auth. */
52− val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300, 1800, 3600)
53− const val DEFAULT_APP_LOCK_RELOCK_SECONDS = 0
54− }
55−
56− val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
57− runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
58− .getOrDefault(ThemeMode.SYSTEM)
59− }
60−
61− // Default OFF so the app ships with its hand-built "Tokyo Night" brand palette rather than
62− // the wallpaper-derived Material You scheme, which fully replaces the brand colours. Users
63− // on Android 12+ can still opt into dynamic color from Settings.
64− val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
65− prefs[dynamicColorKey] ?: false
66− }
67−
68− /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
69− * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
70− * true to match the prior hardcoded behavior. */
71− val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
72− prefs[sendOnEnterKey] ?: true
73− }
74−
75− /** When true, the app requires device biometric/credential authentication on launch
76− * (and when returning from the background) before the UI is shown, protecting the
77− * stored server credentials. Defaults to false so the app is usable out of the box. */
78− val appLock: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
79− prefs[appLockKey] ?: false
80− }
81−
82− /** Grace period (seconds) before re-locking after the app backgrounds. 0 = re-lock
83− * immediately (the prior behavior). Lets a quick app-switch not re-prompt, which is the
84− * standard reason users disable biometric locks in messaging/banking apps. */
85− val appLockReLockSeconds: Flow<Int> = appContext.settingsDataStore.data.map { prefs ->
86− prefs[appLockReLockSecondsKey] ?: DEFAULT_APP_LOCK_RELOCK_SECONDS
87− }
88−
89− /** Multiplier applied to chat message (markdown) and code text so users can size the
90− * conversation for readability independently of the system font scale. Clamped to
91− * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
92− * stored value can never produce an unreadable UI. Defaults to 1.0 (design size). */
93− val chatTextScale: Flow<Float> = appContext.settingsDataStore.data.map { prefs ->
94− (prefs[chatTextScaleKey] ?: DEFAULT_CHAT_TEXT_SCALE)
95− .coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
96− }
97−
98− /** When true, fenced/indented code blocks soft-wrap long lines instead of scrolling
99− * horizontally. Defaults to false (horizontal scroll) to preserve code formatting. */
100− val codeWrap: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
101− prefs[codeWrapKey] ?: false
102− }
103−
104− /** Persisted session-list sort mode so a chosen ordering survives process death (not just
105− * rotation). Stored as the enum name; an unknown value falls back to RECENT. */
106− val sessionSortMode: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
107− prefs[sessionSortModeKey] ?: "RECENT"
108− }
109−
110− /** Persisted session-list sort direction. Defaults to descending (newest first). */
111− val sessionSortDescending: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
112− prefs[sessionSortDescendingKey] ?: true
113− }
114−
115− /** Persisted "show archived sessions" toggle so the choice survives process death. */
116− val sessionShowArchived: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
117− prefs[sessionShowArchivedKey] ?: false
118− }
119−
120− /** Preferred default model id for new sessions. Empty means "use the server default".
121− * A user who always picks a specific model doesn't have to pick it every session. */
122− val preferredModelId: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
123− prefs[preferredModelIdKey] ?: ""
124− }
125−
126− /** Preferred default agent name for new sessions. Empty means "use the server default". */
127− val preferredAgentName: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
128− prefs[preferredAgentNameKey] ?: ""
129− }
130−
131− /** When true, chat message bubbles use a tighter vertical spacing (8dp vs the default 16dp)
132− * so power users can fit more conversation on screen. Defaults to false (the design default). */
133− val compactMessageSpacing: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
134− prefs[compactMessageSpacingKey] ?: false
135− }
136−
137− /** When true, haptic feedback is fired on taps and confirmations. Defaults to true (the
138− * existing behavior). An in-app toggle lets users disable haptics without turning off
139− * system-wide vibration. Read by a CompositionLocal that gates haptic calls. */
140− val hapticsEnabled: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
141− prefs[hapticsEnabledKey] ?: true
142− }
143−
144− /** When true, animations and transitions are limited (collapsed to instant/snap).
145− * Defaults to false — the app already honors the system Developer Options animator
146− * scale, but this gives users an in-app toggle without disabling motion OS-wide.
147− * Read by a CompositionLocal that gates animation specs. */
148− val reducedMotion: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
149− prefs[reducedMotionKey] ?: false
150− }
151−
152− /** Override the app language (an ISO 639-1 code like "en" or "es", or empty for the
153− * system default). Applied via AppCompat's per-app language API so the OS can
154− * handle locale resolution without an app restart on Android 13+. */
155− val languageOverride: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
156− prefs[languageOverrideKey] ?: ""
157− }
158−
159− /** Granular notification toggles. Each gates a notification channel's posting so a user
160− * can mute specific notification types in-app (in addition to the OS channel controls).
161− * Default true for all (matching the prior "always post" behavior). */
162− val notifRunComplete: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
163− prefs[notifRunCompleteKey] ?: true
164− }
165− val notifPermission: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
166− prefs[notifPermissionKey] ?: true
167− }
168− val notifError: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
169− prefs[notifErrorKey] ?: true
170− }
171−
172− /** Remappable swipe actions for the session list. Stored as the SwipeAction enum name;
173− * an unknown value falls back to the default. Defaults: left=DELETE, right=ARCHIVE
174− * (matching the prior hardcoded behavior). */
175− val swipeLeftAction: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
176− prefs[swipeLeftActionKey] ?: SwipeAction.DELETE.name
177− }
178− val swipeRightAction: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
179− prefs[swipeRightActionKey] ?: SwipeAction.ARCHIVE.name
180− }
181−
182− suspend fun setThemeMode(mode: ThemeMode) {
183− appContext.settingsDataStore.edit { it[themeKey] = mode.name }
184− }
185−
186− suspend fun setDynamicColor(enabled: Boolean) {
187− appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
188− }
189−
190− suspend fun setSendOnEnter(enabled: Boolean) {
191− appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
192− }
193−
194− suspend fun setAppLock(enabled: Boolean) {
195− appContext.settingsDataStore.edit { it[appLockKey] = enabled }
196− }
197−
198− suspend fun setAppLockReLockSeconds(seconds: Int) {
199− appContext.settingsDataStore.edit {
200− it[appLockReLockSecondsKey] = APP_LOCK_RELOCK_OPTIONS_SECONDS.minBy { k -> Math.abs(k - seconds) }
201− }
202− }
203−
204− suspend fun setChatTextScale(scale: Float) {
205− appContext.settingsDataStore.edit {
206− it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
207− }
208− }
209− suspend fun setCodeWrap(enabled: Boolean) {
210− appContext.settingsDataStore.edit {
211− it[codeWrapKey] = enabled
212− }
213− }
214−
215− suspend fun setSessionSortMode(mode: String) {
216− appContext.settingsDataStore.edit { it[sessionSortModeKey] = mode }
217− }
218−
219− suspend fun setSessionSortDescending(descending: Boolean) {
220− appContext.settingsDataStore.edit { it[sessionSortDescendingKey] = descending }
221− }
222− suspend fun setSessionShowArchived(show: Boolean) {
223− appContext.settingsDataStore.edit {
224− it[sessionShowArchivedKey] = show
225− }
226− }
227−
228− suspend fun setPreferredModelId(modelId: String) {
229− appContext.settingsDataStore.edit {
230− if (modelId.isEmpty()) it.remove(preferredModelIdKey) else it[preferredModelIdKey] = modelId
231− }
232− }
233−
234− suspend fun setPreferredAgentName(agentName: String) {
235− appContext.settingsDataStore.edit {
236− if (agentName.isEmpty()) it.remove(preferredAgentNameKey) else it[preferredAgentNameKey] = agentName
237− }
238− }
239−
240− suspend fun setCompactMessageSpacing(enabled: Boolean) {
241− appContext.settingsDataStore.edit {
242− it[compactMessageSpacingKey] = enabled
243− }
244− }
245−
246− suspend fun setHapticsEnabled(enabled: Boolean) {
247− appContext.settingsDataStore.edit { it[hapticsEnabledKey] = enabled }
248− }
249−
250− suspend fun setReducedMotion(enabled: Boolean) {
251− appContext.settingsDataStore.edit { it[reducedMotionKey] = enabled }
252− }
253−
254− suspend fun setLanguageOverride(code: String) {
255− appContext.settingsDataStore.edit {
256− if (code.isEmpty()) it.remove(languageOverrideKey) else it[languageOverrideKey] = code
257− }
258− }
259−
260− suspend fun setNotifRunComplete(enabled: Boolean) {
261− appContext.settingsDataStore.edit { it[notifRunCompleteKey] = enabled }
262− }
263− suspend fun setNotifPermission(enabled: Boolean) {
264− appContext.settingsDataStore.edit { it[notifPermissionKey] = enabled }
265− }
266− suspend fun setNotifError(enabled: Boolean) {
267− appContext.settingsDataStore.edit { it[notifErrorKey] = enabled }
268− }
269−
270− suspend fun setSwipeLeftAction(action: SwipeAction) {
271− appContext.settingsDataStore.edit { it[swipeLeftActionKey] = action.name }
272− }
273− suspend fun setSwipeRightAction(action: SwipeAction) {
274− appContext.settingsDataStore.edit { it[swipeRightActionKey] = action.name }
275− }
276−}
277−
278−/** Remappable swipe actions for the session list. NONE disables the swipe entirely. */
279−enum class SwipeAction { DELETE, ARCHIVE, MARK_READ, NONE }
app/src/main/java/soy/iko/opencode/di/AppContainer.ktdeleted−1587
@@ -1,1587 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import android.content.Context
4−import android.net.ConnectivityManager
5−import android.net.Network
6−import android.net.NetworkCapabilities
7−import android.net.NetworkRequest
8−import android.util.Log
9−import soy.iko.opencode.data.model.AssistantMessage
10−import soy.iko.opencode.data.model.BusEvent
11−import soy.iko.opencode.data.model.FilePromptPart
12−import soy.iko.opencode.data.model.MessagePartUpdated
13−import soy.iko.opencode.data.model.MessageUpdated
14−import soy.iko.opencode.data.model.Permission
15−import soy.iko.opencode.data.model.PermissionReplied
16−import soy.iko.opencode.data.model.PermissionResponse
17−import soy.iko.opencode.data.model.PermissionUpdated
18−import soy.iko.opencode.data.model.StepFinishPart
19−import soy.iko.opencode.data.model.ServerProfile
20−import soy.iko.opencode.data.model.SessionError
21−import soy.iko.opencode.data.model.SessionIdle
22−import soy.iko.opencode.data.model.ToolPart
23−import soy.iko.opencode.data.model.TODO_WRITE_TOOL
24−import soy.iko.opencode.data.model.parseTodos
25−import soy.iko.opencode.data.model.statusEnum
26−import soy.iko.opencode.data.model.TodoStatus
27−import soy.iko.opencode.data.model.inputElement
28−import soy.iko.opencode.data.repo.AttachmentDraftStore
29−import soy.iko.opencode.data.repo.BackupManager
30−import soy.iko.opencode.data.repo.DraftStore
31−import soy.iko.opencode.data.repo.ErrorKind
32−import soy.iko.opencode.data.repo.FileBrowserPrefs
33−import soy.iko.opencode.data.repo.MessageCacheStore
34−import soy.iko.opencode.data.repo.OutboxMessage
35−import soy.iko.opencode.data.repo.OutboxStore
36−import soy.iko.opencode.data.repo.RecentModelsStore
37−import soy.iko.opencode.data.repo.SearchHistoryStore
38−import soy.iko.opencode.data.repo.SessionPrefsStore
39−import soy.iko.opencode.data.repo.SessionRepository
40−import soy.iko.opencode.data.repo.ProfileStore
41−import soy.iko.opencode.data.repo.SettingsStore
42−import soy.iko.opencode.data.repo.classifyError
43−import soy.iko.opencode.data.repo.responseStatusCode
44−import soy.iko.opencode.data.network.EventStreamClient
45−import soy.iko.opencode.data.network.HttpClientFactory
46−import soy.iko.opencode.data.network.NetworkConfig
47−import soy.iko.opencode.data.network.OpencodeApiClient
48−import soy.iko.opencode.notification.NotificationChannels
49−import soy.iko.opencode.notification.RunForegroundService
50−import soy.iko.opencode.util.safeExceptionSummary
51−import soy.iko.opencode.notification.SessionNotifications
52−import soy.iko.opencode.R
53−import soy.iko.opencode.util.runCatchingCancellable
54−import kotlinx.coroutines.CoroutineScope
55−import kotlinx.coroutines.Dispatchers
56−import kotlinx.coroutines.ExperimentalCoroutinesApi
57−import kotlinx.coroutines.SupervisorJob
58−import kotlinx.coroutines.cancel
59−import kotlinx.coroutines.delay
60−import kotlinx.coroutines.flow.MutableSharedFlow
61−import kotlinx.coroutines.flow.MutableStateFlow
62−import kotlinx.coroutines.flow.SharedFlow
63−import kotlinx.coroutines.flow.StateFlow
64−import kotlinx.coroutines.flow.asSharedFlow
65−import kotlinx.coroutines.flow.asStateFlow
66−import kotlinx.coroutines.flow.collectLatest
67−import kotlinx.coroutines.flow.combine
68−import kotlinx.coroutines.flow.debounce
69−import kotlinx.coroutines.flow.distinctUntilChanged
70−import kotlinx.coroutines.flow.drop
71−import kotlinx.coroutines.flow.emptyFlow
72−import kotlinx.coroutines.flow.first
73−import kotlinx.coroutines.Job
74−import kotlinx.coroutines.flow.flatMapLatest
75−import kotlinx.coroutines.flow.map
76−import kotlinx.coroutines.flow.update
77−import kotlinx.coroutines.isActive
78−import kotlinx.coroutines.launch
79−import kotlinx.coroutines.sync.Mutex
80−import kotlinx.coroutines.sync.withLock
81−import kotlinx.coroutines.withTimeoutOrNull
82−import java.util.concurrent.atomic.AtomicBoolean
83−
84−/**
85− * Result of probing a server URL to check connectivity and whether authentication
86− * is required, without persisting a profile or opening a long-lived connection.
87− */
88−sealed class ProbeResult {
89− /** Server is reachable and accepts unauthenticated requests. */
90− object Reachable : ProbeResult()
91−
92− /** Server is reachable but rejected the probe with an authentication error. */
93− object NeedsAuth : ProbeResult()
94−
95− /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
96− data class Unreachable(val error: String) : ProbeResult()
97−}
98−
99−/**
100− * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
101− * process-wide singletons and the currently active [OpencodeConnection].
102− */
103−open class AppContainer private constructor(
104− private val appContext: Context?,
105− private val skipInit: Boolean,
106− @Suppress("unused") private val testMode: Boolean,
107−) {
108− constructor(context: Context) : this(context.applicationContext, false, false)
109− protected constructor() : this(null, true, true)
110−
111− private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
112− open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
113− open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
114− open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
115− open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
116− open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
117− open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
118− open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
119− open val searchHistoryStore: SearchHistoryStore by lazy { SearchHistoryStore(appContext!!) }
120− open val recentModelsStore: RecentModelsStore by lazy { RecentModelsStore(appContext!!) }
121− open val fileBrowserPrefs: FileBrowserPrefs by lazy { FileBrowserPrefs(appContext!!) }
122− open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
123−
124− /** True while the outbox is actively flushing queued messages to the server, so the UI
125− * can show a "sending queued messages…" indicator. */
126− private val _outboxSending = MutableStateFlow(false)
127− open val outboxSending: StateFlow<Boolean> = _outboxSending.asStateFlow()
128−
129− /** Bumped to nudge an immediate outbox flush (after enqueue, or a manual "Send now"),
130− * independent of connection/online changes. */
131− private val _outboxFlushTrigger = MutableStateFlow(0)
132− open fun flushOutbox() { _outboxFlushTrigger.update { it + 1 } }
133−
134− private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
135− open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
136−
137− /** True while a background auto-reconnect to the most-recent server is in flight. */
138− private val _reconnecting = MutableStateFlow(false)
139− open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
140−
141− /**
142− * Set once a startup auto-reconnect succeeds. The server list collects this and
143− * (once) navigates straight to the session list so a returning user skips it.
144− * [consumeAutoConnect] guards against re-firing on screen re-entry.
145− */
146− private val _autoConnectDone = MutableStateFlow(false)
147− open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
148− private val autoConnectConsumed = AtomicBoolean(false)
149− open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
150−
151− /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
152− private val _pendingShare = MutableStateFlow<String?>(null)
153− open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
154− open fun setPendingShare(text: String) { _pendingShare.value = text }
155− open fun consumePendingShare(): String? {
156− val current = _pendingShare.value ?: return null
157− return if (_pendingShare.compareAndSet(current, null)) current else null
158− }
159−
160− /** Image/file Uris (as strings) shared into the app, staged as attachments in the next
161− * opened session. Consumed once by the first ChatScreen to compose after the share. */
162− private val _pendingSharedMedia = MutableStateFlow<List<String>>(emptyList())
163− open val pendingSharedMedia: StateFlow<List<String>> = _pendingSharedMedia.asStateFlow()
164− open fun setPendingSharedMedia(uris: List<String>) { _pendingSharedMedia.value = uris }
165− open fun consumePendingSharedMedia(): List<String> {
166− val current = _pendingSharedMedia.value
167− if (current.isEmpty()) return emptyList()
168− return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
169− }
170−
171− /**
172− * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
173− * consumes it once connected, creating a fresh session and opening it. Uses a counter
174− * (not a boolean) so re-tapping the shortcut while a prior request is still pending
175− * increments the value and re-fires the LaunchedEffect — a boolean StateFlow wouldn't
176− * emit when set to the same value, making the request unrecoverable by re-tapping.
177− */
178− private val _pendingNewSession = MutableStateFlow(0)
179− open val pendingNewSession: StateFlow<Int> = _pendingNewSession.asStateFlow()
180− open fun requestNewSession() { _pendingNewSession.update { it + 1 } }
181− open fun consumePendingNewSession(): Boolean {
182− while (true) {
183− val current = _pendingNewSession.value
184− if (current == 0) return false
185− if (_pendingNewSession.compareAndSet(current, current - 1)) return true
186− }
187− }
188−
189− /** A one-shot signal to navigate to the Diagnostics screen (e.g. from the crash-relaunch
190− * prompt). Consumed by OpencodeApp's NavHost. */
191− private val _pendingDiagnostics = MutableStateFlow(false)
192− open val pendingDiagnostics: StateFlow<Boolean> = _pendingDiagnostics.asStateFlow()
193− open fun requestDiagnostics() { _pendingDiagnostics.value = true }
194− open fun consumePendingDiagnostics(): Boolean = _pendingDiagnostics.compareAndSet(true, false)
195−
196− /**
197− * A session id to open from an external trigger (a notification tap or a deep link),
198− * paired with the originating server's profile id (if known) so a tap after the user
199− * has switched servers routes back to the server that ran the session. The nav host
200− * consumes it once a connection is active (switching to [profileId] first if needed).
201− */
202− data class PendingOpenSession(val sessionId: String, val profileId: String?)
203−
204− private val _pendingOpenSession = MutableStateFlow<PendingOpenSession?>(null)
205− open val pendingOpenSession: StateFlow<PendingOpenSession?> = _pendingOpenSession.asStateFlow()
206− open fun requestOpenSession(id: String, profileId: String? = null) {
207− _pendingOpenSession.value = PendingOpenSession(id, profileId)
208− }
209− open fun consumePendingOpenSession(): PendingOpenSession? {
210− val current = _pendingOpenSession.value ?: return null
211− return if (_pendingOpenSession.compareAndSet(current, null)) current else null
212− }
213−
214− /**
215− * A file path requested to be opened (via deep link `opencode://file/{path}` or future
216− * entry points), consumed by the NavHost to navigate to the file viewer. Mirrors the
217− * session open-signal pattern: a StateFlow the NavHost observes, drained atomically.
218− */
219− private val _pendingOpenFile = MutableStateFlow<String?>(null)
220− open val pendingOpenFile: StateFlow<String?> = _pendingOpenFile.asStateFlow()
221− open fun requestOpenFile(path: String) { _pendingOpenFile.value = path }
222− open fun consumePendingOpenFile(): String? {
223− val current = _pendingOpenFile.value ?: return null
224− return if (_pendingOpenFile.compareAndSet(current, null)) current else null
225− }
226−
227− /**
228− * The session the user is currently viewing (or null when not in a chat). Drives the
229− * unread tracker: messages arriving for any *other* session mark it unread, and
230− * opening a session clears its unread state.
231− */
232− private val _currentSession = MutableStateFlow<String?>(null)
233− open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
234−
235− /** Session ids that received activity while not being viewed, mapped to the count
236− * of unread message events. A count (not just a presence set) lets the session
237− * list badge show "3 unread" instead of a bare dot, so the user can tell whether
238− * one reply or a whole burst arrived. */
239− private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
240− open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
241−
242− /** Session ids the user has muted. A muted session doesn't badge unread (the count is
243− * still tracked internally so unmuting can restore it, but the badge and completion
244− * notification are suppressed). Persisted via SessionPrefsStore so the choice survives
245− * process death. */
246− private val _mutedSessions = MutableStateFlow<Set<String>>(emptySet())
247− open val mutedSessions: StateFlow<Set<String>> = _mutedSessions.asStateFlow()
248−
249− /** True when any assistant run is actively streaming across all sessions. Drives the
250− * disconnect confirmation on the session list (disconnecting mid-run kills it). */
251− private val _anyRunActive = MutableStateFlow(false)
252− open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
253−
254− /** Session ids currently streaming an assistant run, surfaced so the session list can
255− * badge the row whose run is active. Mirrors [activeRuns] as an immutable snapshot so
256− * Compose can skip recomposition when the set hasn't changed. */
257− private val _runningSessionIds = MutableStateFlow<Set<String>>(emptySet())
258− open val runningSessionIds: StateFlow<Set<String>> = _runningSessionIds.asStateFlow()
259−
260− /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
261− * the foreground-service notification so the user can see what the agent is working on
262− * without opening the app. Null when no plan is available or no run is active. */
263− private val _runProgressText = MutableStateFlow<String?>(null)
264− open val runProgressText: StateFlow<String?> = _runProgressText.asStateFlow()
265−
266− /** Whether the device currently has network connectivity. Distinct from the SSE
267− * connection state so the UI can tell "you're offline" (device) from "server
268− * unreachable" (credentials/host). Updated by the [networkCallback]. */
269− private val _isOnline = MutableStateFlow(true)
270− open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
271−
272− /** Whether a foreground Activity is currently showing. Distinct from [currentSession],
273− * which stays set while the app is merely backgrounded (the chat screen isn't disposed
274− * when the user locks their phone). Permission/completion notifications need the real
275− * foreground signal so they still fire for a session the user "has open" but has walked
276− * away from — the core of the run-in-the-background use case. Set from MainActivity's
277− * onStart/onStop. */
278− private val _isForeground = MutableStateFlow(false)
279− open val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
280− open fun setForeground(foreground: Boolean) { _isForeground.value = foreground }
281−
282− /** True when the user is actively looking at [sessionId] (app foregrounded AND that
283− * session is the one on screen), so an in-app affordance (the permission dialog) is
284− * handling it and a notification would be redundant. */
285− private fun isActivelyViewing(sessionId: String): Boolean =
286− _isForeground.value && sessionId == _currentSession.value
287−
288− open fun setCurrentSession(id: String?) {
289− _currentSession.value = id
290− if (id != null) {
291− _unread.update { it - id }
292− unreadMessageIds.remove(id)
293− appContext?.let {
294− SessionNotifications.cancel(it, id)
295− SessionNotifications.cancelPermission(it, id)
296− SessionNotifications.cancelError(it, id)
297− }
298− }
299− }
300−
301− /** Clear unread badge and dedup set for a deleted session so its stale count doesn't
302− * linger in the session list badge or leak memory until the next connect/disconnect. */
303− open fun clearUnread(id: String) {
304− _unread.update { it - id }
305− unreadMessageIds.remove(id)
306− }
307−
308− /** Mark a session as read (clear its unread badge) — the "Mark read" notification action.
309− * Updates the launcher-icon badge directly (not just the in-memory [StateFlow]) so a
310− * "Mark read" tap while the app is backgrounded — when [SessionListScreen]'s
311− * `LaunchedEffect(totalUnread)` collector isn't running — still decrements the badge.
312− * Without this, the notification dismisses but the launcher badge shows a stale count
313− * until the app is next foregrounded. */
314− open fun markSessionRead(id: String) {
315− clearUnread(id)
316− appContext?.let { ctx ->
317− SessionNotifications.updateUnreadBadge(ctx, _unread.value.values.sum())
318− }
319− }
320−
321− /** Mark a session as unread with a count of 1 (or restore a prior count). Used by the
322− * session list's "Mark as unread" overflow action so a user can re-flag a conversation
323− * they want to revisit. No-op when the session is the one currently being viewed — a
324− * viewed session clears its own unread badge via [setCurrentSession], so marking it
325− * unread would race and immediately clear.
326− *
327− * Unlike the SSE-driven unread tracking in [observeMessageActivity] (which skips muted
328− * sessions so a muted conversation doesn't accumulate counts from incoming events), this
329− * manual action does NOT skip muted sessions: the count is stored in [_unread] regardless
330− * so unmuting restores it, and the list UI hides the badge while muted. The launcher-icon
331− * and nav badges (in SessionListScreen / OpencodeApp) filter out muted sessions when
332− * summing the total, so a manually-marked-unread muted session doesn't bump those. */
333− open fun markSessionUnread(id: String, count: Int = 1) {
334− if (id == _currentSession.value) return
335− _unread.update { it + (id to count) }
336− }
337−
338− /** Toggle a session's mute state. A muted session suppresses the unread badge and the
339− * completion notification so a noisy conversation doesn't interrupt. Persisted
340− * via SessionPrefsStore so it survives process death. */
341− fun toggleSessionMute(id: String) {
342− // Use atomic update so a concurrent prefs reload (_mutedSessions.value = ids from
343− // sessionPrefsStore.muted.collect) can't clobber this toggle mid read-modify-write.
344− var nowMuted = false
345− _mutedSessions.update { current ->
346− nowMuted = id !in current
347− if (nowMuted) current + id else current - id
348− }
349− appContext?.let {
350− appScope.launch {
351− runCatchingCancellable { sessionPrefsStore.setMuted(id, nowMuted) }
352− }
353− }
354− }
355−
356− /** True when [id] is muted (unread badge and completion notification suppressed). */
357− fun isSessionMuted(id: String): Boolean = id in _mutedSessions.value
358−
359− /** Clear every session's unread badge at once — the session list's "Mark all read" action. */
360− open fun clearAllUnread() {
361− _unread.update { emptyMap() }
362− }
363−
364− /** Restore a session's unread badge after a failed server switch reconnects.
365− * Preserves the prior [count] so a session badged with "5 unread" before the
366− * switch attempt still shows 5 (not 1) after the restore. No-op for the session
367− * the user is currently viewing, since a viewed session is never badged. */
368− open fun restoreUnread(id: String, count: Int) {
369− if (id != _currentSession.value) {
370− _unread.update { it + (id to count) }
371− }
372− }
373−
374− /** Resolve a localized string — view models reach resources through the container. */
375− open fun string(id: Int, vararg formatArgs: Any): String {
376− if (appContext == null) return ""
377− return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
378− }
379−
380− /**
381− * Convert a throwable into a user-facing message. Classifies the error by concrete
382− * type (network, timeout, HTTP status) rather than string-matching class names, so
383− * the message reflects what actually went wrong without leaking internal URLs/state.
384− */
385− open fun friendlyError(t: Throwable): String =
386− friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
387−
388− /**
389− * Same classification as [friendlyError] but accepts an explicit base URL, so callers
390− * that aren't operating on the active connection (e.g. [probeServer]) can still
391− * produce a user-facing message with the right server address.
392− */
393− open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
394− when (classifyError(t)) {
395− ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
396− string(R.string.error_not_reachable, baseUrl)
397− ErrorKind.TIMEOUT -> string(R.string.error_timeout)
398− ErrorKind.SERVER -> string(R.string.error_server)
399− // Show only the HTTP status (e.g. 401, 404) — never the request URL,
400− // which a ClientRequestException carries in its message and which we
401− // promised not to leak (it can include auth or internal paths).
402− ErrorKind.CLIENT -> {
403− val code = responseStatusCode(t)
404− when (code) {
405− // Auth failures reuse the connection banner text: the request itself is
406− // fine, the credentials aren't, so the fix is re-editing the profile —
407− // not "request failed (401)" which implies an opaque program error.
408− 401, 403 -> string(R.string.connection_failed)
409− 404 -> string(R.string.error_not_found)
410− else -> code?.let { string(R.string.error_client_status, it) }
411− ?: string(R.string.error_generic)
412− }
413− }
414− ErrorKind.UNKNOWN -> string(R.string.error_generic)
415− }
416−
417− /**
418− * Drive the [RunForegroundService] from the process-lived [appScope] rather than from the
419− * Activity's composition. Previously the start/stop was wired to a
420− * `collectAsStateWithLifecycle` collector in the root composable, which pauses while the
421− * Activity is STOPPED — so a run *started while backgrounded* (e.g. via the notification
422− * inline-reply) never acquired foreground priority and its long-lived SSE stream could be
423− * killed by Doze, and a run that *ended* while backgrounded left the "working…"
424− * notification lingering until the app was reopened. Collecting here reacts to
425− * [anyRunActive] regardless of Activity state.
426− */
427− private fun observeRunForegroundService() {
428− val ctx = appContext ?: return
429− appScope.launch {
430− // Debounce so a run that starts then fails/idles almost immediately doesn't
431− // dispatch startForegroundService() then stopService() back-to-back — if the stop
432− // wins that race before the service calls startForeground(), Android raises the
433− // "did not then call startForeground()" crash ~5s later. Collapsing the window
434− // emits only the settled state; distinctUntilChanged drops a no-op re-emit when a
435− // brief true→false→true flurry settles back to the value already applied.
436− anyRunActive
437− .debounce(NetworkConfig.runForegroundDebounceMs)
438− .distinctUntilChanged()
439− .collect { active ->
440− if (active) {
441− // When exactly one run is active, resolve its title (and id, for the
442− // Stop action) so the notification identifies which session is running
443− // and can be aborted from the notification. Multiple concurrent runs
444− // fall back to the generic title (naming one would be misleading).
445− val activeId = synchronized(activeRuns) {
446− activeRuns.toList().singleOrNull()
447− }
448− val title = activeId?.let { sid ->
449− runCatchingCancellable {
450− activeConnection.value?.repository?.listSessions()
451− ?.firstOrNull { it.id == sid }?.displayTitle
452− }.getOrNull()
453− }
454− val profileId = activeConnection.value?.profile?.id
455− RunForegroundService.start(ctx, title, activeId, profileId, _runProgressText.value)
456− } else {
457− RunForegroundService.stop(ctx)
458− }
459− }
460− }
461− // Separate collector: update the FGS notification's progress text ("Step 2 of 5")
462− // as the agent works through its task plan. Re-sends the service intent with the
463− // updated progress, which the service uses to rebuild the notification.
464− appScope.launch {
465− _runProgressText
466− .drop(1) // skip initial value — the anyRunActive collector handles the first start
467− .distinctUntilChanged()
468− .collect { progress ->
469− if (_anyRunActive.value) {
470− val activeId = synchronized(activeRuns) { activeRuns.toList().singleOrNull() }
471− val title = activeId?.let { sid ->
472− runCatchingCancellable {
473− activeConnection.value?.repository?.listSessions()
474− ?.firstOrNull { it.id == sid }?.displayTitle
475− }.getOrNull()
476− }
477− val profileId = activeConnection.value?.profile?.id
478− RunForegroundService.start(ctx, title, activeId, profileId, progress)
479− }
480− }
481− }
482− }
483−
484− /**
485− * Post a system notification when the SSE stream hits a non-retryable failure
486− * ([Failed]/[AuthFailed]) while the app is backgrounded during an active run, so a
487− * backgrounded user is signaled that their run is stranded (the in-app banner isn't
488− * visible). The notification is cancelled when the stream reconnects. This closes the
489− * gap where a user who kicks off a run and backgrounds the app returns hours later to
490− * find nothing happened and no signal why — the stream parked on a 4xx mid-run and the
491− * in-app banner was never seen.
492− */
493− private fun observeBackgroundedConnectionFailures() {
494− val ctx = appContext ?: return
495− appScope.launch {
496− activeConnection.collectLatest { conn ->
497− if (conn == null) return@collectLatest
498− conn.events.state.collect { state ->
499− when (state) {
500− EventStreamClient.ConnectionState.Failed,
501− EventStreamClient.ConnectionState.AuthFailed -> {
502− // Only notify when the app is backgrounded AND a run was recently
503− // active (or still is) — a foregrounded user sees the banner, and
504− // a failure with no run is less actionable.
505− if (!_isForeground.value && _anyRunActive.value) {
506− runCatchingCancellable {
507− SessionNotifications.postConnectionLost(
508− ctx, conn.profile.displayLabel, conn.profile.id,
509− )
510− }.onFailure {
511− Log.w("AppContainer", "connection-lost notification failed: ${safeExceptionSummary(it)}")
512− }
513− }
514− }
515− EventStreamClient.ConnectionState.Connected -> {
516− runCatchingCancellable { SessionNotifications.cancelConnectionLost(ctx) }
517− }
518− else -> {}
519− }
520− }
521− }
522− }
523− }
524−
525− /**
526− * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
527− * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
528− * [anyRunActive] (and the foreground service + "working…" notification) on indefinitely. On a
529− * reconnect we clear the tracking and let live streaming events re-populate it: a genuinely
530− * in-progress run re-adds itself within moments via [isLiveRunActivity], while a run that
531− * finished during the outage produces no further live events (its completion arrives only via
532− * the REST re-seed), so it correctly stays cleared. The first connect of a connection is not a
533− * reconnect; `connect()` already resets [activeRuns], so only subsequent Connected transitions
534− * trigger the sweep.
535− */
536− private fun observeRunReconcileOnReconnect() {
537− appScope.launch {
538− activeConnection.collectLatest { conn ->
539− if (conn == null) return@collectLatest
540− var hasConnectedBefore = false
541− conn.events.state.collect { state ->
542− if (state == EventStreamClient.ConnectionState.Connected) {
543− if (hasConnectedBefore) {
544− synchronized(activeRuns) {
545− activeRuns.clear()
546− publishRunState()
547− }
548− }
549− hasConnectedBefore = true
550− }
551− }
552− }
553− }
554− }
555−
556− /**
557− * Flush the offline outbox whenever there's an active connection and the device is
558− * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
559− * server they were composed for (matched by profile id), so a reconnect to a different
560− * server never misfires them against a session id that doesn't exist there. Uses a plain
561− * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight and
562− * send order is preserved; each message carries a stable idempotency key (its persistent
563− * outbox id) so even a re-send across flushes is deduplicated server-side.
564− */
565− private fun observeOutbox() {
566− appScope.launch {
567− combine(activeConnection, isOnline, _outboxFlushTrigger) { conn, online, _ -> conn to online }
568− .collect { (conn, online) ->
569− if (conn == null || !online) return@collect
570− flushOutboxNow(conn)
571− }
572− }
573− }
574−
575− private suspend fun flushOutboxNow(conn: OpencodeConnection) {
576− val pending = outboxStore.messages.value
577− .filter { it.profileId == conn.profile.id }
578− .sortedBy { it.createdAt }
579− if (pending.isEmpty()) return
580− _outboxSending.value = true
581− try {
582− for (msg in pending) {
583− val result = runCatchingCancellable {
584− conn.repository.sendPrompt(
585− msg.sessionId,
586− msg.text,
587− attachments = msg.attachments.map {
588− FilePromptPart(mime = it.mime, url = it.url, filename = it.filename)
589− },
590− model = msg.model,
591− agent = msg.agent,
592− // Reuse the persistent outbox id as a stable idempotency key so a
593− // later flush of a message whose earlier POST reached the server (but
594− // whose response was lost) is deduplicated server-side instead of
595− // starting a duplicate agent run.
596− idempotencyKey = msg.id,
597− )
598− }.onFailure {
599− Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
600− }
601− if (result.isSuccess) {
602− outboxStore.remove(msg.id)
603− continue
604− }
605− // A permanently-undeliverable message (a non-408/429 4xx — e.g. the target
606− // session was deleted server-side, yielding a 404) must be dropped, not left at
607− // the head of the queue. The loop stops at the first failure to preserve send
608− // order, so a poison message would otherwise block every later queued message
609− // for this profile forever. Transient failures (offline again, 5xx, 408/429)
610− // keep the queue intact and retry on the next trigger.
611− // 401/403 (bad/expired credentials) are NOT permanent: the message itself is
612− // deliverable once the user fixes the profile. Dropping it would silently lose
613− // the user's composed offline prompt on a recoverable auth failure. They stay
614− // in the queue and retry on the next flush after auth is restored.
615− val status = result.exceptionOrNull()?.let { responseStatusCode(it) }
616− // Permanent = a non-408/429/401/403 4xx, matching withRetry's non-retryable rule
617− // except for auth failures, which are recoverable by re-editing the profile.
618− val permanent = when (status) {
619− null, 408, 429, 401, 403 -> false
620− in 400..499 -> true
621− else -> false
622− }
623− if (permanent) {
624− Log.w(
625− "AppContainer",
626− "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
627− )
628− outboxStore.remove(msg.id)
629− // Surface the dropped reply on the ERROR channel with a distinct title
630− // ("Message not delivered") so the user understands a queued reply
631− // couldn't be delivered — not that a run failed (which postError
632− // implies). Tap routes back to the originating profile's session.
633− val ctx = appContext
634− if (ctx != null) {
635− val title = resolveSessionTitle(msg.sessionId, conn.repository)
636− runCatchingCancellable {
637− SessionNotifications.postOutboxDropped(
638− ctx, msg.sessionId,
639− title,
640− conn.profile.id,
641− )
642− }.onFailure {
643− Log.w("AppContainer", "outbox drop notification failed: ${safeExceptionSummary(it)}")
644− }
645− }
646− } else {
647− break
648− }
649− }
650− } finally {
651− _outboxSending.value = false
652− }
653− }
654−
655− /** Release resources held for the process lifetime (network callback, app scope). */
656− open fun shutdown() {
657− // Cancel the app scope first so any coroutine holding the connection mutex
658− // (e.g. a connect() suspended on profileStore) is cancelled and releases the
659− // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
660− // that can't be cancelled until after the runBlocking completes.
661− appScope.cancel()
662− // Capture the active connection BEFORE the bounded close attempt so a timeout
663− // can still close it outside the mutex. Without this, the timeout path would
664− // null the StateFlow reference without closing the connection — leaking the SSE
665− // socket, OkHttp pool, and the connection's coroutine scope.
666− val stuck = _activeConnection.value
667− var closed = false
668− // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
669− // 2 seconds is generous: appScope.cancel() already triggered cancellation of
670− // any coroutine holding the mutex; we're just waiting for it to unwind.
671− kotlinx.coroutines.runBlocking {
672− withTimeoutOrNull(2_000) {
673− connectionMutex.withLock {
674− // runCatchingCancellable (not plain runCatching) per the repo convention:
675− // close() is a suspend function, and withTimeoutOrNull above can cancel
676− // this block mid-close — plain runCatching would swallow the
677− // CancellationException, leaving the connection half-closed and falsely
678− // reporting success via `closed = true`.
679− runCatchingCancellable { _activeConnection.value?.close() }
680− _activeConnection.value = null
681− closed = true
682− }
683− }
684− // If the timeout fired, force-clear the StateFlow so observers see no
685− // connection. The stuck connection (if any) is closed below, outside the
686− // mutex, so a slow close() can't ANR — its scope is cancelled and the JVM
687− // exit reaps any remaining sockets.
688− _activeConnection.value = null
689− }
690− if (!closed && stuck != null) {
691− // The timeout path fired (the locked close didn't complete). Cancel the
692− // stuck connection's scope directly. close() suspends (scopeJob.join), so
693− // wrap in a bounded runBlocking that can't ANR — at this point the app is
694− // shutting down, so reaping is best-effort.
695− // runCatchingCancellable: withTimeoutOrNull can cancel stuck.close() mid-flight,
696− // and plain runCatching would swallow the CancellationException — same invariant
697− // as the locked close above.
698− runCatchingCancellable {
699− kotlinx.coroutines.runBlocking {
700− kotlinx.coroutines.withTimeoutOrNull(1_000) { stuck.close() }
701− }
702− }
703− }
704− val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
705− networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
706− if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
707− if (!skipInit) draftStore.shutdown()
708− }
709−
710− /**
711− * Watch the SSE bus for new message activity and badge any session that isn't
712− * currently open. This powers the unread dot on the session list so the user can
713− * tell which conversations got a reply while they were elsewhere.
714− *
715− * Uses [flatMapLatest] so a server switch (which replaces the active connection)
716− * re-subscribes to the new event stream and drops the old one.
717− */
718− @OptIn(ExperimentalCoroutinesApi::class)
719− private fun observeMessageActivity() {
720− appScope.launch {
721− // Retry the observer if it fails for any reason (transient flow exception,
722− // cancellation due to scope issues). Without this, a single failure would
723− // permanently disable unread tracking and completion notifications.
724− while (isActive) {
725− runCatchingCancellable {
726− activeConnection
727− .flatMapLatest { conn ->
728− // Capture the originating connection so notification coroutines
729− // resolve titles and route permission responses against THIS
730− // server, not whichever happens to be active when they resume.
731− // A server switch during a listSessions() round-trip would
732− // otherwise resolve the title (or POST the permission reply)
733− // against the new server, where the session/permission doesn't
734− // exist — showing an opaque id or silently losing the reply.
735− conn?.events?.events?.map { conn to it } ?: emptyFlow()
736− }
737− .collect { (conn, event) ->
738− // A permission request needs the user's approval. Post a heads-up
739− // notification (with Allow/Reject actions) unless the user is actively
740− // viewing this session, where the in-app permission dialog handles it.
741− if (event is PermissionUpdated) {
742− val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
743− if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties, conn) }
744− return@collect
745− }
746− // The permission was answered (in-app, from the notification, or
747− // auto-resolved): clear its heads-up notification.
748− if (event is PermissionReplied) {
749− val psid = event.properties.sessionID?.takeIf { it.isNotBlank() } ?: return@collect
750− appContext?.let { SessionNotifications.cancelPermission(it, psid) }
751− return@collect
752− }
753− // A run failed. Clear its active-run tracking (an error isn't always
754− // followed by a SessionIdle) and notify unless it's being viewed.
755− // The notification is NOT gated on wasRunning: activeRuns is populated
756− // only by SSE streaming events (MessagePartUpdated/MessageUpdated), so a
757− // run that errors before any streaming started (e.g. the model is
758− // unavailable, the server fails to create the assistant message) never
759− // entered the set and wasRunning would be false — silently dropping the
760− // error notification for a backgrounded user. SessionError itself is the
761− // server's signal that a run failed, so notify on any error for a session
762− // the user isn't actively viewing.
763− if (event is SessionError) {
764− val esid = event.properties.sessionID ?: return@collect
765− synchronized(activeRuns) {
766− if (activeRuns.remove(esid)) publishRunState()
767− }
768− if (!isActivelyViewing(esid)) {
769− appScope.launch { notifySessionError(esid, conn.repository, conn.profile.id) }
770− }
771− return@collect
772− }
773− // SessionIdle is not "message activity" (don't badge as unread)
774− // but signals a run finished — fire a completion notification if the
775− // session isn't currently being viewed and was actively streaming.
776− if (event is SessionIdle) {
777− val idleSid = event.properties.sessionID ?: return@collect
778− // Clear the run state unconditionally — even for the session on
779− // screen. Gating the removal on `idleSid != currentSession` (as
780− // this once did) leaks the id in activeRuns and pins anyRunActive
781− // true forever whenever a run finishes while being viewed. Only
782− // the completion *notification* is suppressed for the viewed one.
783− val wasRunning = synchronized(activeRuns) {
784− activeRuns.remove(idleSid).also { removed ->
785− if (removed) publishRunState()
786− }
787− }
788− // Clear the progress text when the run finishes.
789− _runProgressText.value = null
790− if (wasRunning && !isActivelyViewing(idleSid)) {
791− // Fire this off the collector's coroutine: notifySessionCompleted
792− // does a network listSessions() to resolve the title, and blocking
793− // the shared-event collector here would stall the SharedFlow,
794− // overflowing its DROP_OLDEST buffer and silently dropping live
795− // parts/updates for every other subscriber (e.g. the message reducer).
796− appScope.launch { notifySessionCompleted(idleSid, conn.repository, conn.profile.id) }
797− }
798− return@collect
799− }
800− // Track the agent's task-plan progress (from todowrite tool calls) so the
801− // foreground-service notification can show "Step 2 of 5" instead of just a
802− // chronometer. The todowrite tool sends the full plan on every call, so the
803− // latest call's input is a complete snapshot.
804− if (event is MessagePartUpdated) {
805− val part = event.properties.part
806− if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
807− val todos = parseTodos(part.state.inputElement())
808− if (todos.isNotEmpty()) {
809− val completed = todos.count { it.statusEnum() == TodoStatus.COMPLETED }
810− _runProgressText.value = appContext?.getString(
811− soy.iko.opencode.R.string.notif_running_progress,
812− completed + 1,
813− todos.size,
814− )
815− }
816− }
817− }
818− val sid = sessionOf(event) ?: return@collect
819− // Re-read currentSession here, right before mutating the badge state:
820− // the user may have opened this session between when this event was
821− // enqueued and now (setCurrentSession clears its badge and dedup set).
822− // A stale earlier check would race a badge — and a dedup entry — back
823− // in for the session that's actually on screen.
824− if (sid != _currentSession.value) {
825− // Track the message id in the dedup set for *every* session (muted or
826− // not), so unmuting can't re-count messages that arrived while muted.
827− // The visible badge increment below is gated on !muted so a muted
828− // conversation doesn't interrupt, but the dedup tracking must not be
829− // — otherwise the first event after unmute for an already-seen message
830− // id would pass the counted.add() check (empty set) and re-increment.
831− val messageId = messageIdOfEvent(event)
832− val counted = unreadMessageIds.getOrPut(sid) {
833− java.util.Collections.synchronizedSet(mutableSetOf())
834− }
835− val isNewMessage = messageId == null || counted.add(messageId)
836− if (isNewMessage && sid !in _mutedSessions.value) {
837− // Skip the unread badge for muted sessions — the count is still
838− // tracked in unreadMessageIds (so unmuting can't re-count the
839− // same messages), but the visible badge is suppressed so a muted
840− // conversation doesn't interrupt. The completion notification is
841− // also gated on isSessionMuted (see notifySessionCompleted).
842− _unread.update { current ->
843− // Guard again inside the atomic update lambda so a retry
844− // (or an open that landed just now) can't reintroduce the
845− // badge for the session on screen.
846− if (sid == _currentSession.value) current
847− else current + (sid to (current[sid] ?: 0) + 1)
848− }
849− }
850− }
851− // Track sessions actively streaming so we know which idle events
852− // represent a finished run worth notifying about.
853− if (isLiveRunActivity(event)) {
854− // add() returns true only when the id is genuinely new. Only republish
855− // the run-state snapshot in that case: a continuously-streaming session
856− // re-adds its id on every token (hundreds/sec), and republishing each
857− // time allocates a fresh Set via toSet() + writes two StateFlows under
858− // the monitor — pure overhead since the set membership didn't change.
859− // The access-order refresh from LinkedHashSet.add still happens (keeping
860− // the session from being evicted by a reconnect sweep); we just skip the
861− // O(set size) snapshot + StateFlow writes when nothing changed.
862− synchronized(activeRuns) {
863− if (activeRuns.add(sid)) {
864− publishRunState()
865− }
866− }
867− }
868− } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
869− if (!isActive) break
870− delay(NetworkConfig.observerRetryDelayMs)
871− }
872− }
873− }
874−
875− /** True if [event] is live streaming activity worth tracking as an active run. A
876− * trailing message.updated that lands *after* the run finished (final cost/token totals,
877− * carrying a completion time) is NOT live streaming: with no further SessionIdle to
878− * follow it, re-adding the session would pin anyRunActive true indefinitely. */
879− private fun isLiveRunActivity(event: BusEvent): Boolean {
880− if (event !is MessagePartUpdated && event !is MessageUpdated) return false
881− // For MessageUpdated, only an in-flight *assistant* message counts as live activity.
882− // A UserMessage or UnknownMessage update (e.g. a metadata refresh of an existing user
883− // message) would otherwise add the session to activeRuns with no following SessionIdle
884− // to clear it — pinning anyRunActive true and the foreground notification indefinitely.
885− // Mirrors SessionRepository.isRunActivity's guard exactly.
886− if (event is MessageUpdated) {
887− val info = event.properties.info ?: return false
888− return info is AssistantMessage && !info.isComplete && info.error == null
889− }
890− // A step-finish part is the trailing completion marker for a run (it carries the
891− // final cost/token totals) and arrives after the last stream delta. Like a completed
892− // message.updated, treating it as live would re-add the session to activeRuns with no
893− // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
894− // a part is replayed/reordered after the run already went idle.
895− val part = when (event) {
896− is MessagePartUpdated -> event.properties.part
897− else -> return false
898− }
899− return part !is StepFinishPart
900− }
901−
902− /** Snapshot [activeRuns] into [_runningSessionIds] and derive [_anyRunActive] from it.
903− * MUST be called under the [activeRuns] monitor so the published flag and snapshot can't
904− * diverge from the set a concurrent SSE add()/remove() is mutating — the same invariant
905− * [connectLocked]/[teardownActiveLocked] rely on. */
906− private fun publishRunState() {
907− _anyRunActive.value = activeRuns.isNotEmpty()
908− _runningSessionIds.value = activeRuns.toSet()
909− }
910−
911− /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
912− * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
913− * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
914− * NOT reorder). Entries are drained by [SessionIdle] (and cleared wholesale on reconnect
915− * reconcile); we intentionally do NOT cap the set, because evicting the eldest would drop
916− * its completion notification when its SessionIdle eventually arrives — a busy server
917− * with many concurrent runs is still bounded by the server's own concurrency, so growth
918− * is not unbounded in practice. */
919− private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
920− java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
921− )
922−
923− /** Per non-viewed session, the set of message ids already reflected in its unread
924− * count. De-duplicates the many streaming events that share one message id so the
925− * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
926− private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
927−
928− /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
929− private val connectionMutex = Mutex()
930−
931− /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
932− * a DataStore + encrypted-prefs write on every rapid reconnect. */
933− private companion object {
934− const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
935− }
936−
937− /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id.
938− * Uses [originRepo] — the repository of the connection that emitted the event — so a
939− * server switch during the listSessions() round-trip doesn't resolve the title against
940− * an unrelated server (showing an opaque id, or worse, a colliding session's title). */
941− private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
942− runCatchingCancellable {
943− originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
944− }.getOrNull() ?: sessionId
945−
946− /** Resolve the title for [sessionId] (best-effort) and post a completion notification.
947− * [originProfileId] is the id of the server that ran the session; it's embedded in the
948− * inline-reply action so a follow-up routes back to THIS server even after the user switches
949− * connections (mirrors [notifyPermission]). */
950− private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
951− val title = resolveSessionTitle(sessionId, originRepo)
952− // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
953− // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
954− // If the user opened the session in that gap, setCurrentSession already cancelled the
955− // prior notification — posting a fresh one here would leave a "session ready" heads-up
956− // lingering while they're already looking at the finished run.
957− if (isActivelyViewing(sessionId)) return
958− // Suppress the completion notification for muted sessions — the whole point of muting
959− // is that a noisy conversation doesn't interrupt.
960− if (sessionId in _mutedSessions.value) return
961− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
962− }
963−
964− /** Post a heads-up permission notification for [permission], resolving its session title.
965− * [originConn] is the connection that emitted the permission event — its profile id is
966− * embedded in the notification so the receiver routes the response back to THIS server. */
967− private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
968− val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
969− val title = resolveSessionTitle(sessionId, originConn.repository)
970− // Re-check viewing just before posting — see notifySessionCompleted for the race.
971− if (isActivelyViewing(sessionId)) return
972− appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
973− }
974−
975− /** Post an error notification for a failed background run. [originProfileId] is embedded
976− * in the tap intent so the session opens under the server that ran it, not whichever
977− * is active when the user taps — mirroring [notifySessionCompleted]. */
978− private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
979− val title = resolveSessionTitle(sessionId, originRepo)
980− // Re-check viewing just before posting — see notifySessionCompleted for the race.
981− if (isActivelyViewing(sessionId)) return
982− appContext?.let { SessionNotifications.postError(it, sessionId, title, originProfileId) }
983− }
984−
985− /** Respond to a permission from a notification action (Allow once / Always / Reject),
986− * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
987− * so the receiver's goAsync() result can finish. Its Boolean is true only when a live
988− * connection existed AND respondPermission succeeded — the receiver uses it to decide
989− * whether to dismiss the notification. A missing connection makes the respond a no-op,
990− * so it must report failure (not success) rather than let the notification vanish with
991− * the tool left unanswered.
992− *
993− * [profileId] is the id of the server that posted the permission request (embedded in
994− * the notification's PendingIntent). If it doesn't match the currently active
995− * connection's profile, the response is NOT sent: posting it to a different server
996− * would 404 (treated as "already resolved" success by respondPermission), dismissing
997− * the notification while the original server's tool stays paused forever. Reporting
998− * failure instead leaves the notification up so the user can switch back and retry. */
999− open fun respondToPermissionFromNotification(
1000− sessionId: String,
1001− permissionId: String,
1002− response: PermissionResponse,
1003− profileId: String?,
1004− onDone: (Boolean) -> Unit,
1005− ) {
1006− appScope.launch {
1007− // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
1008− // scope is cancelled mid-call (process shutdown cancels appScope first). Without
1009− // this, a cancelled scope leaves pending.finish() never called → a receiver ANR
1010− // and the permission response silently lost.
1011− var success = false
1012− try {
1013− success = runCatchingCancellable {
1014− val conn = activeConnection.value ?: return@runCatchingCancellable false
1015− // Route to the originating server only. A mismatch means the user
1016− // switched servers while the permission was pending; sending the reply
1017− // to the wrong server would be silently swallowed as a 404-success.
1018− if (profileId != null && conn.profile.id != profileId) return@runCatchingCancellable false
1019− conn.api.respondPermission(sessionId, permissionId, response)
1020− true
1021− }.onFailure {
1022− Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
1023− }.getOrDefault(false)
1024− } finally {
1025− onDone(success)
1026− }
1027− }
1028− }
1029−
1030− /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
1031− * server's default model/agent. Routes through the durable [outboxStore] — the same path
1032− * the in-app composer uses ([soy.iko.opencode.ui.chat.ChatViewModel.enqueueOffline]) —
1033− * rather than a direct repository send, so a reply composed while disconnected survives to
1034− * be flushed on reconnect instead of being silently dropped. [flushOutbox] nudges an
1035− * immediate send when a connection is live, so an online reply still goes out now. [onDone]
1036− * reports whether the reply was durably enqueued so the receiver only dismisses the
1037− * notification on success. */
1038− open fun sendPromptFromNotification(sessionId: String, text: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
1039− appScope.launch {
1040− // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
1041− // scope is cancelled mid-call — see respondToPermissionFromNotification above.
1042− var enqueued = false
1043− try {
1044− // Attribute to the profile that POSTED the notification (embedded in the reply
1045− // intent) so the follow-up is always enqueued against the server that owns
1046− // [sessionId] — never whichever server happens to be active now, which would POST
1047− // A's session id to B, 404, and silently drop the reply. Fall back to the active/
1048− // most-recent profile only for a legacy intent that predates the embedded id.
1049− val profileId = originProfileId
1050− ?: activeConnection.value?.profile?.id
1051− ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1052− enqueued = if (profileId == null) {
1053− false
1054− } else {
1055− runCatchingCancellable {
1056− outboxStore.enqueue(
1057− OutboxMessage(
1058− id = java.util.UUID.randomUUID().toString(),
1059− profileId = profileId,
1060− sessionId = sessionId,
1061− text = text,
1062− model = null,
1063− createdAt = System.currentTimeMillis(),
1064− ),
1065− )
1066− }.onFailure {
1067− Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
1068− }.isSuccess
1069− }
1070− if (enqueued) flushOutbox()
1071− } finally {
1072− onDone(enqueued)
1073− }
1074− }
1075− }
1076−
1077− /** Abort the active run for [sessionId] from a notification Stop action. Routes to the
1078− * originating profile only — sending the abort to a different server would 404 (silently
1079− * swallowed) and leave the original run stranded. A profile mismatch reports failure so the
1080− * notification stays up and the user can switch back, mirroring
1081− * [respondToPermissionFromNotification]'s routing guard. Runs on the app scope so it
1082− * survives the BroadcastReceiver's lifetime. */
1083− open fun abortRunFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
1084− appScope.launch {
1085− var success = false
1086− try {
1087− val conn = activeConnection.value?.takeIf {
1088− originProfileId == null || it.profile.id == originProfileId
1089− }
1090− if (conn != null) {
1091− success = runCatchingCancellable { conn.repository.abort(sessionId) }.isSuccess
1092− }
1093− } finally {
1094− onDone(success)
1095− }
1096− }
1097− }
1098−
1099− /**
1100− * Retry the last user prompt for [sessionId] from an error notification's "Retry last"
1101− * action. Fetches the session's messages, re-sends the most recent user prompt, and
1102− * cancels the error notification on success. Routes to the originating profile's
1103− * connection only — sending the retry to a different server would 404 or start a fresh
1104− * run on an unrelated session id. A profile mismatch reports failure so the notification
1105− * stays up and the user can switch back, mirroring [respondToPermissionFromNotification]'s
1106− * routing guard. A no-op (reported as false) when there's no connection or no prior prompt.
1107− */
1108− open fun retryLastFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
1109− appScope.launch {
1110− var success = false
1111− try {
1112− val conn = activeConnection.value?.takeIf {
1113− originProfileId == null || it.profile.id == originProfileId
1114− }
1115− if (conn != null) {
1116− success = runCatchingCancellable {
1117− val messages = conn.api.listMessages(sessionId)
1118− val lastPrompt = messages
1119− .lastOrNull { it.info is soy.iko.opencode.data.model.UserMessage }
1120− ?.parts
1121− ?.filterIsInstance<soy.iko.opencode.data.model.TextPart>()
1122− ?.joinToString("\n\n") { it.text }
1123− ?.takeIf { it.isNotBlank() }
1124− if (lastPrompt != null) {
1125− // model=null lets the server reuse the session's current model; we
1126− // don't know the originally-selected model from a notification context.
1127− conn.repository.sendPrompt(sessionId, lastPrompt, model = null)
1128− true
1129− } else {
1130− false
1131− }
1132− }.isSuccess
1133− }
1134− } finally {
1135− onDone(success)
1136− }
1137− }
1138− }
1139−
1140− /** Extract the session id an event pertains to, for message-activity events. */
1141− private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
1142−
1143− /**
1144− * On cold start, transparently reconnect to the most recently used server so a
1145− * returning user lands in their session list instead of the empty server screen.
1146− */
1147− private suspend fun autoConnect() {
1148− val recent = runCatchingCancellable { profileStore.profiles.first() }
1149− .getOrDefault(emptyList())
1150− .firstOrNull() ?: return
1151− // The profile read above suspends on DataStore. A manual connect() to a different
1152− // server during that window establishes a connection the user explicitly chose; we must
1153− // not clobber it by connecting to `recent` on top. connectIfIdle() checks "is anything
1154− // active?" and connects atomically under connectionMutex, returning null if the cold-start
1155− // slot was already taken — closing the TOCTOU window an unlocked null-check plus connect()'s
1156− // unconditional close would leave. (disconnectIf below similarly guards the failed-ping
1157− // teardown by identity.)
1158− _reconnecting.value = true
1159− var conn: OpencodeConnection? = null
1160− try {
1161− val ok = runCatchingCancellable {
1162− conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
1163− conn!!.api.ping()
1164− true
1165− }.getOrDefault(false)
1166− when {
1167− conn == null -> Unit // slot already taken by a manual connect — leave it be
1168− ok -> _autoConnectDone.value = true
1169− else -> disconnectIf(conn!!)
1170− }
1171− } finally {
1172− // Reset in a finally so a CancellationException (e.g. scope cancelled during
1173− // shutdown) can't leave the flag pinned true — runCatchingCancellable rethrows it,
1174− // skipping the line below the try block.
1175− _reconnecting.value = false
1176− }
1177− }
1178−
1179− /**
1180− * When the device regains connectivity, nudge the active SSE stream to reconnect
1181− * immediately instead of waiting out the exponential backoff.
1182− */
1183− private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
1184−
1185− private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
1186− if (appContext == null) return null
1187− val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
1188− ?: return null
1189− // Seed the online state from the current active network so the indicator is
1190− // correct on cold start rather than defaulting to "online" until a callback fires.
1191− _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
1192− val callback = object : ConnectivityManager.NetworkCallback() {
1193− override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
1194− // A matched (internet-capable) network was lost. Don't eagerly flip offline: during a
1195− // Wi-Fi→cellular handoff onLost(WiFi) fires before onAvailable(cellular) and
1196− // cm.activeNetwork is transiently null in that window — flashing offline there would
1197− // skip outbox flushing and blink the indicator. Instead re-check activeNetwork after a
1198− // short grace period and only report offline if there's still no active network.
1199− //
1200− // This MUST live in onLost, not onUnavailable: onUnavailable is delivered only for
1201− // requestNetwork() with a timeout, never for registerNetworkCallback() — relying on it
1202− // (as this once did) meant offline was never detected after the first onAvailable.
1203− override fun onLost(network: Network) {
1204− appScope.launch {
1205− delay(NetworkConfig.networkOfflineGraceMs)
1206− _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
1207− }
1208− }
1209− }
1210− // Only match networks that actually provide internet — a capability-less request
1211− // fires onAvailable for transports that can't reach the server (and would trigger
1212− // spurious reconnects), so require NET_CAPABILITY_INTERNET.
1213− val request = NetworkRequest.Builder()
1214− .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1215− .build()
1216− runCatching { cm.registerNetworkCallback(request, callback) }
1217− .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
1218− return callback
1219− }
1220−
1221− /** Open (or re-open) a connection to [profile], replacing any current one.
1222− * Only persists the updated `lastUsed` timestamp if it's stale (older than a
1223− * minute), so rapid reconnect storms don't each trigger a DataStore write. */
1224− open suspend fun connect(profile: ServerProfile): OpencodeConnection =
1225− connectionMutex.withLock { connectLocked(profile) }
1226−
1227− /** Connect to the profile with [profileId], looking it up from [profileStore]. Returns
1228− * true on success, false if the profile wasn't found or connect threw. Used by the
1229− * notification body-tap path to route a session open back to the originating server. */
1230− open suspend fun connectByProfileId(profileId: String): Boolean {
1231− val profile = profileStore.profiles.first().find { it.id == profileId } ?: return false
1232− return runCatchingCancellable { connect(profile) }.isSuccess
1233− }
1234−
1235− /** Connect to [profile] only if nothing is currently active, checking-and-connecting
1236− * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
1237− * already active, so autoConnect() can't clobber a manual connect() the user made during its
1238− * suspending profile read — closing the TOCTOU window an unlocked null-check would leave. */
1239− private suspend fun connectIfIdle(profile: ServerProfile): OpencodeConnection? =
1240− connectionMutex.withLock {
1241− if (_activeConnection.value != null) null else connectLocked(profile)
1242− }
1243−
1244− /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
1245− * [connectionMutex]. */
1246− private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
1247− // Capture the previous profile id before closing, so we can preserve unread badges
1248− // when reconnecting to the same server — a brief network blip or manual reconnect
1249− // shouldn't erase "5 unread". Only a genuine server switch clears them.
1250− val previousProfileId = _activeConnection.value?.profile?.id
1251− _activeConnection.value?.close()
1252− _activeConnection.value = null
1253− // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
1254− // event handler adds ids under the same lock, so an unlocked clear() here could
1255− // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
1256− // down connection and no SessionIdle to clear it — a stuck foreground service.
1257− synchronized(activeRuns) {
1258− activeRuns.clear()
1259− publishRunState()
1260− // Clear the progress text too: with no SSE stream there's no SessionIdle to drop
1261− // it (line ~789 does it on idle), so without this a mid-run server switch would
1262− // leave "Step 2 of 5" exposed in the public StateFlow with no run actually active.
1263− _runProgressText.value = null
1264− }
1265− if (previousProfileId != profile.id) {
1266− _unread.update { emptyMap() }
1267− unreadMessageIds.clear()
1268− }
1269− val now = System.currentTimeMillis()
1270− val resolved = profileStore.resolve(profile)
1271− val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
1272− val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
1273− if (needsSave) profileStore.save(finalProfile)
1274− return OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
1275− }
1276−
1277− open suspend fun disconnect() {
1278− // Acquire the connection mutex so disconnect is serialized with connect().
1279− // Previously this was fire-and-forget (launch on appScope), which raced with
1280− // a subsequent connect(): connect() could acquire the mutex, create a new
1281− // connection, and then the pending disconnect() would close it.
1282− connectionMutex.withLock { teardownActiveLocked() }
1283− }
1284−
1285− /** Tear down [expected] only if it is still the active connection, with the identity
1286− * check performed *under* the mutex. A caller that checks `activeConnection.value === x`
1287− * itself and then calls [disconnect] has a TOCTOU race: a concurrent connect() to a
1288− * different server can replace the active connection in the gap, and [disconnect] would
1289− * then close that new connection. autoConnect()'s failed-ping teardown uses this. */
1290− private suspend fun disconnectIf(expected: OpencodeConnection) {
1291− connectionMutex.withLock {
1292− if (_activeConnection.value === expected) teardownActiveLocked()
1293− }
1294− }
1295−
1296− /** Close and reset the active connection. Caller MUST hold [connectionMutex]. */
1297− private suspend fun teardownActiveLocked() {
1298− _activeConnection.value?.close()
1299− _activeConnection.value = null
1300− // Reset run/unread state on disconnect too. With no SSE stream there's no
1301− // SessionIdle to drain activeRuns, so without this the working indicator
1302− // (anyRunActive) and unread badges would stay pinned until the next connect().
1303− // Mutate under activeRuns' monitor (see connect()) so a concurrent SSE add() can't
1304− // re-pin _anyRunActive after the clear.
1305− synchronized(activeRuns) {
1306− activeRuns.clear()
1307− publishRunState()
1308− // Clear the progress text too: with no SSE stream there's no SessionIdle to drop
1309− // it (line ~789 does it on idle), so without this a disconnect mid-run would leave
1310− // "Step 2 of 5" exposed in the public StateFlow with no run actually active.
1311− _runProgressText.value = null
1312− }
1313− _unread.update { emptyMap() }
1314− unreadMessageIds.clear()
1315− }
1316−
1317− /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
1318− * ViewModel scope) so navigating away during the undo window still commits the delete
1319− * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
1320− private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1321−
1322− /**
1323− * Schedule the profile [id] for deletion after [delayMs], cancellable via
1324− * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
1325− * after the delete resolves so the caller can clear its optimistic-hide state. A
1326− * repeated schedule for the same id replaces the prior timer.
1327− */
1328− open fun scheduleProfileDelete(
1329− id: String,
1330− delayMs: Long,
1331− onDeleted: () -> Unit,
1332− onError: (Throwable) -> Unit,
1333− ) {
1334− val job = appScope.launch {
1335− delay(delayMs)
1336− // Claim the delete by removing our own entry BEFORE committing it — the atomic
1337− // remove is the sync point with cancelProfileDelete. Losing the race (an undo
1338− // removed us first) means we skip, honouring cancel's `true`; deleting first
1339− // would let a cancel land in the gap and falsely report undo of a gone profile.
1340− // remove(id, thisJob) also no-ops for a stale job after a reschedule.
1341− val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
1342− if (!claimed) return@launch
1343− runCatchingCancellable { profileStore.delete(id) }
1344− .onSuccess { onDeleted() }
1345− .onFailure { onError(it) }
1346− }
1347− pendingProfileDeletes[id]?.cancel()
1348− pendingProfileDeletes[id] = job
1349− }
1350−
1351− /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
1352− * still pending (undo succeeded), false if the delete had already fired. */
1353− open fun cancelProfileDelete(id: String): Boolean {
1354− val job = pendingProfileDeletes.remove(id) ?: return false
1355− job.cancel()
1356− return true
1357− }
1358−
1359− /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
1360− * ViewModel scope) so navigating away during the undo window still commits the delete
1361− * instead of silently cancelling it (which would leave the session deleted-in-UI but
1362− * alive on the server, reappearing on the next refresh). */
1363− private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1364−
1365− /** One-shot events carrying the id of a session scheduled for deferred deletion from
1366− * outside the session list (e.g. from the chat screen's Delete). The SessionListScreen
1367− * collects these to show an Undo snackbar, mirroring its own VM-level undo events. */
1368− private val _externalSessionUndoEvents = MutableSharedFlow<String>(
1369− extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
1370− onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
1371− )
1372− val externalSessionUndoEvents: SharedFlow<String> = _externalSessionUndoEvents.asSharedFlow()
1373−
1374− /** Emit an undo event for a session delete initiated outside the session list (e.g.
1375− * from the chat screen). The SessionListScreen collects these to show an Undo snackbar. */
1376− fun emitExternalSessionUndo(sessionId: String) {
1377− _externalSessionUndoEvents.tryEmit(sessionId)
1378− }
1379−
1380− /**
1381− * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
1382− * (the Undo action). Also clears the session's stored draft on a successful delete.
1383− * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
1384− * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
1385− *
1386− * The owning connection is captured now, at schedule time — NOT re-resolved when the timer
1387− * fires. Otherwise a server switch during the undo window would send the delete to whichever
1388− * server happens to be active at fire time: the original session would never be deleted (its
1389− * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
1390− * on the new server could be deleted instead. A disconnect closes the captured connection's
1391− * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
1392− * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
1393− * error) if there is no active connection at schedule time.
1394− */
1395− open fun scheduleSessionDelete(
1396− id: String,
1397− delayMs: Long,
1398− onDeleted: () -> Unit,
1399− onError: (Throwable) -> Unit,
1400− ) {
1401− val conn = _activeConnection.value
1402− val job = appScope.launch {
1403− delay(delayMs)
1404− // Claim the delete by removing our own entry BEFORE committing it — the atomic
1405− // remove is the sync point with cancelSessionDelete. Losing the race (an undo
1406− // removed us first) means we skip, honouring cancel's `true`; deleting first
1407− // would let a cancel land in the gap and falsely report undo of a gone session.
1408− // remove(id, thisJob) also no-ops for a stale job after a reschedule.
1409− val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
1410− if (!claimed) return@launch
1411− if (conn == null) {
1412− onError(IllegalStateException("No active connection"))
1413− } else {
1414− runCatchingCancellable { conn.repository.deleteSession(id) }
1415− .onSuccess {
1416− draftStore.remove(id)
1417− attachmentDraftStore.remove(id)
1418− sessionPrefsStore.forget(id)
1419− messageCacheStore.remove(conn.profile.id, id)
1420− outboxStore.removeForSession(id)
1421− clearUnread(id)
1422− onDeleted()
1423− }
1424− .onFailure { onError(it) }
1425− }
1426− }
1427− pendingSessionDeletes[id]?.cancel()
1428− pendingSessionDeletes[id] = job
1429− }
1430−
1431− /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
1432− * still pending (undo succeeded), false if the delete had already fired. */
1433− open fun cancelSessionDelete(id: String): Boolean {
1434− val job = pendingSessionDeletes.remove(id) ?: return false
1435− job.cancel()
1436− return true
1437− }
1438−
1439− /**
1440− * Probe a server URL (without credentials) to check reachability and detect whether
1441− * the server requires authentication. Builds a short-lived HTTP client with the same
1442− * base URL normalization as [connect] but no auth, calls the health endpoint, and
1443− * classifies the outcome:
1444− *
1445− * - 2xx → [ProbeResult.Reachable] (no auth needed)
1446− * - 401/403 → [ProbeResult.NeedsAuth]
1447− * - anything else → [ProbeResult.Unreachable] with a user-facing message
1448− *
1449− * The probe client is always closed afterwards so no resources linger. This does not
1450− * touch the active connection or the profile store.
1451− */
1452− open suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
1453− return probeWithProfile(ServerProfile(
1454− id = "probe",
1455− label = "",
1456− baseUrl = baseUrl.trim(),
1457− username = null,
1458− password = null,
1459− // Probe the same effective URL save()/connect() will use: HttpClientFactory upgrades
1460− // http->https and installs the cert pinner when these are set, so a probe without them
1461− // would validate a plain-http endpoint the real connection never talks to.
1462− requireHttps = requireHttps,
1463− certPin = certPin,
1464− ))
1465− }
1466−
1467− /**
1468− * Probe a server URL *with* credentials to validate them. Returns true if the
1469− * server accepts the credentials (ping succeeds), false on 401/403, and throws
1470− * on other failures (unreachable, timeout) so the caller can surface a friendly
1471− * message. Mirrors [probeServer]'s short-lived-client pattern.
1472− */
1473− open suspend fun probeWithCredentials(
1474− baseUrl: String,
1475− username: String,
1476− password: String,
1477− requireHttps: Boolean,
1478− certPin: String?,
1479− ): Boolean {
1480− val probeProfile = ServerProfile(
1481− id = "probe-auth",
1482− label = "",
1483− baseUrl = baseUrl.trim(),
1484− username = username.trim().takeIf { it.isNotBlank() },
1485− password = password.trim().takeIf { it.isNotEmpty() },
1486− // Match the effective URL/pinning save()/connect() will use (see probeServer).
1487− requireHttps = requireHttps,
1488− certPin = certPin,
1489− )
1490− val client = HttpClientFactory.create(probeProfile)
1491− return try {
1492− val api = OpencodeApiClient(client)
1493− api.ping()
1494− true
1495− } catch (c: kotlinx.coroutines.CancellationException) {
1496− throw c
1497− } catch (e: Exception) {
1498− val status = responseStatusCode(e)
1499− if (status == 401 || status == 403) false else throw e
1500− } finally {
1501− // runCatchingCancellable (not plain runCatching): this is a suspend finally, and
1502− // plain runCatching swallows CancellationException — if the caller's scope is
1503− // cancelled while client.close() is in flight, the cancellation must propagate.
1504− runCatchingCancellable { client.close() }
1505− }
1506− }
1507−
1508− private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
1509− val client = HttpClientFactory.create(profile)
1510− return try {
1511− val api = OpencodeApiClient(client)
1512− api.ping()
1513− ProbeResult.Reachable
1514− } catch (c: kotlinx.coroutines.CancellationException) {
1515− throw c
1516− } catch (e: Exception) {
1517− val status = responseStatusCode(e)
1518− if (status == 401 || status == 403) {
1519− ProbeResult.NeedsAuth
1520− } else {
1521− ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
1522− }
1523− } finally {
1524− // runCatchingCancellable: see probeWithCredentials for the rationale.
1525− runCatchingCancellable { client.close() }
1526− }
1527− }
1528−
1529− // Declared last so every property above is fully initialized before these observers start.
1530− // They only launch coroutines into appScope (Dispatchers.IO) — nothing here runs
1531− // synchronously during construction — so placing init at the end removes any dependence on
1532− // the textual declaration order of the fields those observers reference.
1533− init {
1534− if (!skipInit) {
1535− NotificationChannels.create(appContext!!)
1536− observeMessageActivity()
1537− observeRunReconcileOnReconnect()
1538− observeRunForegroundService()
1539− observeBackgroundedConnectionFailures()
1540− // Load the persisted muted-sessions set so isSessionMuted() reflects the choice
1541− // immediately on cold start (before any session list composition).
1542− appScope.launch {
1543− runCatchingCancellable {
1544− sessionPrefsStore.muted.collect { ids -> _mutedSessions.update { ids } }
1545− }
1546− }
1547− // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
1548− // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1549− // if connect() sets activeConnection before load() finishes reading a (possibly
1550− // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
1551− // re-triggers it. Nudging the flush here guarantees a loaded queue is sent. Log a
1552− // load failure so a corrupt outbox.json / IO error is debuggable rather than silently
1553− // dropping every queued offline message until a future restart happens to succeed.
1554− appScope.launch {
1555− runCatchingCancellable { outboxStore.load() }
1556− .onFailure { Log.w("AppContainer", "Outbox load failed; queued messages may be lost: ${safeExceptionSummary(it)}") }
1557− flushOutbox()
1558− }
1559− observeOutbox()
1560− appScope.launch { autoConnect() }
1561− }
1562− }
1563−}
1564−
1565−/**
1566− * Extract the session id a message-activity event pertains to, or null for events that
1567− * don't describe message activity. This drives the unread badge: any session that
1568− * receives activity while it isn't the currently-viewed one gets badged. Extracted as
1569− * a top-level `internal` function so the rule is unit-testable without an Android
1570− * [Context] (the surrounding [AppContainer] needs one).
1571− */
1572−internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
1573− is MessageUpdated -> event.properties.info?.sessionID
1574− is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
1575− else -> null
1576−}
1577−
1578−/**
1579− * The message id a message-activity event pertains to, used to de-duplicate the unread
1580− * badge so a reply's many streaming events count once. Null for events that carry no
1581− * message id (those fall back to being counted individually).
1582− */
1583−internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
1584− is MessageUpdated -> event.properties.info?.id
1585− is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
1586− else -> null
1587−}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktdeleted−52
@@ -1,52 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import soy.iko.opencode.data.network.EventStreamClient
5−import soy.iko.opencode.data.network.HttpClientFactory
6−import soy.iko.opencode.data.network.OpencodeApiClient
7−import soy.iko.opencode.data.repo.MessageCacheStore
8−import soy.iko.opencode.data.repo.SessionRepository
9−import kotlinx.coroutines.CoroutineScope
10−import kotlinx.coroutines.Dispatchers
11−import kotlinx.coroutines.SupervisorJob
12−import kotlinx.coroutines.cancel
13−import soy.iko.opencode.util.runCatchingCancellable
14−
15−/**
16− * An active connection to one opencode server. Bundles the Ktor client, REST API,
17− * SSE event stream, and the reducing repository, all sharing a connection-scoped
18− * coroutine scope that is cancelled on [close].
19− */
20−open class OpencodeConnection(
21− val profile: ServerProfile,
22− // Optional on-disk message cache, supplied by AppContainer, so conversations render
23− // instantly and stay readable offline. Null in the test constructor.
24− private val messageCache: MessageCacheStore? = null,
25−) {
26−
27− protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"), null)
28−
29− private val scopeJob = SupervisorJob()
30− private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
31− private val client by lazy { HttpClientFactory.create(profile) }
32−
33− open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
34− open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
35− open val repository: SessionRepository by lazy { SessionRepository(api, events, messageCache, profile.id) }
36−
37− /**
38− * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
39− * to wind down before closing the HTTP client. Calling client.close() while the SSE
40− * coroutine is still mid-request produces a spurious "stream error, will retry"
41− * warning during normal teardown.
42− */
43− open suspend fun close() {
44− scope.cancel()
45− scopeJob.join()
46− // runCatchingCancellable (not plain runCatching) per the repo convention: close() is a
47− // suspend function, and plain runCatching swallows CancellationException — if the
48− // caller's scope is cancelled mid-close, the cancellation must propagate rather than
49− // be eaten as a Result.failure (which would leave the client half-closed).
50− runCatchingCancellable { client.close() }
51− }
52−}
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.ktdeleted−195
@@ -1,195 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.content.BroadcastReceiver
4−import android.content.Context
5−import android.content.Intent
6−import androidx.core.app.RemoteInput
7−import soy.iko.opencode.OpencodeApp
8−import soy.iko.opencode.R
9−import soy.iko.opencode.data.model.PermissionResponse
10−import soy.iko.opencode.data.network.NetworkConfig
11−import java.util.concurrent.atomic.AtomicBoolean
12−
13−/**
14− * Handles the action buttons on the app's notifications so the user can respond without
15− * opening the app — the core of the "kick it off and walk away" flow:
16− *
17− * - [ACTION_PERMISSION] with a [PermissionResponse] → replies to a paused tool's permission
18− * request (Allow once / Always / Reject) via the active connection.
19− * - [ACTION_REPLY] with a [RemoteInput] text payload → sends a follow-up prompt to a session
20− * that just finished a run.
21− *
22− * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
23− * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
24− * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
25− *
26− * A watchdog finishes the [PendingResult] within [NetworkConfig.notificationReceiverTimeoutMs]
27− * so a slow network call (e.g. a permission respond retried with exponential backoff) can't
28− * ANR the receiver. The underlying call keeps running on the app scope; only the receiver's
29− * lifetime is bounded. [finishOnce] guards against a double finish() (which would throw).
30− */
31−class NotificationActionReceiver : BroadcastReceiver() {
32−
33− override fun onReceive(context: Context, intent: Intent) {
34− val container = (context.applicationContext as? OpencodeApp)?.container ?: return
35− if (intent.action == ACTION_STOP_RUN) {
36− handleStopRun(context, container, intent)
37− return
38− }
39− val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
40− when (intent.action) {
41− ACTION_MARK_READ -> {
42− // "Mark read" from a completion notification — clear the unread badge and
43− // dismiss the notification without opening the session.
44− container.markSessionRead(sessionId)
45− SessionNotifications.cancel(context, sessionId)
46− return
47− }
48− ACTION_PERMISSION -> {
49− val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
50− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
51− val response = PermissionResponse.entries.firstOrNull {
52− it.wire == intent.getStringExtra(EXTRA_RESPONSE)
53− } ?: return
54− val pending = goAsync()
55− val finished = AtomicBoolean(false)
56− var watchdogThread: Thread? = null
57− val finishOnce = {
58− if (finished.compareAndSet(false, true)) {
59− watchdogThread?.interrupt()
60− pending.finish()
61− }
62− }
63− watchdogThread = watchdog(finishOnce)
64− container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
65− // Only dismiss the notification once the tool was actually answered. If
66− // there was no live connection (respond is a no-op) or the call failed,
67− // leave it up so the user can retry — otherwise it vanishes with the
68− // permission request left unanswered.
69− if (success) SessionNotifications.cancelPermission(context, sessionId)
70− finishOnce()
71− }
72− }
73− ACTION_REPLY -> {
74− val text = RemoteInput.getResultsFromIntent(intent)
75− ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
76− ?.takeIf { it.isNotEmpty() }
77− if (text == null) {
78− // The RemoteInput fired but the reply was empty/whitespace. SystemUI locks the
79− // inline field in a "sending…" state until the notification is updated or
80− // cancelled — cancel it so the field doesn't stay stuck with nothing to send.
81− SessionNotifications.cancel(context, sessionId)
82− return
83− }
84− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
85− val pending = goAsync()
86− val finished = AtomicBoolean(false)
87− var watchdogThread: Thread? = null
88− val finishOnce = {
89− if (finished.compareAndSet(false, true)) {
90− watchdogThread?.interrupt()
91− pending.finish()
92− }
93− }
94− watchdogThread = watchdog(finishOnce)
95− container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
96− // The reply is durably queued (and flushed now if online). Show a brief
97− // "Sent" confirmation by updating the completion notification's text, then
98− // let it auto-cancel on tap — instead of vanishing silently. If the enqueue
99− // failed, post a "reply not sent" error: leaving the completion notification
100− // up strands SystemUI's RemoteInput field in a permanent "sending…" spinner
101− // that can't be resubmitted, so "leave it up to retry" doesn't actually let
102− // the user retry.
103− if (enqueued) {
104− SessionNotifications.postReplySent(context, sessionId, profileId)
105− } else {
106− SessionNotifications.postReplyFailed(context, sessionId, profileId)
107− }
108− finishOnce()
109− }
110− }
111− ACTION_RETRY_LAST -> {
112− // "Retry last" from an error notification: re-send the most recent user prompt
113− // for this session without opening the app. Cancels the error notification on
114− // success so the user sees the retry took effect.
115− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
116− val pending = goAsync()
117− val finished = AtomicBoolean(false)
118− var watchdogThread: Thread? = null
119− val finishOnce = {
120− if (finished.compareAndSet(false, true)) {
121− watchdogThread?.interrupt()
122− pending.finish()
123− }
124− }
125− watchdogThread = watchdog(finishOnce)
126− container.retryLastFromNotification(sessionId, profileId) { success ->
127− if (success) SessionNotifications.cancel(context, sessionId)
128− finishOnce()
129− }
130− }
131− }
132− }
133−
134− /** Handle the Stop action from the foreground-service notification. Extracted from
135− * [onReceive] to keep that function's cyclomatic complexity under detekt's threshold. */
136− private fun handleStopRun(context: Context, container: soy.iko.opencode.di.AppContainer, intent: Intent) {
137− // The session id may be absent if multiple runs were active (generic notification);
138− // in that case we can't target a specific run — just let it ride.
139− val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
140− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
141− val pending = goAsync()
142− val finished = AtomicBoolean(false)
143− var watchdogThread: Thread? = null
144− val finishOnce = {
145− if (finished.compareAndSet(false, true)) {
146− watchdogThread?.interrupt()
147− pending.finish()
148− }
149− }
150− watchdogThread = watchdog(finishOnce)
151− container.abortRunFromNotification(sessionId, profileId) { success ->
152− // On a failed abort (transient network error), the run continues but the FGS
153− // notification will disappear once anyRunActive clears — leaving the user with
154− // no signal that their Stop tap didn't take. Post a quick "couldn't stop"
155− // notification so they know to open the app. On success, the FGS disappearing
156− // is sufficient feedback.
157− if (!success) {
158− SessionNotifications.postError(
159− context, sessionId,
160− context.getString(R.string.couldnt_stop_run),
161− profileId,
162− )
163− }
164− finishOnce()
165− }
166− }
167−
168− /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
169− * the network call hasn't resolved, so the system can't ANR the receiver. The
170− * underlying work continues on the app scope. Returns the watchdog thread so the
171− * caller can [interrupt][Thread.interrupt] it when work completes early, releasing
172− * the sleeping thread immediately instead of leaving it for the full timeout. */
173− private fun watchdog(finishOnce: () -> Unit): Thread =
174− Thread {
175− try {
176− Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
177− } catch (_: InterruptedException) {
178− return@Thread
179− }
180− finishOnce()
181− }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
182−
183− companion object {
184− const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
185− const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
186− const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
187− const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
188− const val ACTION_RETRY_LAST = "soy.iko.opencode.action.RETRY_LAST"
189− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
190− const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
191− const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
192− const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
193− const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
194− }
195−}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktdeleted−65
@@ -1,65 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.NotificationChannel
4−import android.app.NotificationManager
5−import android.content.Context
6−import soy.iko.opencode.R
7−
8−/**
9− * The app's notification channels. Created once at startup (idempotent — recreating an
10− * existing channel is a no-op). Two channels keep user control granular:
11− *
12− * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13− * "agent running" indicator.
14− * - [COMPLETED]: default importance. Used when a background session finishes a run.
15− * - [PERMISSION]: high importance (heads-up + sound). Used when a paused tool run needs
16− * the user's approval — the whole point is to reach the user who has walked away, so it
17− * interrupts rather than sitting silently in the shade.
18− * - [ERROR]: default importance. Used when a background run fails.
19− */
20−object NotificationChannels {
21− const val STATUS = "run_status"
22− const val COMPLETED = "session_completed"
23− const val PERMISSION = "permission_request"
24− const val ERROR = "session_error"
25− const val UNREAD = "unread_badge"
26−
27− fun create(context: Context) {
28− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
29− nm.createNotificationChannel(
30− NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
31− description = context.getString(R.string.notif_channel_status_desc)
32− setShowBadge(false)
33− },
34− )
35− nm.createNotificationChannel(
36− NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
37− description = context.getString(R.string.notif_channel_completed_desc)
38− },
39− )
40− nm.createNotificationChannel(
41− NotificationChannel(PERMISSION, context.getString(R.string.notif_channel_permission), NotificationManager.IMPORTANCE_HIGH).apply {
42− description = context.getString(R.string.notif_channel_permission_desc)
43− },
44− )
45− nm.createNotificationChannel(
46− // HIGH importance so a failed run stands out from a completed one by
47− // sound/vibration — both are actionable, but a failure is time-sensitive.
48− NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_HIGH).apply {
49− description = context.getString(R.string.notif_channel_error_desc)
50− },
51− )
52− // A silent, badge-only channel that carries the launcher icon's unread count (the only
53− // way to badge the launcher icon on Android is via a notification on a badge-enabled
54− // channel). Low importance + no sound/vibration so it never alerts; setShowBadge(true)
55− // so its number renders on the launcher icon.
56− nm.createNotificationChannel(
57− NotificationChannel(UNREAD, context.getString(R.string.notif_channel_unread), NotificationManager.IMPORTANCE_MIN).apply {
58− description = context.getString(R.string.notif_channel_unread_desc)
59− setShowBadge(true)
60− setSound(null, null)
61− enableVibration(false)
62− },
63− )
64− }
65−}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktdeleted−384
@@ -1,384 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.Notification
4−import android.app.PendingIntent
5−import android.app.Service
6−import android.content.Context
7−import android.content.Intent
8−import android.content.pm.ServiceInfo
9−import android.content.res.Configuration
10−import android.os.Build
11−import android.os.IBinder
12−import android.util.Log
13−import android.annotation.SuppressLint
14−import androidx.core.app.NotificationCompat
15−import androidx.core.app.NotificationManagerCompat
16−import androidx.core.app.ServiceCompat
17−import androidx.core.content.ContextCompat
18−import soy.iko.opencode.MainActivity
19−import soy.iko.opencode.R
20−import java.security.MessageDigest
21−
22−/**
23− * A foreground service kept alive while an opencode agent is actively running. Holding a
24− * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
25− * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
26− *
27− * The service is started when a run begins ([start]) and stopped when it goes idle
28− * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
29− * Started/stopped via Compose from the chat screen based on the `running` state.
30− */
31−class RunForegroundService : Service() {
32−
33− // Wall-clock time the current run started, captured on the first onStartCommand of
34− // the run and reused across progress-driven notification rebuilds so the chronometer's
35− // base doesn't jump back to 0:00 every time the agent advances a step. Reset to 0 in
36− // onDestroy once the run ends (stop() → onDestroy) so the next run starts fresh.
37− //
38− // Instance field (not companion/static): a static field would survive a stopped-then-
39− // recreated service instance and leave the chronometer pinned to a previous run's start
40− // if onDestroy hadn't run yet when the next run's first intent arrived. As an instance
41− // field it's reset by construction on every new Service instance.
42− //
43− // @Volatile: onStartCommand/onDestroy write these on the main thread, but updateProgress
44− // (reached via the companion start() from AppContainer.observeRunForegroundService, which
45− // runs on Dispatchers.IO) reads them. Without @Volatile there's no happens-before edge,
46− // so a 64-bit Long (runStartMillis) can be torn and the booleans/strings can be stale.
47− @Volatile private var runStartMillis: Long = 0L
48− // Whether *this* service instance has successfully called startForeground. Tracked so
49− // progress-driven re-starts (which re-enter onStartCommand) don't re-call startForeground
50− // and re-bind the 5s obligation each time — that would let a backgrounded re-start throw
51− // ForegroundServiceStartNotAllowedException even though the service is already in the
52− // foreground. After the first successful startForeground, progress updates refresh the
53− // notification via NotificationManager.notify directly (see [updateProgress]), which
54− // never throws the background-start exception.
55− @Volatile private var isInForeground: Boolean = false
56− // The most recent session title/id/profile/progress handed to onStartCommand, retained so
57− // [updateProgress] can rebuild the notification with a new progress string without a fresh
58− // startForegroundService intent (which can throw when backgrounded).
59− @Volatile private var lastTitle: String? = null
60− @Volatile private var lastSessionId: String? = null
61− @Volatile private var lastProfileId: String? = null
62− @Volatile private var lastProgress: String? = null
63−
64− @android.annotation.SuppressLint("MissingPermission")
65− override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
66− instance = this
67− runCatching { NotificationManagerCompat.from(this).cancel(NOTIF_TIMEOUT_ID) }
68− // The session title (when a single run is active) is passed via the intent extra so
69− // the notification can identify which session is running, instead of a generic
70− // "Agent is working…". Null/blank falls back to the generic title.
71− val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
72− val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
73− val profileId = intent?.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
74− val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
75− lastTitle = sessionTitle
76− lastSessionId = sessionId
77− lastProfileId = profileId
78− lastProgress = progress
79− // Capture the wall-clock start of the *first* intent of this run and reuse it across
80− // rebuilds so the chronometer's base doesn't jump back to 0:00 every time the agent
81− // advances a step. As an instance field it's 0 on a fresh service, so a recreated
82− // service (after onDestroy reset) starts the clock fresh too.
83− if (runStartMillis == 0L) runStartMillis = System.currentTimeMillis()
84− val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
85− // Only call startForeground once per service lifetime — re-calling it on every
86− // progress-driven re-start re-binds the "must call startForeground within 5s"
87− // obligation each time, and if the app is backgrounded between two progress
88− // updates the new call can throw ForegroundServiceStartNotAllowedException even
89− // though the service is already in the foreground. That would drop foreground
90− // priority mid-run (the catch below stopSelf()s, killing the FGS while
91− // anyRunActive is still true). After the first successful startForeground,
92− // progress updates refresh the notification via NotificationManager.notify
93− // directly, which never throws the background-start exception.
94− if (!isInForeground) {
95− runCatching {
96− ServiceCompat.startForeground(
97− this,
98− NOTIF_ID,
99− notification,
100− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
101− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
102− } else {
103− 0
104− },
105− )
106− }.onFailure {
107− Log.w(TAG, "startForeground failed; running without foreground priority", it)
108− stopSelf()
109− }.also { isInForeground = it.isSuccess }
110− } else {
111− // Already in the foreground: refresh the notification in place. NotificationManager
112− // updates an ongoing notification without re-asserting foreground priority.
113− runCatching {
114− NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
115− }.onFailure { Log.w(TAG, "Failed to update foreground notification", it) }
116− }
117− return START_NOT_STICKY
118− }
119−
120− /** Rebuild the notification with an updated progress string and re-notify in place, *without*
121− * re-entering [onStartCommand] (and therefore without calling [Context.startForegroundService],
122− * which can throw [android.app.ForegroundServiceStartNotAllowedException] on Android 12+ when
123− * the app is backgrounded — even if the service is already in the foreground). Called directly
124− * by the companion [start] when a progress refresh is needed while the service is already
125− * running; falls back to [start] (which goes through startForegroundService) when the
126− * service isn't alive yet, so the first progress update still lands.
127− *
128− * Forwards the full set of run fields (title/sessionId/profileId/progress), not just the
129− * progress string, so a late-resolved session title or a second run starting after the
130− * first updates the notification text AND the Stop action's target session/profile.
131− * Without this, the Stop action would keep targeting the originally-captured session even
132− * after a profile switch (mirroring the SessionNotifications abort-routing guard). */
133− @SuppressLint("MissingPermission")
134− private fun updateProgress(
135− progress: String?,
136− sessionTitle: String? = lastTitle,
137− sessionId: String? = lastSessionId,
138− profileId: String? = lastProfileId,
139− ) {
140− // Guard against the companion start() → instance race: start() reads @Volatile instance
141− // on a background dispatcher and calls updateProgress, but onDestroy (on the main thread)
142− // can null instance and call stopForeground between the read and this notify(). isInForeground
143− // is cleared in onDestroy, so checking it here covers the stopForeground-already-called case
144− // and avoids touching NotificationManager during teardown.
145− if (!isInForeground) return
146− lastTitle = sessionTitle
147− lastSessionId = sessionId
148− lastProfileId = profileId
149− lastProgress = progress
150− val notification = buildNotification(sessionTitle, sessionId, profileId, progress)
151− runCatching {
152− NotificationManagerCompat.from(this).notify(NOTIF_ID, notification)
153− }.onFailure { Log.w(TAG, "Failed to update foreground notification progress", it) }
154− }
155−
156− private fun mainContentIntent(): PendingIntent {
157− // Tapping the notification opens the app so the user can see the running session.
158− val openIntent = Intent(this, MainActivity::class.java).apply {
159− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
160− }
161− return PendingIntent.getActivity(
162− this, 0, openIntent,
163− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
164− )
165− }
166−
167− private fun buildNotification(
168− sessionTitle: String?,
169− sessionId: String?,
170− profileId: String? = null,
171− progress: String? = null,
172− ): Notification {
173− val pendingIntent = mainContentIntent()
174− val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
175− val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
176− .setSmallIcon(R.drawable.ic_stat_notify)
177− .setColor(brandColor(this))
178− .setContentTitle(getString(R.string.notif_running_title))
179− .setContentText(contentText)
180− .setContentIntent(pendingIntent)
181− .setOngoing(true)
182− // Show a live elapsed timer so the user can see how long the current run has
183− // been going — no per-tick plumbing needed; the system renders the chronometer.
184− // runStartMillis is captured once at the start of the run (see onStartCommand)
185− // and reused across progress-driven rebuilds so the chronometer base is stable.
186− .setWhen(runStartMillis)
187− .setUsesChronometer(true)
188− .setShowWhen(true)
189− .setPriority(NotificationCompat.PRIORITY_LOW)
190− .setCategory(NotificationCompat.CATEGORY_PROGRESS)
191− // An indeterminate progress bar so the notification reads as actively "in progress"
192− // at a glance (alongside the chronometer), matching the conventional run affordance.
193− .setProgress(0, 0, true)
194− // Add a Stop action so the user can cancel the run without opening the app — the
195− // core of the "kick it off and walk away" flow. Only when a specific session is
196− // known (multiple concurrent runs can't be targeted individually). The profile id
197− // is embedded so [AppContainer.abortRunFromNotification]'s routing guard sends the
198− // abort to the originating server even if the user has switched profiles between
199− // starting the run and tapping Stop (mirroring the other notification actions).
200− if (sessionId != null) {
201− val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
202− action = NotificationActionReceiver.ACTION_STOP_RUN
203− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
204− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
205− }
206− // Derive the request code from a SHA-256 of the session id (truncated to 31 bits),
207− // NOT String.hashCode(): two distinct session ids can share a 32-bit hashCode, and
208− // with FLAG_UPDATE_CURRENT the second registration overwrites the first's extras —
209− // so a hash collision between two concurrent runs would make tapping Stop on one
210− // session's notification broadcast the OTHER session's id. Mirrors SessionNotifications.
211− // notifId's collision-resistant derivation.
212− val requestCode = stopRequestCode(sessionId)
213− val stopPending = PendingIntent.getBroadcast(
214− this, requestCode, stopIntent,
215− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
216− )
217− builder.addAction(
218− R.drawable.ic_action_stop,
219− getString(R.string.notif_action_stop),
220− stopPending,
221− )
222− }
223− return builder.build()
224− }
225−
226− // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
227− // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
228− // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
229− // continues without foreground priority. Post a low-priority notification so the user
230− // knows their long-running task may be paused in the background, rather than silently
231− // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
232− override fun onTimeout(startId: Int) {
233− Log.w(TAG, "Foreground service timed out; stopping")
234− postTimeoutNotification()
235− stopSelf()
236− }
237−
238− override fun onTimeout(startId: Int, fgsType: Int) {
239− Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
240− postTimeoutNotification()
241− stopSelf()
242− }
243−
244− /** Post a low-priority notification informing the user that the foreground service was
245− * timed out by the system — the run continues but may be killed by Doze without warning. */
246− @SuppressLint("MissingPermission")
247− private fun postTimeoutNotification() {
248− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
249− ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) !=
250− android.content.pm.PackageManager.PERMISSION_GRANTED
251− ) return
252− runCatching {
253− val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
254− .setSmallIcon(R.drawable.ic_stat_notify)
255− .setColor(brandColor(this))
256− .setContentTitle(getString(R.string.notif_running_title))
257− .setContentText(getString(R.string.notif_fg_timeout_text))
258− .setContentIntent(mainContentIntent())
259− .setAutoCancel(true)
260− .setPriority(NotificationCompat.PRIORITY_LOW)
261− .build()
262− NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
263− }
264− }
265−
266− override fun onDestroy() {
267− // Safety net: if the process is being killed while a run is in progress,
268− // ensure the notification is removed rather than lingering.
269− // Clear isInForeground BEFORE stopForeground so a concurrent updateProgress (called
270− // from the companion start() on Dispatchers.IO) returns early before re-posting the
271− // notification that stopForeground is about to remove.
272− isInForeground = false
273− ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
274− // Clear the run-start / instance state so the next run's chronometer
275− // begins fresh and startForeground is re-asserted on the first intent of the next
276− // run. These are instance fields, so a recreated service resets them by construction
277− // — but clearing here also covers the same-instance restart case.
278− runStartMillis = 0L
279− lastTitle = null
280− lastSessionId = null
281− lastProfileId = null
282− lastProgress = null
283− instance = null
284− super.onDestroy()
285− }
286−
287− override fun onBind(intent: Intent?): IBinder? = null
288−
289− // Reset the brand-color cache when the configuration (notably uiMode/night mode) changes
290− // so the notification icon tint follows the app theme instead of staying pinned to whatever
291− // the first build resolved. The service isn't recreated on a config change (it's a service,
292− // not an Activity), so without this the cached color is stale until onDestroy.
293− override fun onConfigurationChanged(newConfig: Configuration) {
294− super.onConfigurationChanged(newConfig)
295− brandColorCache = 0
296− }
297−
298− companion object {
299− private const val TAG = "RunForegroundService"
300− private const val NOTIF_ID = 1
301− private const val NOTIF_TIMEOUT_ID = 2
302−
303− /** Derive a stable, collision-resistant PendingIntent request code from a session id.
304− * String.hashCode() can collide for distinct session ids; a SHA-256 truncated to 31
305− * bits makes accidental collisions astronomically unlikely, so two concurrent runs'
306− * Stop actions can't overwrite each other's PendingIntent extras. Mirrors
307− * [SessionNotifications.notifId]'s derivation. */
308− private fun stopRequestCode(sessionId: String): Int {
309− val digest = runCatching { MessageDigest.getInstance("SHA-256") }
310− .getOrNull()?.digest(sessionId.toByteArray()) ?: return sessionId.hashCode()
311− val hash = (((digest[0].toInt() and 0xFF) shl 24) or
312− ((digest[1].toInt() and 0xFF) shl 16) or
313− ((digest[2].toInt() and 0xFF) shl 8) or
314− (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
315− return hash
316− }
317− // Brand accent for the foreground notifications' small-icon tint circle. Resolved
318− // from resources (R.color.notif_brand) so it follows the app theme (light/dark)
319− // instead of a hardcoded constant. Lazily computed on first use from the app context
320− // and reset in onConfigurationChanged so a theme toggle mid-run doesn't pin the tint
321− // to the old theme for the rest of the run.
322− private var brandColorCache: Int = 0
323− private fun brandColor(context: Context): Int {
324− if (brandColorCache == 0) {
325− brandColorCache = androidx.core.content.ContextCompat.getColor(context, R.color.notif_brand)
326− }
327− return brandColorCache
328− }
329− // Live reference to the running service instance, published in onStartCommand and
330− // cleared in onDestroy. @Volatile so [updateProgress]'s read from the app-scope
331− // coroutine sees the most recent publication. Null when the service isn't running.
332− @Volatile private var instance: RunForegroundService? = null
333− const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
334− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
335− const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
336− const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
337−
338− /** Start the service (or refresh an already-running one). [profileId] is embedded in the
339− * Stop action's PendingIntent so an abort from the notification reaches the originating
340− * server even after a profile switch. */
341− fun start(
342− context: Context,
343− sessionTitle: String? = null,
344− sessionId: String? = null,
345− profileId: String? = null,
346− progress: String? = null,
347− ) {
348− // If the service is already in the foreground, refresh the notification in place
349− // via NotificationManager.notify (through the live instance) instead of going
350− // through startForegroundService. On Android 12+, startForegroundService can
351− // throw ForegroundServiceStartNotAllowedException when the app is backgrounded
352− // — *even if the service is already running in the foreground* — and the
353− // runCatching below would swallow it, leaving the progress text un-updated
354− // precisely when the app is backgrounded (the FGS's whole purpose). The in-place
355− // notify never throws, so progress refreshes always land while the service runs.
356− val live = instance
357− if (live != null) {
358− // Forward the full set of run fields (not just progress) so a late-resolved
359− // session title or a second run updates the notification text AND the Stop
360− // action's target session/profile — otherwise the Stop action would keep
361− // targeting the originally-captured session even after a profile switch.
362− live.updateProgress(progress, sessionTitle, sessionId, profileId)
363− return
364− }
365− // startForegroundService can throw ForegroundServiceStartNotAllowedException
366− // on Android 12+ if the app is in the background. Wrap it so a backgrounded
367− // start (e.g. the user navigates away at the wrong moment) doesn't crash.
368− runCatching {
369− val intent = Intent(context, RunForegroundService::class.java)
370− sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
371− sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
372− profileId?.let { intent.putExtra(EXTRA_PROFILE_ID, it) }
373− progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
374− context.startForegroundService(intent)
375− }.onFailure {
376− Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
377− }
378− }
379−
380− fun stop(context: Context) {
381− context.stopService(Intent(context, RunForegroundService::class.java))
382− }
383− }
384−}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktdeleted−584
@@ -1,584 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.annotation.SuppressLint
4−import android.app.Notification
5−import android.app.NotificationManager
6−import android.app.PendingIntent
7−import android.content.Context
8−import android.content.Intent
9−import android.os.Build
10−import android.util.Log
11−import androidx.core.app.NotificationCompat
12−import androidx.core.app.NotificationManagerCompat
13−import androidx.core.app.RemoteInput
14−import soy.iko.opencode.MainActivity
15−import soy.iko.opencode.R
16−import soy.iko.opencode.data.model.Permission
17−import soy.iko.opencode.data.model.PermissionResponse
18−import java.security.MessageDigest
19−
20−/**
21− * Builds and posts the app's session notifications:
22− *
23− * - [postCompleted]: a "session ready" notification when a background run finishes, with an
24− * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
25− * - [postPermission]: a heads-up notification when a paused tool needs approval, with
26− * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
27− * - [postError]: a notification when a background run fails.
28− *
29− * Tapping a notification body opens the session via [MainActivity] with an
30− * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
31− * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
32− * skipped rather than throwing.
33− */
34−object SessionNotifications {
35−
36− private const val NOTIF_ID_PREFIX = 4000
37− private const val TAG = "SessionNotifications"
38− // Fixed notification IDs use NEGATIVE space (mirroring the SUMMARY_*_ID constants below)
39− // so they can never collide with the hash-derived per-session IDs returned by notifId(),
40− // which are always positive (NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX)),
41− // range [4000, 2147483646]). A collision would let a session-specific notification
42− // overwrite or be canceled by the fixed one (~1 in 2 billion per session, but a real
43− // ID-space flaw). Negative IDs are valid for NotificationManager.cancel/notify.
44− private const val UNREAD_BADGE_ID = -10
45− private const val CONNECTION_LOST_ID = -11
46−
47− // Distinct namespaces so a session can have a completion, a permission, an error, and an
48− // outbox-dropped notification outstanding at once without their ids colliding. The outbox
49− // drop is semantically distinct from a run error (no run failed — a queued reply couldn't
50− // be delivered), so it gets its own namespace rather than overwriting a prior postError.
51− private const val NS_COMPLETED = "done"
52− private const val NS_PERMISSION = "perm"
53− private const val NS_ERROR = "err"
54− private const val NS_OUTBOX_DROPPED = "outbox"
55−
56− private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
57−
58− /** Derive a stable, collision-resistant notification id from a namespace + session id.
59− * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
60− * bits makes accidental collisions astronomically unlikely. The namespace keeps the
61− * three notification kinds for one session in separate id spaces. */
62− private fun notifId(namespace: String, sessionId: String): Int {
63− val digest = runCatching { MessageDigest.getInstance("SHA-256") }
64− .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
65− // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
66− // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
67− // left-to-right, so the shifts MUST be parenthesized individually — otherwise
68− // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
69− // byte out of the Int entirely and collapsing the id's entropy.
70− val hash = (((digest[0].toInt() and 0xFF) shl 24) or
71− ((digest[1].toInt() and 0xFF) shl 16) or
72− ((digest[2].toInt() and 0xFF) shl 8) or
73− (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
74− // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
75− // Using modulo ensures the prefix is preserved without Int overflow.
76− return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
77− }
78−
79− /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
80− * it, callers should skip posting instead of throwing. Foreground-service notifications
81− * are exempt, so the running indicator still shows during a run. */
82− private fun canPost(context: Context): Boolean =
83− Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
84− androidx.core.content.ContextCompat.checkSelfPermission(
85− context, android.Manifest.permission.POST_NOTIFICATIONS,
86− ) == android.content.pm.PackageManager.PERMISSION_GRANTED
87−
88− /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
89− * [profileId] (the originating server) is embedded so a tap after the user has switched
90− * servers routes back to the server that ran the session, not whichever is active —
91− * mirroring the Reply and permission action intents. */
92− private fun openSessionIntent(
93− context: Context, sessionId: String, requestCode: Int, profileId: String?,
94− ): PendingIntent {
95− // Sanitize the session id before embedding it in the deep-link URI so characters like
96− // /, ?, # can't inject path segments or query parameters.
97− val safeId = notifIdRegex.replace(sessionId, "")
98− val openIntent = Intent(context, MainActivity::class.java).apply {
99− action = Intent.ACTION_VIEW
100− data = android.net.Uri.parse("opencode://session/$safeId")
101− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
102− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
103− }
104− return PendingIntent.getActivity(
105− context, requestCode, openIntent,
106− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
107− )
108− }
109−
110− // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
111− // check through the helper, so suppress its MissingPermission flag on these posts.
112− @SuppressLint("MissingPermission")
113− fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
114− if (!canPost(context)) return
115− val notifId = notifId(NS_COMPLETED, sessionId)
116−
117− // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
118− // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
119− // fill in the RemoteInput results before delivering it. [profileId] is embedded so the
120− // receiver enqueues the reply against the server that ran the session, not whichever
121− // server is active when the user taps (mirrors postPermission).
122− val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
123− .setLabel(context.getString(R.string.notif_reply_hint))
124− .build()
125− val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
126− action = NotificationActionReceiver.ACTION_REPLY
127− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
128− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
129− }
130− val replyPending = PendingIntent.getBroadcast(
131− context, notifId, replyIntent,
132− PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
133− )
134− val replyAction = NotificationCompat.Action.Builder(
135− R.drawable.ic_action_reply,
136− context.getString(R.string.notif_action_reply),
137− replyPending,
138− ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
139−
140− // "Mark read" action so the user can triage a completion without opening the session.
141− val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
142− action = NotificationActionReceiver.ACTION_MARK_READ
143− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
144− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
145− }
146− val markReadPending = PendingIntent.getBroadcast(
147− context, notifId + 100, markReadIntent,
148− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
149− )
150− val markReadAction = NotificationCompat.Action.Builder(
151− R.drawable.ic_action_check,
152− context.getString(R.string.notif_action_mark_read),
153− markReadPending,
154− ).build()
155−
156− val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
157− .setSmallIcon(R.drawable.ic_stat_notify)
158− .setContentTitle(context.getString(R.string.notif_completed_title))
159− // No % escaping here: getString(id, arg) inserts the argument verbatim and
160− // never re-scans it for format specifiers, so escaping the title would show
161− // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
162− .setContentText(context.getString(R.string.notif_completed_text, title))
163− .setAutoCancel(true)
164− .setOnlyAlertOnce(true)
165− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
166− .addAction(replyAction)
167− .addAction(markReadAction)
168− .setCategory(NotificationCompat.CATEGORY_MESSAGE)
169− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
170− // Group notifications from the same server so multiple completions collapse into
171− // a stackable group on Android 7+ instead of filling the shade with individual entries.
172− .setGroup(GROUP_COMPLETED)
173− .build()
174−
175− runCatching {
176− NotificationManagerCompat.from(context).notify(notifId, notification)
177− // Post a summary notification so the group has a single top-level entry in the shade.
178− postCompletedSummary(context)
179− }.onFailure { Log.w(TAG, "Failed to post completion notification", it) }
180− }
181−
182− /** Post (or update) a summary notification for the completed-sessions group so multiple
183− * completions collapse into one stackable entry instead of filling the shade. */
184− @SuppressLint("MissingPermission")
185− private fun postCompletedSummary(context: Context) {
186− // The summary needs a content intent so tapping it opens the app (and so
187− // setAutoCancel can fire on tap — without a PendingIntent the tap does nothing
188− // and the summary can only be dismissed by swiping). A plain open-app intent is
189− // right for a group summary: there's no single session to route to.
190− val openIntent = Intent(context, MainActivity::class.java).apply {
191− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
192− }
193− val contentIntent = PendingIntent.getActivity(
194− context, 0, openIntent,
195− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
196− )
197− val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
198− .setSmallIcon(R.drawable.ic_stat_notify)
199− .setContentTitle(context.getString(R.string.notif_completed_title))
200− .setContentText(context.getString(R.string.app_name))
201− .setContentIntent(contentIntent)
202− .setAutoCancel(true)
203− .setOnlyAlertOnce(true)
204− .setCategory(NotificationCompat.CATEGORY_MESSAGE)
205− .setGroup(GROUP_COMPLETED)
206− .setGroupSummary(true)
207− .build()
208− runCatching {
209− NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
210− }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
211− }
212−
213− /** Post a heads-up notification for a permission request with Allow once / Always /
214− * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
215− * pattern/type supplies the detail line. [profileId] is embedded in the action intents
216− * so the receiver routes the response back to the server that posted the request, not
217− * whichever server happens to be active when the user taps. */
218− @SuppressLint("MissingPermission")
219− fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
220− if (!canPost(context)) return
221− val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
222− val notifId = notifId(NS_PERMISSION, sessionId)
223− val detail = permission.title?.takeIf { it.isNotBlank() }
224− ?: permission.patternText?.takeIf { it.isNotBlank() }
225− ?: permission.type?.takeIf { it.isNotBlank() }
226− ?: context.getString(R.string.notif_permission_fallback)
227−
228− val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
229− .setSmallIcon(R.drawable.ic_stat_notify)
230− .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
231− .setContentText(detail)
232− .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
233− .setAutoCancel(true)
234− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
235− .setPriority(NotificationCompat.PRIORITY_HIGH)
236− .setGroup(GROUP_PERMISSION)
237− .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
238− .setPublicVersion(
239− NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
240− .setSmallIcon(R.drawable.ic_stat_notify)
241− .setContentTitle(context.getString(R.string.notif_permission_public))
242− .build(),
243− )
244−
245− // One action per response. Each PendingIntent needs a distinct request code or
246− // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
247− listOf(
248− Triple(PermissionResponse.ONCE, R.string.notif_action_allow_once, R.drawable.ic_action_check),
249− Triple(PermissionResponse.ALWAYS, R.string.notif_action_always, R.drawable.ic_action_check),
250− Triple(PermissionResponse.REJECT, R.string.notif_action_reject, R.drawable.ic_action_block),
251− ).forEach { (response, labelRes, iconRes) ->
252− val intent = Intent(context, NotificationActionReceiver::class.java).apply {
253− action = NotificationActionReceiver.ACTION_PERMISSION
254− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
255− putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
256− putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
257− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
258− }
259− val pending = PendingIntent.getBroadcast(
260− context, notifId + response.ordinal + 1, intent,
261− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
262− )
263− builder.addAction(iconRes, context.getString(labelRes), pending)
264− }
265−
266− runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
267− .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
268− // Group permission notifications so a multi-session burst collapses into a stackable
269− // group instead of filling the shade — mirroring the completed-sessions grouping.
270− runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
271− .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
272− }
273−
274− /** Post a notification when a permission prompt auto-rejected on timeout. A user who
275− * walked away from an in-app prompt returns to find the run stopped with no on-screen
276− * record of why; this leaves a persistent trace in the shade explaining the stop and
277− * offering to re-open the session. Uses the permission channel (heads-up) so it's seen. */
278− @SuppressLint("MissingPermission")
279− fun postPermissionAutoRejected(context: Context, sessionId: String, sessionTitle: String, profileId: String?) {
280− if (!canPost(context)) return
281− val notifId = notifId(NS_PERMISSION, sessionId) + 1 // distinct from the live prompt
282− val detail = context.getString(R.string.notif_permission_auto_rejected_text)
283− val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
284− .setSmallIcon(R.drawable.ic_stat_notify)
285− .setContentTitle(context.getString(R.string.notif_permission_auto_rejected_title, sessionTitle))
286− .setContentText(detail)
287− .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
288− .setAutoCancel(true)
289− .setOnlyAlertOnce(true)
290− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
291− .setCategory(NotificationCompat.CATEGORY_STATUS)
292− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
293− .setGroup(GROUP_PERMISSION)
294− .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
295− runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
296− .onFailure { Log.w(TAG, "Failed to post permission auto-reject notification", it) }
297− runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
298− .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
299− }
300−
301− /** Post a notification when a background run fails. */
302− @SuppressLint("MissingPermission")
303− fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
304− if (!canPost(context)) return
305− val notifId = notifId(NS_ERROR, sessionId)
306− val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
307− .setSmallIcon(R.drawable.ic_stat_notify)
308− .setContentTitle(context.getString(R.string.notif_error_title))
309− .setContentText(context.getString(R.string.notif_error_text, title))
310− .setAutoCancel(true)
311− .setOnlyAlertOnce(true)
312− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
313− .setCategory(NotificationCompat.CATEGORY_ERROR)
314− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
315− // Group error notifications so a burst of failures collapses into a stackable group.
316− .setGroup(GROUP_ERROR)
317− .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
318− .setPublicVersion(
319− NotificationCompat.Builder(context, NotificationChannels.ERROR)
320− .setSmallIcon(R.drawable.ic_stat_notify)
321− .setContentTitle(context.getString(R.string.notif_error_public))
322− .build(),
323− )
324− // A "Retry last" action lets the user re-run the failed prompt straight from the shade
325− // without opening the app — a failed run is exactly when a one-tap retry is most useful.
326− val retryIntent = Intent(context, NotificationActionReceiver::class.java).apply {
327− action = NotificationActionReceiver.ACTION_RETRY_LAST
328− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
329− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
330− }
331− val retryPending = PendingIntent.getBroadcast(
332− context, notifId + 10, retryIntent,
333− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
334− )
335− builder.addAction(R.drawable.ic_action_refresh, context.getString(R.string.retry_last), retryPending)
336− val notification = builder.build()
337− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
338− .onFailure { Log.w(TAG, "Failed to post error notification", it) }
339− runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
340− .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
341− }
342−
343− /** Update the completion notification to show a brief "Sent" confirmation after a
344− * successful inline reply, replacing the completion notification's content. Auto-cancels
345− * on tap (via a [setContentIntent]) so it doesn't linger. */
346− @SuppressLint("MissingPermission")
347− fun postReplySent(context: Context, sessionId: String, profileId: String? = null) {
348− if (!canPost(context)) {
349− cancel(context, sessionId)
350− return
351− }
352− val notifId = notifId(NS_COMPLETED, sessionId)
353− val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
354− .setSmallIcon(R.drawable.ic_stat_notify)
355− .setContentTitle(context.getString(R.string.notif_reply_sent))
356− // setAutoCancel(true) only fires when the user taps a contentIntent; without one
357− // the "Sent" notification lingers indefinitely (the prior behavior). Wire the
358− // open-session intent so a tap dismisses the notification and opens the session.
359− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
360− .setAutoCancel(true)
361− .setPriority(NotificationCompat.PRIORITY_LOW)
362− .setGroup(GROUP_COMPLETED)
363− .build()
364− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
365− .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
366− }
367−
368− /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
369− * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
370− * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
371− * notification so the reply isn't silently lost and the user can reopen the session to retry. */
372− @SuppressLint("MissingPermission")
373− fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
374− NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
375− maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
376− if (!canPost(context)) return
377− val notifId = notifId(NS_ERROR, sessionId)
378− val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
379− .setSmallIcon(R.drawable.ic_stat_notify)
380− .setContentTitle(context.getString(R.string.notif_reply_failed_title))
381− .setContentText(context.getString(R.string.notif_reply_failed_text))
382− .setAutoCancel(true)
383− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
384− .setCategory(NotificationCompat.CATEGORY_ERROR)
385− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
386− // Group with the other error notifications (mirrors postError/postOutboxDropped)
387− // so this notification joins the GROUP_ERROR stack and the group summary stays
388− // consistent. Without .setGroup, reusing the NS_ERROR id space would overwrite a
389− // prior postError for this session without the group association, leaving an
390− // orphaned SUMMARY_ERROR_ID group summary with no children lingering in the shade.
391− .setGroup(GROUP_ERROR)
392− .build()
393− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
394− .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
395− runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
396− .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
397− }
398−
399− /** A queued outbox message was permanently undeliverable (e.g. the target session was
400− * deleted server-side → 404). Distinct from [postError] (which is titled "Run failed"):
401− * no run failed — a queued reply couldn't be delivered. Uses a distinct title/body so the
402− * user understands the failure mode and recovery action (open the session and re-send).
403− * Uses a distinct notification namespace ([NS_OUTBOX_DROPPED]) so it doesn't overwrite a
404− * prior [postError] for the same session — the two failure modes are independent and the
405− * user may need to see both. */
406− @SuppressLint("MissingPermission")
407− fun postOutboxDropped(context: Context, sessionId: String, sessionTitle: String, profileId: String? = null) {
408− if (!canPost(context)) return
409− val notifId = notifId(NS_OUTBOX_DROPPED, sessionId)
410− val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
411− .setSmallIcon(R.drawable.ic_stat_notify)
412− .setContentTitle(context.getString(R.string.message_not_delivered))
413− .setContentText(context.getString(R.string.message_not_delivered_body, sessionTitle))
414− .setAutoCancel(true)
415− .setOnlyAlertOnce(true)
416− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
417− .setCategory(NotificationCompat.CATEGORY_ERROR)
418− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
419− .setGroup(GROUP_ERROR)
420− .build()
421− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
422− .onFailure { Log.w(TAG, "Failed to post outbox-dropped notification", it) }
423− runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
424− .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
425− }
426−
427− /** The SSE stream hit a non-retryable failure (Failed/AuthFailed) while the app was
428− * backgrounded during an active run, so the in-app banner isn't visible and the run is
429− * effectively stranded. Posts a low-priority notification so a backgrounded user is
430− * signaled that their run is stalled and can tap to reconnect. Distinct from [postError]
431− * (a run error) — this is a connection loss, not a run failure. */
432− @SuppressLint("MissingPermission")
433− fun postConnectionLost(context: Context, profileLabel: String, profileId: String?) {
434− if (!canPost(context)) return
435− val notifId = CONNECTION_LOST_ID
436− // Tap reopens the app at the server list so the user can reconnect / fix credentials.
437− val openIntent = Intent(context, MainActivity::class.java).apply {
438− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
439− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
440− }
441− val openPending = PendingIntent.getActivity(
442− context, notifId, openIntent,
443− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
444− )
445− val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
446− .setSmallIcon(R.drawable.ic_stat_notify)
447− .setContentTitle(context.getString(R.string.connection_lost, profileLabel))
448− .setContentText(context.getString(R.string.connection_lost_tap))
449− .setAutoCancel(true)
450− .setOnlyAlertOnce(true)
451− .setContentIntent(openPending)
452− .setCategory(NotificationCompat.CATEGORY_STATUS)
453− .setPriority(NotificationCompat.PRIORITY_LOW)
454− .build()
455− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
456− .onFailure { Log.w(TAG, "Failed to post connection-lost notification", it) }
457− }
458−
459− /** Cancel a previously-posted [postConnectionLost] notification (e.g. on reconnect). */
460− fun cancelConnectionLost(context: Context) {
461− NotificationManagerCompat.from(context).cancel(CONNECTION_LOST_ID)
462− }
463−
464− /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
465− private fun mutableFlag(): Int =
466− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
467−
468− /** Cancel a session's completion notification (e.g. when the user opens it). */
469− fun cancel(context: Context, sessionId: String) {
470− NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
471− maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
472− }
473−
474− /**
475− * Reflect the total number of unread sessions as a launcher-icon badge. Android only badges
476− * the launcher icon via a notification on a badge-enabled channel, so this posts a silent,
477− * min-importance notification with [.setNumber] on the [NotificationChannels.UNREAD] channel
478− * when [totalUnread] > 0, and cancels it when it drops to 0. Skipped entirely without
479− * POST_NOTIFICATIONS permission (Android 13+) — the badge just won't show, no error.
480− */
481− @SuppressLint("MissingPermission")
482− fun updateUnreadBadge(context: Context, totalUnread: Int) {
483− val nm = NotificationManagerCompat.from(context)
484− if (totalUnread <= 0) {
485− nm.cancel(UNREAD_BADGE_ID)
486− return
487− }
488− if (!canPost(context)) return
489− val notification = NotificationCompat.Builder(context, NotificationChannels.UNREAD)
490− .setSmallIcon(R.drawable.ic_stat_notify)
491− .setContentTitle(context.getString(R.string.app_name))
492− .setContentText(context.resources.getQuantityString(R.plurals.unread_sessions, totalUnread, totalUnread))
493− .setNumber(totalUnread.coerceAtMost(999))
494− .setOnlyAlertOnce(true)
495− .setSilent(true)
496− .setShowWhen(false)
497− .setPriority(NotificationCompat.PRIORITY_MIN)
498− .build()
499− runCatching { nm.notify(UNREAD_BADGE_ID, notification) }
500− .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
501− }
502−
503− /** Cancel a session's permission notification (on reply, or when the user opens it). Also
504− * cancels the auto-rejected-permission notification (posted under notifId + 1) so opening
505− * the session by any route other than tapping that notification clears it too — without
506− * this a stale "auto-rejected" heads-up lingers in the shade (setAutoCancel only dismisses
507− * on a direct tap). */
508− fun cancelPermission(context: Context, sessionId: String) {
509− val nm = NotificationManagerCompat.from(context)
510− val baseId = notifId(NS_PERMISSION, sessionId)
511− nm.cancel(baseId)
512− nm.cancel(baseId + 1) // postPermissionAutoRejected uses baseId + 1
513− maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
514− }
515−
516− /** Cancel a session's error notification (e.g. when the user opens it). Without this a
517− * stale "run failed" notification lingers when the session is opened by any route other
518− * than tapping the notification itself (setAutoCancel only clears it on a direct tap).
519− * Also cancels an outbox-dropped notification ([postOutboxDropped]) since it shares the
520− * error channel/group and should likewise clear on open. */
521− fun cancelError(context: Context, sessionId: String) {
522− val nm = NotificationManagerCompat.from(context)
523− nm.cancel(notifId(NS_ERROR, sessionId))
524− nm.cancel(notifId(NS_OUTBOX_DROPPED, sessionId))
525− maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
526− }
527−
528− /** Build the lock-screen (public) version of a notification: only a generic title, no
529− * detail line, so sensitive file paths / commands in the real content aren't revealed
530− * while the device is locked. */
531− /** When the last child of a group is dismissed, cancel its lingering summary so it
532− * doesn't sit empty in the shade. NotificationManagerCompat lacks getActiveNotifications,
533− * so reach for the platform NotificationManager. The children/summary are posted without
534− * a tag, so matching by [android.app.Notification.getGroup] (plus excluding the summary
535− * id) is the reliable way to tell if any child remains. */
536− private fun maybeCancelSummary(context: Context, group: String, summaryId: Int) {
537− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
538− ?: return
539− val active = try {
540− nm.activeNotifications
541− } catch (e: Exception) {
542− Log.w(TAG, "Failed to query active notifications for summary cleanup", e)
543− return
544− }
545− val hasChild = active.any { it.id != summaryId && it.notification?.group == group }
546− if (!hasChild) {
547− runCatching { NotificationManagerCompat.from(context).cancel(summaryId) }
548− .onFailure { Log.w(TAG, "Failed to cancel empty group summary", it) }
549− }
550− }
551−
552− private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
553− private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
554− private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
555− private const val SUMMARY_COMPLETED_ID = -1
556− private const val SUMMARY_PERMISSION_ID = -2
557− private const val SUMMARY_ERROR_ID = -3
558−
559− /** Post a summary notification for a group so multiple entries collapse into one stackable
560− * entry in the shade instead of filling it. */
561− @SuppressLint("MissingPermission")
562− private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
563− if (!canPost(context)) return
564− val openIntent = Intent(context, MainActivity::class.java).apply {
565− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
566− }
567− val contentIntent = PendingIntent.getActivity(
568− context, 0, openIntent,
569− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
570− )
571− val summary = NotificationCompat.Builder(context, channelId)
572− .setSmallIcon(R.drawable.ic_stat_notify)
573− .setContentTitle(context.getString(R.string.app_name))
574− .setContentIntent(contentIntent)
575− .setAutoCancel(true)
576− .setOnlyAlertOnce(true)
577− .setGroup(group)
578− .setGroupSummary(true)
579− .build()
580− runCatching {
581− NotificationManagerCompat.from(context).notify(summaryId, summary)
582− }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
583− }
584−}
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.ktdeleted−65
@@ -1,65 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.content.Context
4−import android.content.Intent
5−import android.net.Uri
6−import androidx.core.content.pm.ShortcutInfoCompat
7−import androidx.core.content.pm.ShortcutManagerCompat
8−import androidx.core.graphics.drawable.IconCompat
9−import soy.iko.opencode.MainActivity
10−import soy.iko.opencode.R
11−import soy.iko.opencode.data.repo.RecentSession
12−import soy.iko.opencode.notification.NotificationActionReceiver
13−
14−/**
15− * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
16− * "Resume last". Rebuilt whenever the recent-session list changes so "Resume last" points at
17− * the most recent conversation via the existing `opencode://session/{id}` deep link.
18− */
19−object AppShortcuts {
20−
21− private const val ID_NEW = "new_session"
22− private const val ID_RESUME = "resume_last"
23− private val idRegex = Regex("[^A-Za-z0-9_-]")
24−
25− fun update(context: Context, lastSession: RecentSession?) {
26− val ctx = context.applicationContext
27− val shortcuts = mutableListOf<ShortcutInfoCompat>()
28−
29− shortcuts += ShortcutInfoCompat.Builder(ctx, ID_NEW)
30− .setShortLabel(ctx.getString(R.string.shortcut_new_short))
31− .setLongLabel(ctx.getString(R.string.shortcut_new_long))
32− .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
33− .setIntent(
34− Intent(ctx, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
35− )
36− .build()
37−
38− if (lastSession != null) {
39− val safeId = idRegex.replace(lastSession.id, "")
40− if (safeId.isNotEmpty()) {
41− shortcuts += ShortcutInfoCompat.Builder(ctx, ID_RESUME)
42− .setShortLabel(ctx.getString(R.string.shortcut_resume_short))
43− .setLongLabel(
44− lastSession.title.ifBlank { ctx.getString(R.string.shortcut_resume_long) }.take(40),
45− )
46− .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
47− .setIntent(
48− Intent(ctx, MainActivity::class.java).apply {
49− action = Intent.ACTION_VIEW
50− data = Uri.parse("opencode://session/$safeId")
51− // Embed the profile id so the session opens under the server that
52− // ran it, not whichever is active when the user taps — mirrors the
53− // widget and notification action paths.
54− if (lastSession.profileId.isNotBlank()) {
55− putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, lastSession.profileId)
56− }
57− },
58− )
59− .build()
60− }
61− }
62−
63− runCatching { ShortcutManagerCompat.setDynamicShortcuts(ctx, shortcuts) }
64− }
65−}
app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.ktdeleted−33
@@ -1,33 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.app.PendingIntent
4−import android.content.Intent
5−import android.os.Build
6−import android.service.quicksettings.TileService
7−import soy.iko.opencode.MainActivity
8−
9−/**
10− * Quick-settings tile that starts a new opencode session. Tapping launches [MainActivity]
11− * with [MainActivity.ACTION_NEW_SESSION]; the app creates and opens a fresh session once
12− * connected. On Android 14+ [startActivityAndCollapse] requires a PendingIntent.
13− */
14−class NewSessionTileService : TileService() {
15−
16− override fun onClick() {
17− super.onClick()
18− val intent = Intent(this, MainActivity::class.java).apply {
19− action = MainActivity.ACTION_NEW_SESSION
20− addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
21− }
22− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
23− val pending = PendingIntent.getActivity(
24− this, 0, intent,
25− PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
26− )
27− startActivityAndCollapse(pending)
28− } else {
29− @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
30− startActivityAndCollapse(intent)
31− }
32− }
33−}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.ktdeleted−83
@@ -1,83 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.app.PendingIntent
4−import android.appwidget.AppWidgetManager
5−import android.appwidget.AppWidgetProvider
6−import android.content.ComponentName
7−import android.content.Context
8−import android.content.Intent
9−import android.net.Uri
10−import android.os.Build
11−import android.widget.RemoteViews
12−import soy.iko.opencode.MainActivity
13−import soy.iko.opencode.R
14−
15−/**
16− * Home-screen widget listing recent opencode sessions (fed by
17− * [soy.iko.opencode.data.repo.RecentSessionsStore]) with a "New session" action. Each row deep-links
18− * into its session; the header opens the app. The list is backed by [SessionsWidgetService].
19− */
20−class SessionsWidgetProvider : AppWidgetProvider() {
21−
22− override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
23− for (id in appWidgetIds) render(context, appWidgetManager, id)
24− }
25−
26− companion object {
27− /** Re-render all widgets and tell them to reload their list data (call after the
28− * recent-session list changes). No-op when no widget is placed. */
29− fun refresh(context: Context) {
30− val manager = AppWidgetManager.getInstance(context) ?: return
31− val ids = runCatching {
32− manager.getAppWidgetIds(ComponentName(context, SessionsWidgetProvider::class.java))
33− }.getOrNull() ?: return
34− if (ids.isEmpty()) return
35− for (id in ids) render(context, manager, id)
36− manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list)
37− }
38−
39− private fun render(context: Context, manager: AppWidgetManager, widgetId: Int) {
40− val views = RemoteViews(context.packageName, R.layout.widget_sessions)
41−
42− // Header: title opens the app; the "+" starts a new session.
43− views.setOnClickPendingIntent(
44− R.id.widget_title,
45− activityPending(context, requestCode = 2, Intent(context, MainActivity::class.java)),
46− )
47− views.setOnClickPendingIntent(
48− R.id.widget_new,
49− activityPending(
50− context, requestCode = 1,
51− Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
52− ),
53− )
54−
55− // Bind the list to the RemoteViewsService. A unique data Uri per widget id keeps
56− // the framework from reusing one adapter across widgets.
57− val serviceIntent = Intent(context, SessionsWidgetService::class.java).apply {
58− putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
59− data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
60− }
61− views.setRemoteAdapter(R.id.widget_list, serviceIntent)
62− views.setEmptyView(R.id.widget_list, R.id.widget_empty)
63−
64− // Template intent filled in per row (fill-in carries EXTRA_SESSION_ID). Must be
65− // mutable on Android 12+ so the row's fill-in can be merged.
66− val templateIntent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW }
67− val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
68− val templatePending = PendingIntent.getActivity(
69− context, 3, templateIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,
70− )
71− views.setPendingIntentTemplate(R.id.widget_list, templatePending)
72−
73− manager.updateAppWidget(widgetId, views)
74− manager.notifyAppWidgetViewDataChanged(widgetId, R.id.widget_list)
75− }
76−
77− private fun activityPending(context: Context, requestCode: Int, intent: Intent): PendingIntent =
78− PendingIntent.getActivity(
79− context, requestCode, intent,
80− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
81− )
82− }
83−}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.ktdeleted−72
@@ -1,72 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.content.Context
4−import android.content.Intent
5−import android.widget.RemoteViews
6−import android.widget.RemoteViewsService
7−import soy.iko.opencode.MainActivity
8−import soy.iko.opencode.R
9−import soy.iko.opencode.data.repo.RecentSession
10−import soy.iko.opencode.data.repo.RecentSessionsStore
11−import soy.iko.opencode.notification.NotificationActionReceiver
12−import java.security.MessageDigest
13−
14−/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
15−class SessionsWidgetService : RemoteViewsService() {
16− override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
17− SessionsWidgetFactory(applicationContext)
18−}
19−
20−private class SessionsWidgetFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
21−
22− private var items: List<RecentSession> = emptyList()
23−
24− override fun onCreate() { items = RecentSessionsStore.read(context) }
25−
26− // Re-read from disk whenever the app signals the data changed (notifyAppWidgetViewDataChanged).
27− override fun onDataSetChanged() { items = RecentSessionsStore.read(context) }
28−
29− override fun onDestroy() { items = emptyList() }
30−
31− override fun getCount(): Int = items.size
32−
33− override fun getViewAt(position: Int): RemoteViews {
34− val row = RemoteViews(context.packageName, R.layout.widget_session_item)
35− val session = items.getOrNull(position) ?: return row
36− row.setTextViewText(R.id.widget_item_title, session.title.ifBlank { context.getString(R.string.session) })
37− // Fill-in intent merged into the list's template: carries the session id the app opens,
38− // plus the profile id of the server that ran the session so it opens under THAT server
39− // even after the user has switched connections — mirrors the notification action path.
40− val fillIn = Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id)
41− if (session.profileId.isNotBlank()) {
42− fillIn.putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, session.profileId)
43− }
44− row.setOnClickFillInIntent(R.id.widget_item_root, fillIn)
45− return row
46− }
47−
48− override fun getLoadingView(): RemoteViews? = null
49− override fun getViewTypeCount(): Int = 1
50− // Derive a stable id from the session id via a collision-resistant SHA-256 digest
51− // (truncated to 63 bits) rather than String.hashCode(), whose 32-bit output can
52− // collide for distinct session ids — with hasStableIds=true the RemoteViews framework
53− // would then mis-recycle two sessions' row content. Mirrors SessionNotifications.notifId.
54− // Falls back to the position for an out-of-range index.
55− override fun getItemId(position: Int): Long {
56− val id = items.getOrNull(position)?.id ?: return position.toLong()
57− val digest = runCatching { MessageDigest.getInstance("SHA-256").digest(id.toByteArray()) }
58− .getOrNull() ?: return position.toLong()
59− // Take the first 8 bytes, mask to 63 bits (always positive) to fit a Long id.
60− // The shl/or/and infix precedence requires parenthesizing each shift individually
61− // (see SessionNotifications.notifId for the rationale).
62− return (((digest[0].toLong() and 0xFF) shl 56) or
63− ((digest[1].toLong() and 0xFF) shl 48) or
64− ((digest[2].toLong() and 0xFF) shl 40) or
65− ((digest[3].toLong() and 0xFF) shl 32) or
66− ((digest[4].toLong() and 0xFF) shl 24) or
67− ((digest[5].toLong() and 0xFF) shl 16) or
68− ((digest[6].toLong() and 0xFF) shl 8) or
69− (digest[7].toLong() and 0xFF)).and(0x7FFFFFFFFFFFFFFF)
70− }
71− override fun hasStableIds(): Boolean = true
72−}
app/src/main/java/soy/iko/opencode/ui/AppLockGate.ktdeleted−312
@@ -1,312 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import android.content.Context
4−import android.content.ContextWrapper
5−import android.os.Build
6−import androidx.biometric.BiometricManager
7−import androidx.biometric.BiometricPrompt
8−import androidx.compose.foundation.clickable
9−import androidx.compose.foundation.layout.Arrangement
10−import androidx.compose.foundation.layout.Column
11−import androidx.compose.foundation.layout.Spacer
12−import androidx.compose.foundation.layout.fillMaxSize
13−import androidx.compose.foundation.layout.padding
14−import androidx.compose.foundation.layout.size
15−import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.filled.Fingerprint
17−import androidx.compose.material.icons.filled.Lock
18−import androidx.compose.material3.Button
19−import androidx.compose.material3.Icon
20−import androidx.compose.material3.MaterialTheme
21−import androidx.compose.material3.Surface
22−import androidx.compose.material3.Text
23−import androidx.compose.runtime.Composable
24−import androidx.compose.runtime.DisposableEffect
25−import androidx.compose.runtime.LaunchedEffect
26−import androidx.compose.runtime.getValue
27−import androidx.compose.runtime.mutableStateOf
28−import androidx.compose.runtime.remember
29−import androidx.compose.runtime.saveable.rememberSaveable
30−import androidx.compose.runtime.saveable.rememberSaveableStateHolder
31−import androidx.compose.runtime.setValue
32−import androidx.compose.ui.Alignment
33−import androidx.compose.ui.Modifier
34−import androidx.compose.ui.platform.LocalContext
35−import androidx.compose.ui.res.stringResource
36−import androidx.compose.ui.semantics.LiveRegionMode
37−import androidx.compose.ui.semantics.Role
38−import androidx.compose.ui.semantics.liveRegion
39−import androidx.compose.ui.semantics.semantics
40−import androidx.compose.ui.unit.dp
41−import androidx.core.content.ContextCompat
42−import androidx.fragment.app.FragmentActivity
43−import androidx.lifecycle.Lifecycle
44−import androidx.lifecycle.LifecycleEventObserver
45−import androidx.lifecycle.compose.LocalLifecycleOwner
46−import soy.iko.opencode.R
47−import soy.iko.opencode.ui.components.LoadingSize
48−import soy.iko.opencode.ui.components.LoadingSpinner
49−
50−/**
51− * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
52− * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
53− * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
54− */
55−private val lockAuthenticators: Int
56− get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
57− BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
58− } else {
59− BiometricManager.Authenticators.BIOMETRIC_STRONG
60− }
61−
62−/** Whether device authentication is currently possible, so Settings can gate the toggle. */
63−fun canAuthenticateForAppLock(context: Context): Boolean =
64− BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
65−
66−/**
67− * Whether app-lock auth is *permanently* impossible on this device (no hardware, nothing
68− * enrolled, or a pending security update). Only these states may fail the gate open. A transient
69− * error (sensor busy / temporarily unavailable) must NOT fail open — that would unlock the app on
70− * a momentary glitch — so the gate stays locked and lets the user retry.
71− */
72−private fun appLockPermanentlyUnavailable(context: Context): Boolean =
73− when (BiometricManager.from(context).canAuthenticate(lockAuthenticators)) {
74− BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
75− BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
76− BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> true
77− else -> false
78− }
79−
80−/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
81−private fun Context.findFragmentActivity(): FragmentActivity? {
82− var ctx: Context? = this
83− while (ctx is ContextWrapper) {
84− if (ctx is FragmentActivity) return ctx
85− ctx = ctx.baseContext
86− }
87− return null
88−}
89−
90−/**
91− * Gates [content] behind device authentication when [enabled]. The content stays composed
92− * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
93− * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
94− * device can't authenticate, the gate is a no-op — the setting is only offered when
95− * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
96− */
97−@Composable
98−fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composable () -> Unit) {
99− if (!enabled) {
100− content()
101− return
102− }
103− val context = LocalContext.current
104− val activity = remember(context) { context.findFragmentActivity() }
105− if (activity == null) {
106− content()
107− return
108− }
109− // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
110− // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
111− // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
112− // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
113− if (appLockPermanentlyUnavailable(context)) {
114− // Surface a one-time warning so the user knows their app-lock setting is on but isn't
115− // actually protecting the app — without this they'd have to discover it by opening
116− // Settings and seeing the "unavailable" state. A Toast (not a snackbar) because the
117− // gate sits above the Scaffold/SnackbarHost; the warning is informational, not an action.
118− LaunchedEffect(Unit) {
119− android.widget.Toast.makeText(
120− context,
121− context.getString(R.string.app_lock_disabled_warning),
122− android.widget.Toast.LENGTH_LONG,
123− ).show()
124− }
125− content()
126− return
127− }
128−
129− // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
130− // saved-instance-state Bundle. On API 26-27 onSaveInstanceState runs *before* onStop, so a
131− // saveable `true` would be written to the Bundle before ON_STOP resets it; after a background
132− // process-kill and restore the app would come back unlocked with no biometric prompt — an
133− // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
134− // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
135− var unlocked by remember { mutableStateOf(false) }
136− // Wall-clock time of the most recent ON_STOP. Used with [reLockDelaySeconds] so a quick
137− // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
138− // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
139− // grace period only governs the in-memory re-lock decision across a surviving background stint.
140− //
141− // Plain remember (not rememberSaveable), matching `unlocked` above: the grace check gates on
142− // `unlocked`, which is non-saveable, so persisting only the stop timestamp through the Bundle
143− // is inconsistent — on a config change NOT in the manifest's configChanges (e.g. a locale
144− // change) `unlocked` resets to false while `lastStopTimeMs` would survive, and the grace
145− // check `if (unlocked && ...)` short-circuits to false anyway, re-prompting the user despite
146− // having unlocked seconds ago. Resetting both together on recreation is consistent: the new
147− // DisposableEffect observer gets ON_START synchronously, sees lastStopTimeMs == 0, and prompts
148− // cleanly (a recreation mid-grace re-prompts, which is the safe default for an app lock).
149− val lastStopTimeMs = remember { longArrayOf(0L) }
150− val reLockDelayMs = reLockDelaySeconds * 1000L
151−
152− val title = stringResource(R.string.app_lock_prompt_title)
153− val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
154−
155− // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
156− // BiometricPrompts, which throws.
157− var authInFlight by remember { mutableStateOf(false) }
158− // Monotonic id for the current prompt. The system dismisses an in-flight BiometricPrompt when
159− // the app backgrounds and posts its onAuthenticationError asynchronously; without this, that
160− // stale error could land after ON_START already started a NEW prompt and clear authInFlight
161− // mid-prompt — letting a recomposition or the "Unlock" button (enabled = !authInFlight) stack
162− // a second prompt. Each prompt captures its id and only mutates state while it's still current.
163− val promptId = remember { intArrayOf(0) }
164−
165− fun authenticate() {
166− if (authInFlight || unlocked) return
167− authInFlight = true
168− val myPrompt = ++promptId[0]
169− val executor = ContextCompat.getMainExecutor(activity)
170− val callback = object : BiometricPrompt.AuthenticationCallback() {
171− override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
172− if (myPrompt != promptId[0]) return
173− authInFlight = false
174− unlocked = true
175− }
176−
177− override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
178− // User cancelled / lockout / hardware error: stay locked and let them retry
179− // via the button rather than looping the system prompt. Ignore a stale error
180− // from a prompt already superseded by a newer one (see promptId above).
181− if (myPrompt != promptId[0]) return
182− authInFlight = false
183− }
184−
185− override fun onAuthenticationFailed() {
186− // A single non-matching attempt; the system UI lets the user try again.
187− }
188− }
189− val info = BiometricPrompt.PromptInfo.Builder()
190− .setTitle(title)
191− .setSubtitle(subtitle)
192− .setAllowedAuthenticators(lockAuthenticators)
193− .build()
194− runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
195− .onFailure { if (myPrompt == promptId[0]) authInFlight = false }
196− }
197−
198− // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
199− // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
200− // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
201− // change on return and never re-fire, stranding the user on the manual "Unlock" button.
202− // addObserver also delivers the current state's ON_START to a freshly-added observer, so
203− // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
204− // authenticate() make the overlapping triggers safe.
205− val lifecycleOwner = LocalLifecycleOwner.current
206− DisposableEffect(lifecycleOwner) {
207− val observer = LifecycleEventObserver { _, event ->
208− when (event) {
209− Lifecycle.Event.ON_STOP -> {
210− // Record the stop time but DON'T clear unlocked yet — the re-lock decision is
211− // made on the next ON_START, so a brief background stint under the grace period
212− // returns the user straight to their session without a re-prompt. authInFlight
213− // is still cleared because the system dismisses an in-flight prompt on background.
214− lastStopTimeMs[0] = System.currentTimeMillis()
215− authInFlight = false
216− }
217− Lifecycle.Event.ON_START -> {
218− // Only re-lock (and re-prompt) if more than the grace period elapsed since the
219− // last ON_STOP, or the app is freshly starting (lastStopTimeMs == 0).
220− val elapsed = System.currentTimeMillis() - lastStopTimeMs[0]
221− if (unlocked && lastStopTimeMs[0] != 0L && elapsed < reLockDelayMs) {
222− // Within the grace period — stay unlocked, no re-prompt.
223− } else {
224− unlocked = false
225− authenticate()
226− }
227− }
228− else -> {}
229− }
230− }
231− lifecycleOwner.lifecycle.addObserver(observer)
232− onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
233− }
234−
235− // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
236− // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
237− // separate windows z-ordered above the Activity content, so an open permission dialog or picker
238− // sheet would stay visible and interactive above the lock — an authentication bypass. Not
239− // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
240− // preserves navigation/scroll/input state across the re-lock so unlocking returns the user where
241− // they were, replacing the previous "keep composed underneath" approach.
242− val stateHolder = rememberSaveableStateHolder()
243− if (unlocked) {
244− stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
245− } else {
246− // A Fingerprint glyph hints at the primary unlock method when a biometric is enrolled;
247− // fall back to the generic lock on credential-only setups. Computed once: enrollment
248− // doesn't change while the gate is shown, and re-prompting on each frame is wasteful.
249− val biometricAvailable = remember(context) {
250− BiometricManager.from(context)
251− .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
252− BiometricManager.BIOMETRIC_SUCCESS
253− }
254− val unlockLabel = stringResource(R.string.app_unlock)
255− Surface(
256− modifier = Modifier.fillMaxSize(),
257− color = MaterialTheme.colorScheme.background,
258− ) {
259− // The whole surface is tappable to re-trigger the prompt (in addition to the button),
260− // so a user who dismissed the system prompt can retry by tapping anywhere — not just
261− // by finding the Unlock button.
262− Column(
263− modifier = Modifier
264− .fillMaxSize()
265− .clickable(
266− enabled = !authInFlight,
267− role = Role.Button,
268− onClickLabel = unlockLabel,
269− ) { authenticate() }
270− .padding(32.dp),
271− horizontalAlignment = Alignment.CenterHorizontally,
272− verticalArrangement = Arrangement.Center,
273− ) {
274− Icon(
275− if (biometricAvailable) Icons.Filled.Fingerprint else Icons.Filled.Lock,
276− contentDescription = null,
277− modifier = Modifier.size(56.dp),
278− tint = MaterialTheme.colorScheme.primary,
279− )
280− Text(
281− stringResource(R.string.app_locked),
282− style = MaterialTheme.typography.titleMedium,
283− modifier = Modifier
284− .padding(top = 16.dp)
285− .semantics { liveRegion = LiveRegionMode.Polite },
286− )
287− Text(
288− stringResource(R.string.app_locked_hint),
289− style = MaterialTheme.typography.bodyMedium,
290− color = MaterialTheme.colorScheme.onSurfaceVariant,
291− modifier = Modifier.padding(top = 4.dp),
292− )
293− Button(
294− onClick = { authenticate() },
295− enabled = !authInFlight,
296− modifier = Modifier.padding(top = 24.dp),
297− ) {
298− if (authInFlight) {
299− LoadingSpinner(
300− size = LoadingSize.Small,
301− color = MaterialTheme.colorScheme.primary,
302− )
303− Spacer(Modifier.size(8.dp))
304− }
305− Text(unlockLabel)
306− }
307− }
308− }
309− }
310−}
311−
312−private const val APP_LOCK_CONTENT_KEY = "app_lock_content"
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.ktdeleted−647
@@ -1,647 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import androidx.compose.animation.EnterTransition
4−import androidx.compose.animation.ExitTransition
5−import androidx.compose.animation.core.tween
6−import androidx.compose.animation.fadeIn
7−import androidx.compose.animation.fadeOut
8−import androidx.compose.animation.slideInHorizontally
9−import androidx.compose.animation.slideOutHorizontally
10−import androidx.compose.foundation.layout.Box
11−import androidx.compose.foundation.layout.BoxWithConstraints
12−import androidx.compose.foundation.layout.Row
13−import androidx.compose.foundation.layout.fillMaxHeight
14−import androidx.compose.foundation.layout.fillMaxSize
15−import androidx.compose.runtime.Composable
16−import androidx.compose.runtime.CompositionLocalProvider
17−import androidx.compose.runtime.LaunchedEffect
18−import androidx.compose.runtime.getValue
19−import androidx.compose.runtime.mutableStateOf
20−import androidx.compose.runtime.remember
21−import androidx.compose.runtime.rememberCoroutineScope
22−import androidx.compose.runtime.saveable.rememberSaveable
23−import androidx.compose.runtime.setValue
24−import androidx.compose.ui.Modifier
25−import androidx.compose.ui.input.key.Key
26−import androidx.compose.ui.input.key.KeyEventType
27−import androidx.compose.ui.input.key.isCtrlPressed
28−import androidx.compose.ui.input.key.isAltPressed
29−import androidx.compose.ui.input.key.key
30−import androidx.compose.ui.input.key.onPreviewKeyEvent
31−import androidx.compose.ui.input.key.type
32−import androidx.compose.ui.platform.LocalContext
33−import androidx.compose.ui.platform.LocalDensity
34−import androidx.compose.ui.unit.dp
35−import androidx.compose.ui.unit.LayoutDirection
36−import androidx.compose.ui.platform.LocalLayoutDirection
37−import androidx.navigation.NavType
38−import androidx.navigation.NavGraph.Companion.findStartDestination
39−import androidx.navigation.compose.NavHost
40−import androidx.navigation.compose.composable
41−import androidx.navigation.compose.currentBackStackEntryAsState
42−import androidx.navigation.compose.rememberNavController
43−import androidx.navigation.navArgument
44−import androidx.lifecycle.compose.collectAsStateWithLifecycle
45−import kotlinx.coroutines.launch
46−import soy.iko.opencode.di.AppContainer
47−import soy.iko.opencode.data.network.NetworkConfig
48−import soy.iko.opencode.ui.chat.ChatScreen
49−import soy.iko.opencode.ui.file.FileBrowserScreen
50−import soy.iko.opencode.ui.file.FileViewScreen
51−import soy.iko.opencode.ui.file.TwoPaneFileBrowser
52−import soy.iko.opencode.ui.server.ServerEditScreen
53−import soy.iko.opencode.ui.server.ServerListScreen
54−import soy.iko.opencode.ui.server.ServerSettingsScreen
55−import soy.iko.opencode.ui.search.GlobalSearchScreen
56−import androidx.compose.ui.platform.LocalHapticFeedback
57−import soy.iko.opencode.ui.components.LargeScreenNavRail
58−import soy.iko.opencode.ui.components.CompactNavBar
59−import soy.iko.opencode.ui.components.LocalChatTextScale
60−import soy.iko.opencode.ui.components.LocalCodeWrap
61−import soy.iko.opencode.ui.components.LocalReducedMotion
62−import soy.iko.opencode.ui.components.isReducedMotion
63−import soy.iko.opencode.ui.components.rememberGatedHaptics
64−import soy.iko.opencode.ui.session.SessionListScreen
65−import soy.iko.opencode.ui.session.TwoPaneSessionChat
66−import soy.iko.opencode.ui.settings.DiagnosticsScreen
67−import soy.iko.opencode.ui.settings.SettingsScreen
68−import soy.iko.opencode.ui.usage.UsageScreen
69−import soy.iko.opencode.ui.mcp.McpScreen
70−import soy.iko.opencode.util.findActivity
71−import soy.iko.opencode.util.runCatchingCancellable
72−
73−@Composable
74−fun OpencodeApp(container: AppContainer) {
75− val navController = rememberNavController()
76− val scope = rememberCoroutineScope()
77− val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
78− val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
79− val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
80− val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
81− val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
82− val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
83− val connection by container.activeConnection.collectAsStateWithLifecycle()
84− // Chat presentation preferences, provided to the whole nav graph so the markdown/code
85− // renderers honor the user's text-size and code-wrap choices everywhere.
86− val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
87− val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
88− // Haptics gating: collected once at the root so the in-app toggle can disable all
89− // performHapticFeedback calls everywhere via a single CompositionLocalProvider, instead
90− // of each call site needing to read the setting. Defaults true (the prior behavior) so
91− // the brief window before DataStore loads still haptics as before.
92− val hapticsEnabled by container.settingsStore.hapticsEnabled.collectAsStateWithLifecycle(initialValue = true)
93−
94− // The foreground service that holds priority while any run is active is driven from the
95− // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
96− // from here — a composition-scoped collector pauses while backgrounded, which is exactly
97− // when a run started via the notification reply needs the service to start.
98−
99− // When text is shared into the app, surface the session list so the user can pick
100− // (or create) a conversation to drop it into. The chosen session's draft is set
101− // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
102− // before auto-reconnect completes is retried once the connection is established.
103− // Track the last-handled share payloads (there's no share nonce on the container) so a
104− // *new* share arriving while already connected — a warm start where onNewIntent re-fires
105− // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
106− // carrying no new share (a bare server switch, which routes through a null connection, or
107− // a share the user already navigated for) leaves them where they are.
108− //
109− // Reset the remembered media when the pending list is drained (the chosen ChatScreen
110− // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
111− // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
112− // "already handled", silently skipping the session-list navigation the second time.
113− //
114− // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
115− // state and re-navigate to SESSIONS for a share the user already handled before the kill.
116− var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
117− var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
118− LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
119− // Reset the remembered handled state for each channel independently as it drains,
120− // so re-sharing the same payload after consumption is still recognized as new. A
121− // joint reset (only when both are empty) would drop a text-only re-share that
122− // follows a text+media share of the same text: the text channel matches its
123− // remembered value while the media channel is empty (not "new"), so neither clause
124− // fires and the share is silently swallowed.
125− if (pendingShare == null) handledShareText = null
126− if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
127− if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
128− if (connection == null) return@LaunchedEffect
129− val newShare = (pendingShare != null && pendingShare != handledShareText) ||
130− (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
131− if (!newShare) return@LaunchedEffect
132− handledShareText = pendingShare
133− handledSharedMedia = pendingSharedMedia
134− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
135− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
136− }
137− }
138−
139− // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
140− // "View report" on the cold-start crash dialog. Doesn't require a connection.
141− LaunchedEffect(pendingDiagnostics) {
142− if (!pendingDiagnostics) return@LaunchedEffect
143− container.consumePendingDiagnostics()
144− navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
145− }
146−
147− // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
148− // session and open it. The pending counter is consumed only *after* createSession
149− // succeeds, so a transient failure leaves the request pending to retry (on the next
150− // connection change or a re-tap that increments the counter) instead of silently
151− // dropping the tap.
152− LaunchedEffect(pendingNewSession, connection) {
153− if (pendingNewSession == 0) return@LaunchedEffect
154− val conn = connection ?: return@LaunchedEffect
155− val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
156− ?: return@LaunchedEffect
157− if (!container.consumePendingNewSession()) return@LaunchedEffect
158− // The file browser/viewer "Open in chat" action queues an @path reference as a
159− // pending share alongside the new-session request. Unlike onOpenSession (which
160− // injects it there), this effect creates the session directly and navigates to it
161− // — so consume the share here and seed the new session's draft, otherwise the
162− // text stays queued and silently misfires into the next session the user opens
163− // from the list. Mirror the onOpenSession pattern: set in-memory synchronously
164− // so ChatScreen sees it on first composition, then persist to disk in the back.
165− container.consumePendingShare()?.let { shareText ->
166− container.draftStore.setImmediate(session.id, shareText)
167− scope.launch { runCatchingCancellable { container.draftStore.set(session.id, shareText) } }
168− }
169− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
170− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
171− }
172− navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
173− }
174−
175− // The chat text-size preference multiplies on top of the OS font scale (sp already
176− // honors the system setting), so a large system size × large app size can reach ~2×
177− // and break tight layouts. Clamp the provided app scale so the product with the OS
178− // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
179− // below the user's chosen value's intent (floor of 1× app scale is preserved when the
180− // system size alone already exceeds the cap).
181− val systemFontScale = LocalDensity.current.fontScale
182− val clampedChatTextScale = if (systemFontScale <= 0f) {
183− chatTextScale
184− } else {
185− chatTextScale.coerceAtMost(
186− (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
187− )
188− }
189−
190− // Capture the reduced-motion preference once (in the composable scope) so the
191− // non-composable NavHost transition lambdas can use it to skip animations, and provide
192− // it via LocalReducedMotion so in-composition animations (chat row placement, banners,
193− // image zoom) honor the same accessibility preference.
194− val reducedMotion = isReducedMotion()
195− CompositionLocalProvider(
196− LocalChatTextScale provides clampedChatTextScale,
197− LocalCodeWrap provides codeWrap,
198− LocalReducedMotion provides reducedMotion,
199− LocalHapticFeedback provides rememberGatedHaptics(hapticsEnabled),
200− ) {
201− // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
202− // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
203− val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
204− // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
205− // the open conversation side by side instead of a single-pane back stack.
206− // Posture-aware: a foldable in HALF_OPENED (tabletop/book) mode has a hinge across the
207− // content area — splitting there would place the list/detail boundary on the hinge, which
208− // is hard to read and interact with. Detect a FoldingFeature and suppress two-pane in
209− // those postures so the app falls back to a single-pane layout that respects the fold.
210− val windowContext = LocalContext.current
211− var foldState by remember { mutableStateOf<androidx.window.layout.FoldingFeature?>(null) }
212− androidx.compose.runtime.LaunchedEffect(windowContext) {
213− val activity = windowContext.findActivity()
214− if (activity != null) {
215− androidx.window.layout.WindowInfoTracker.getOrCreate(windowContext)
216− .windowLayoutInfo(activity)
217− .collect { info ->
218− foldState = info.displayFeatures
219− .filterIsInstance<androidx.window.layout.FoldingFeature>()
220− .firstOrNull()
221− }
222− }
223− }
224− val isHalfOpened = foldState?.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED
225− BoxWithConstraints(
226− modifier = Modifier.onPreviewKeyEvent { ev ->
227− // Global keyboard shortcuts for hardware-keyboard users (tablets, DeX, Chromebook):
228− // Ctrl+1..6 switches between top-level destinations. Only fires on KeyDown so a
229− // held Ctrl doesn't re-trigger on every repeat. Per-screen shortcuts (Ctrl+K palette,
230− // Esc stop, Ctrl+F find) are handled in each screen's own onPreviewKeyEvent and take
231− // precedence (they're closer to the focused composable in the tree).
232− if (ev.type != KeyEventType.KeyDown || !ev.isCtrlPressed) {
233− // Alt+Left (a familiar desktop "Back" gesture) pops the back stack. Not gated
234− // on Ctrl so it works as a standalone modifier. Per-screen shortcuts (Esc
235− // stop, Ctrl+F find, Ctrl+K palette) register their own onPreviewKeyEvent
236− // closer to the focused composable and consume first, so this only pops when
237− // nothing nearer handled the key — avoiding conflicts with open dialogs and
238− // text fields. Esc is deliberately NOT mapped to back here for that reason.
239− if (ev.type == KeyEventType.KeyDown &&
240− ev.key == Key.DirectionLeft &&
241− ev.isAltPressed
242− ) {
243− if (navController.previousBackStackEntry != null) {
244− navController.popBackStack()
245− return@onPreviewKeyEvent true
246− }
247− }
248− return@onPreviewKeyEvent false
249− }
250− val route = when (ev.key) {
251− Key.NumPad1, Key.One -> Routes.SESSIONS
252− Key.NumPad2, Key.Two -> Routes.SEARCH
253− Key.NumPad3, Key.Three -> Routes.FILES
254− Key.NumPad4, Key.Four -> Routes.USAGE
255− Key.NumPad5, Key.Five -> Routes.MCP
256− Key.NumPad6, Key.Six -> Routes.SETTINGS
257− // Ctrl+7 navigates to the server list (the 7th nav-rail destination).
258− // Previously Ctrl+1..6 covered the other destinations but Servers was
259− // missing, an inconsistency since the nav rail shows 7 destinations.
260− Key.NumPad7, Key.Seven -> Routes.SERVERS
261− else -> null
262− }
263− if (route != null) {
264− // Servers is reachable even without an active connection (it's the start
265− // destination and handles the not-connected state); the other routes
266− // require a connection to be meaningful.
267− if (route == Routes.SERVERS || connection != null) {
268− navController.navigate(route) {
269− popUpTo(navController.graph.findStartDestination().id) { saveState = true }
270− launchSingleTop = true
271− restoreState = true
272− }
273− true
274− } else false
275− } else false
276− },
277− ) {
278− val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp &&
279− connection != null &&
280− !isHalfOpened
281− // On large screens, surface a navigation rail alongside the content so top-level
282− // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
283− // screen's overflow menu. Mirrors the standard large-screen M3 layout.
284− val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
285− // Observe the current destination reactively so the rail's selected item tracks navigation
286− // (reading currentDestination once would go stale after the first composition).
287− val currentEntry by navController.currentBackStackEntryAsState()
288− val currentRoute = currentEntry?.destination?.route
289−
290− // Open a session requested by a notification tap or deep link, once connected.
291− LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
292− val pending = pendingOpenSession ?: return@LaunchedEffect
293− if (connection == null) return@LaunchedEffect
294− // If the notification body-tap carries a profile id from a different server,
295− // switch to it first so the session opens under the server that ran it, not
296− // whichever is active. The effect re-runs when connection changes (keyed on it),
297− // so on the second run the profile matches and the open proceeds.
298− if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
299− // If the originating profile no longer exists (e.g. deleted after the
300− // notification was queued), connectByProfileId returns false: the active
301− // connection is unchanged and none of this effect's keys change, so it
302− // would never re-run — the request would sit in _pendingOpenSession
303− // forever and re-trigger on every later connection change. Consume it so
304− // the stale request is dropped, mirroring the invalid-deep-link path in
305− // MainActivity.
306− if (!container.connectByProfileId(pending.profileId)) {
307− container.consumePendingOpenSession()
308− return@LaunchedEffect
309− }
310− return@LaunchedEffect // re-run on the new connection
311− }
312− // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
313− // route) consumes the request and opens it in the detail pane. But it only
314− // exists while the NavHost is on SESSIONS, so if the user is currently on
315− // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
316− // pending request is never consumed and the session never opens. Don't consume
317− // here; let TwoPaneSessionChat's effect do it once it's composed.
318− if (isTwoPane) {
319− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
320− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
321− }
322− return@LaunchedEffect
323− }
324− container.consumePendingOpenSession()
325− // Ensure SESSIONS sits on the back stack below the chat destination so backing
326− // out of the chat lands on the session list (not the server list). Bring SESSIONS
327− // to front (or push it if a cold-start deep link left only SERVERS on the stack),
328− // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
329− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
330− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
331− }
332− navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
333− }
334−
335− // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
336− // connection exists (the viewer fetches content from the server). Path comes pre-validated
337− // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
338− // hosted on the FILES route) consumes the request into its detail pane, mirroring how
339− // session deep links are routed — so don't consume or push the full-screen viewer here.
340− LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
341− val path = pendingOpenFile ?: return@LaunchedEffect
342− if (connection == null) return@LaunchedEffect
343− if (isTwoPane) {
344− if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
345− navController.navigate(Routes.FILES) { launchSingleTop = true }
346− }
347− return@LaunchedEffect
348− }
349− container.consumePendingOpenFile()
350− if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
351− navController.navigate(Routes.FILES) { launchSingleTop = true }
352− }
353− navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
354− }
355−
356− val navHost = @Composable {
357− NavHost(
358− modifier = Modifier.fillMaxSize(),
359− navController = navController,
360− startDestination = Routes.SERVERS,
361− // Motion-aware transitions: respect the user's reduced-motion setting
362− // (Developer Options → animator scale 0) by skipping animations entirely.
363− // Per-destination tuning: a chat→chat navigation (switching peer conversations
364− // within the same surface) crossfades instead of sliding, so it reads as a peer
365− // switch rather than drilling into a new section. Top-level destinations and
366− // detail pushes keep the horizontal slide.
367− enterTransition = {
368− if (reducedMotion) {
369− EnterTransition.None
370− } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
371− fadeIn(tween(NetworkConfig.motionFadeDurationMs))
372− } else {
373− slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
374− fadeIn(tween(NetworkConfig.motionFadeDurationMs))
375− }
376− },
377− exitTransition = {
378− if (reducedMotion) {
379− ExitTransition.None
380− } else {
381− fadeOut(tween(NetworkConfig.motionFadeDurationMs))
382− }
383− },
384− popEnterTransition = {
385− if (reducedMotion) {
386− EnterTransition.None
387− } else {
388− fadeIn(tween(NetworkConfig.motionFadeDurationMs))
389− }
390− },
391− popExitTransition = {
392− if (reducedMotion) {
393− ExitTransition.None
394− } else {
395− slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
396− fadeOut(tween(NetworkConfig.motionFadeDurationMs))
397− }
398− },
399− ) {
400−
401− composable(Routes.SERVERS) {
402− ServerListScreen(
403− container = container,
404− onConnected = {
405− // Only leave the server list if we're still on it. A pending deep-link /
406− // notification open sets activeConnection (which drives the open-session
407− // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
408− // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
409− // on top of the chat it just opened, burying it. Order-independent: if the
410− // deep link already navigated away from SERVERS, this is a no-op.
411− if (navController.currentDestination?.route == Routes.SERVERS) {
412− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
413− }
414− },
415− onAddProfile = { navController.navigate(Routes.SERVER_EDIT) },
416− onServerSettings = { id -> navController.navigate(Routes.serverSettings(id)) },
417− )
418− }
419−
420− composable(Routes.SERVER_EDIT) {
421− ServerEditScreen(
422− container = container,
423− onDone = { navController.popBackStack() },
424− )
425− }
426−
427− composable(
428− route = "${Routes.SERVER_SETTINGS}/{id}",
429− arguments = listOf(navArgument("id") { type = NavType.StringType }),
430− ) { entry ->
431− ServerSettingsScreen(
432− container = container,
433− profileId = entry.arguments?.getString("id").orEmpty(),
434− onDone = { navController.popBackStack() },
435− )
436− }
437−
438− composable(Routes.SESSIONS) {
439− if (isTwoPane) {
440− TwoPaneSessionChat(
441− container = container,
442− onOpenFiles = { navController.navigate(Routes.FILES) },
443− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
444− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
445− onOpenSearch = { navController.navigate(Routes.SEARCH) },
446− onDisconnect = {
447− scope.launch { runCatchingCancellable { container.disconnect() } }
448− navController.popBackStack(Routes.SERVERS, inclusive = false)
449− },
450− onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
451− onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
452− )
453− } else {
454− SessionListScreen(
455− container = container,
456− onOpenSession = { id ->
457− // Set the draft synchronously in-memory before navigating so the
458− // ChatScreen sees it on first composition. The async persistence
459− // to disk happens in the background via draftStore.set.
460− container.consumePendingShare()?.let { shareText ->
461− container.draftStore.setImmediate(id, shareText)
462− scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
463− }
464− navController.navigate(Routes.chat(id))
465− },
466− onDisconnect = {
467− scope.launch { runCatchingCancellable { container.disconnect() } }
468− navController.popBackStack(Routes.SERVERS, inclusive = false)
469− },
470− onOpenFiles = { navController.navigate(Routes.FILES) },
471− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
472− onOpenSearch = { navController.navigate(Routes.SEARCH) },
473− onAddServer = { navController.navigate(Routes.SERVER_EDIT) },
474− onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
475− )
476− }
477− }
478−
479− composable(
480− route = "${Routes.CHAT}/{sessionId}?focus={focus}",
481− arguments = listOf(
482− navArgument("sessionId") { type = NavType.StringType },
483− navArgument("focus") { type = NavType.StringType; nullable = true; defaultValue = null },
484− ),
485− ) { entry ->
486− ChatScreen(
487− container = container,
488− sessionId = entry.arguments?.getString("sessionId").orEmpty(),
489− focusMessageId = entry.arguments?.getString("focus"),
490− onBack = { navController.popBackStack() },
491− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
492− onOpenSession = { id ->
493− navController.navigate(Routes.chat(id)) {
494− // Prevent the same chat from being pushed twice when re-tapping a
495− // session deep link, and keep the back stack bounded when chaining
496− // chat → linked chat → … (otherwise every hop piles a new entry).
497− launchSingleTop = true
498− }
499− },
500− onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
501− )
502− }
503−
504− composable(Routes.FILES) {
505− if (isTwoPane) {
506− TwoPaneFileBrowser(
507− container = container,
508− onBack = { navController.popBackStack() },
509− )
510− } else {
511− FileBrowserScreen(
512− container = container,
513− onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
514− onBack = { navController.popBackStack() },
515− )
516− }
517− }
518−
519− composable(
520− route = "${Routes.FILE_VIEW}?path={path}&line={line}",
521− arguments = listOf(
522− navArgument("path") { type = NavType.StringType; defaultValue = "" },
523− navArgument("line") { type = NavType.IntType; defaultValue = -1 },
524− ),
525− ) { entry ->
526− val line = entry.arguments?.getInt("line") ?: -1
527− FileViewScreen(
528− container = container,
529− path = entry.arguments?.getString("path").orEmpty(),
530− initialLine = line.takeIf { it > 0 },
531− onBack = { navController.popBackStack() },
532− )
533− }
534−
535− composable(Routes.SETTINGS) {
536− SettingsScreen(
537− container = container,
538− onBack = { navController.popBackStack() },
539− // Navigate to the server list as a new entry on top of Sessions so the
540− // user can back out to their conversations. Previously this popped back
541− // to the root SERVERS destination, which removed Sessions from the stack
542− // and left the user unable to return to their conversations without
543− // reconnecting. Pop only Settings, then push SERVERS; back from the
544− // server list lands on Sessions (the screen the user was on before
545− // Settings), and connecting pushes a fresh SESSIONS as usual.
546− onManageServers = {
547− navController.navigate(Routes.SERVERS) {
548− popUpTo(Routes.SETTINGS) { inclusive = true }
549− launchSingleTop = true
550− }
551− },
552− onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
553− onOpenUsage = { navController.navigate(Routes.USAGE) },
554− onOpenMcp = { navController.navigate(Routes.MCP) },
555− onEditProfile = { id -> navController.navigate(Routes.serverSettings(id)) },
556− )
557− }
558−
559− composable(Routes.DIAGNOSTICS) {
560− DiagnosticsScreen(onBack = { navController.popBackStack() })
561− }
562−
563− composable(Routes.USAGE) {
564− UsageScreen(
565− container = container,
566− onBack = { navController.popBackStack() },
567− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
568− )
569− }
570−
571− composable(Routes.MCP) {
572− McpScreen(container = container, onBack = { navController.popBackStack() })
573− }
574−
575− composable(Routes.SEARCH) {
576− GlobalSearchScreen(
577− container = container,
578− onOpenSession = { id, focusMessageId -> navController.navigate(Routes.chat(id, focusMessageId)) },
579− onBack = { navController.popBackStack() },
580− )
581− }
582− }
583− }
584−
585− // Collect unread/run state for nav badges (rail on large screens, bottom bar on
586− // compact screens), so a user on Files/Settings sees pending activity at a glance.
587− // Muted sessions are excluded from the nav badge so muting suppresses the badge here
588− // too, not just the in-list row badge — mirrors the launcher-icon badge in
589− // SessionListScreen and the SSE-driven unread tracking in AppContainer.
590− val unread by container.unread.collectAsStateWithLifecycle()
591− val mutedSessions by container.mutedSessions.collectAsStateWithLifecycle()
592− val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
593− val totalUnread = remember(unread, mutedSessions) {
594− unread.filterKeys { it !in mutedSessions }.values.sum()
595− }
596−
597− if (showNavRail) {
598− Row(modifier = Modifier.fillMaxSize()) {
599− LargeScreenNavRail(
600− currentRoute = currentRoute,
601− connected = connection != null,
602− unreadCount = totalUnread,
603− runActive = anyRunActive,
604− // Use the standard M3 nav-bar pattern: popUpTo the start destination
605− // saving state, and restoreState on re-entry, so switching between
606− // top-level tabs via the rail preserves each tab's scroll/selection
607− // state and back always exits cleanly instead of popping through
608− // every sibling tab the user visited.
609− onNavigate = { route ->
610− navController.navigate(route) {
611− popUpTo(navController.graph.findStartDestination().id) {
612− saveState = true
613− }
614− launchSingleTop = true
615− restoreState = true
616− }
617− },
618− )
619− Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
620− }
621− } else {
622− // Compact screens: a bottom NavigationBar surfaces the top-level destinations one
623− // tap away (Sessions/Search/Files/Settings/Servers) instead of buried in each
624− // screen's overflow menu. The navHost fills the space above the bar; the bar's
625− // own height is accounted for by the NavigationBar's intrinsic measurement.
626− androidx.compose.foundation.layout.Column(modifier = Modifier.fillMaxSize()) {
627− Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
628− CompactNavBar(
629− currentRoute = currentRoute,
630− connected = connection != null,
631− unreadCount = totalUnread,
632− runActive = anyRunActive,
633− onNavigate = { route ->
634− navController.navigate(route) {
635− popUpTo(navController.graph.findStartDestination().id) {
636− saveState = true
637− }
638− launchSingleTop = true
639− restoreState = true
640− }
641− },
642− )
643− }
644− }
645− }
646− }
647−}
app/src/main/java/soy/iko/opencode/ui/Routes.ktdeleted−36
@@ -1,36 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−/** String routes for the app's NavHost. Kept simple and centralized. */
4−object Routes {
5− const val SERVERS = "servers"
6− const val SESSIONS = "sessions"
7− const val SETTINGS = "settings"
8− const val DIAGNOSTICS = "diagnostics"
9− const val USAGE = "usage"
10− const val MCP = "mcp"
11− const val FILES = "files"
12− const val SEARCH = "search"
13−
14− const val SERVER_EDIT = "server_edit"
15− const val SERVER_SETTINGS = "server_settings"
16− fun serverSettings(id: String) = "$SERVER_SETTINGS/${android.net.Uri.encode(id)}"
17−
18− const val CHAT = "chat"
19− fun chat(sessionId: String, focusMessageId: String? = null): String {
20− val base = "$CHAT/${android.net.Uri.encode(sessionId)}"
21− // An optional focusMessageId scrolls the chat to (and briefly highlights) that message,
22− // used by global search to jump directly to the matched message instead of the tail.
23− return if (focusMessageId != null && focusMessageId.isNotBlank()) {
24− "$base?focus=${android.net.Uri.encode(focusMessageId)}"
25− } else {
26− base
27− }
28− }
29−
30− const val FILE_VIEW = "file_view"
31− fun fileView(path: String, line: Int? = null): String {
32− val base = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
33− // A positive [line] scrolls the viewer to that 1-based line (from a search hit).
34− return if (line != null && line > 0) "$base&line=$line" else base
35− }
36−}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktdeleted−16
@@ -1,16 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewmodel.CreationExtras
5−import androidx.lifecycle.viewmodel.initializer
6−import androidx.lifecycle.viewmodel.viewModelFactory
7−
8−/**
9− * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10− * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11− */
12−inline fun <reified VM : ViewModel> vmFactory(
13− crossinline create: (CreationExtras) -> VM,
14−) = viewModelFactory {
15− initializer { create(this) }
16−}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktdeleted−318
@@ -1,318 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.imePadding
9−import androidx.compose.foundation.layout.navigationBarsPadding
10−import androidx.compose.foundation.layout.padding
11−import androidx.compose.foundation.layout.size
12−import androidx.compose.foundation.lazy.LazyColumn
13−import androidx.compose.foundation.lazy.items
14−import androidx.compose.foundation.lazy.rememberLazyListState
15−import androidx.compose.foundation.text.KeyboardActions
16−import androidx.compose.foundation.text.KeyboardOptions
17−import androidx.compose.material.icons.Icons
18−import androidx.compose.material.icons.filled.Check
19−import androidx.compose.material.icons.filled.Close
20−import androidx.compose.material.icons.filled.Search
21−import androidx.compose.material3.CircularProgressIndicator
22−import androidx.compose.material3.ExperimentalMaterial3Api
23−import androidx.compose.material3.Icon
24−import androidx.compose.material3.IconButton
25−import androidx.compose.material3.MaterialTheme
26−import androidx.compose.material3.ModalBottomSheet
27−import androidx.compose.material3.OutlinedTextField
28−import androidx.compose.material3.Text
29−import androidx.compose.material3.TextButton
30−import androidx.compose.material3.rememberModalBottomSheetState
31−import androidx.compose.runtime.Composable
32−import androidx.compose.runtime.LaunchedEffect
33−import androidx.compose.runtime.getValue
34−import androidx.compose.runtime.mutableStateOf
35−import androidx.compose.runtime.remember
36−import androidx.compose.runtime.rememberCoroutineScope
37−import androidx.compose.runtime.saveable.rememberSaveable
38−import androidx.compose.runtime.setValue
39−import androidx.compose.ui.Alignment
40−import androidx.compose.ui.Modifier
41−import androidx.compose.ui.focus.FocusRequester
42−import androidx.compose.ui.focus.focusRequester
43−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
44−import androidx.compose.ui.platform.LocalHapticFeedback
45−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
46−import androidx.compose.ui.res.stringResource
47−import androidx.compose.ui.semantics.Role
48−import androidx.compose.ui.semantics.contentDescription
49−import androidx.compose.ui.semantics.role
50−import androidx.compose.ui.semantics.selectableGroup
51−import androidx.compose.ui.semantics.selected
52−import androidx.compose.ui.semantics.semantics
53−import androidx.compose.ui.text.input.ImeAction
54−import androidx.compose.ui.text.style.TextOverflow
55−import androidx.compose.ui.unit.dp
56−import androidx.compose.foundation.layout.Box
57−import kotlinx.coroutines.launch
58−import soy.iko.opencode.data.model.Agent
59−import soy.iko.opencode.data.network.NetworkConfig
60−import soy.iko.opencode.R
61−
62−/**
63− * Flat LazyColumn index of the selected agent, offset by the leading "Default" row when it's
64− * shown. A null selection targets that Default row (index 0). -1 when the selection isn't in
65− * the current list.
66− */
67−/** Whether the synthetic "Default" agent row matches the current search query. */
68−private fun defaultAgentMatches(query: String, label: String, desc: String): Boolean =
69− query.isEmpty() ||
70− label.contains(query, ignoreCase = true) ||
71− desc.contains(query, ignoreCase = true)
72−
73−private fun selectedAgentIndex(
74− distinctAgents: List<Agent>,
75− selected: String?,
76− defaultMatches: Boolean,
77−): Int {
78− if (selected == null) return if (defaultMatches) 0 else -1
79− val base = if (defaultMatches) 1 else 0
80− val pos = distinctAgents.indexOfFirst { it.name == selected }
81− return if (pos >= 0) base + pos else -1
82−}
83−
84−/** Bottom sheet that lists every agent and lets the user pick one. */
85−@OptIn(ExperimentalMaterial3Api::class)
86−@Composable
87−fun AgentPickerSheet(
88− agents: List<Agent>,
89− selected: String?,
90− loading: Boolean,
91− error: Boolean,
92− onSelect: (Agent?) -> Unit,
93− onRetry: () -> Unit,
94− onDismiss: () -> Unit,
95−) {
96− // Hoist the SheetState so selection can animate the sheet out (slide) instead of
97− // snap-dismissing by tearing down the composition. skipPartiallyExpanded = true so the
98− // sheet opens at full height and the list's heightIn cap resolves against a stable sheet,
99− // not the half-height partial state (which previously left the list at ~25% of the screen
100− // until the user dragged up).
101− val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
102− val scope = rememberCoroutineScope()
103− ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
104− Text(
105− stringResource(R.string.agent),
106− style = MaterialTheme.typography.titleMedium,
107− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
108− )
109− if (loading) {
110− val loadingLabel = stringResource(R.string.loading_agents)
111− Column(
112− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
113− horizontalAlignment = Alignment.CenterHorizontally,
114− ) {
115− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
116− Text(
117− stringResource(R.string.loading_agents),
118− style = MaterialTheme.typography.bodyMedium,
119− color = MaterialTheme.colorScheme.onSurfaceVariant,
120− modifier = Modifier.padding(top = 8.dp),
121− )
122− }
123− } else if (error && agents.isEmpty()) {
124− Column(
125− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
126− horizontalAlignment = Alignment.CenterHorizontally,
127− ) {
128− Text(
129− stringResource(R.string.load_failed),
130− style = MaterialTheme.typography.bodyMedium,
131− color = MaterialTheme.colorScheme.error,
132− )
133− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
134− }
135− } else if (agents.isEmpty()) {
136− Text(
137− stringResource(R.string.no_agents),
138− style = MaterialTheme.typography.bodyMedium,
139− color = MaterialTheme.colorScheme.onSurfaceVariant,
140− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
141− )
142− } else {
143− var query by rememberSaveable { mutableStateOf("") }
144− val keyboardController = LocalSoftwareKeyboardController.current
145− val haptics = LocalHapticFeedback.current
146− // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
147− // it for a short catalog that's faster to scan by eye than to filter.
148− val searchFocus = remember { FocusRequester() }
149− LaunchedEffect(Unit) {
150− if (agents.size > NetworkConfig.pickerSearchAutofocusThreshold) {
151− runCatching { searchFocus.requestFocus() }
152− }
153− }
154− OutlinedTextField(
155− value = query,
156− onValueChange = { query = it },
157− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
158− placeholder = { Text(stringResource(R.string.search_agents)) },
159− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
160− trailingIcon = if (query.isNotEmpty()) {
161− {
162− IconButton(onClick = { query = "" }) {
163− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
164− }
165− }
166− } else null,
167− singleLine = true,
168− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
169− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
170− )
171− // Dedupe the source list once (keyed on `agents`, not the per-keystroke `filtered`),
172− // so distinctBy doesn't re-walk the catalog on every character typed.
173− val distinctAgents = remember(agents) { agents.distinctBy { it.name } }
174− val filtered = remember(distinctAgents, query) {
175− val q = query.trim()
176− if (q.isEmpty()) distinctAgents
177− else distinctAgents.filter {
178− it.name.contains(q, ignoreCase = true) ||
179− it.displayDescription.contains(q, ignoreCase = true)
180− }
181− }
182− // The "Default" option participates in the search too: show it when the query is
183− // empty or matches its label/description. Without this it either always showed
184− // (ignoring the filter) or, when a query matched no named agent, disappeared with
185− // the whole list — leaving the default unselectable.
186− val defaultLabel = stringResource(R.string.default_agent)
187− val defaultDesc = stringResource(R.string.default_agent_desc)
188− val q = query.trim()
189− val defaultMatches = defaultAgentMatches(q, defaultLabel, defaultDesc)
190− if (filtered.isEmpty() && !defaultMatches) {
191− Text(
192− stringResource(R.string.no_agents_match, query.trim()),
193− style = MaterialTheme.typography.bodyMedium,
194− color = MaterialTheme.colorScheme.onSurfaceVariant,
195− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
196− )
197− } else {
198− val listState = rememberLazyListState()
199− // Flat index of the selected agent, offset by the leading "Default" row when
200− // it's shown. selected == null targets that Default row (index 0). -1 when the
201− // selection isn't in the current list. Computed against `filtered` (not the
202− // unfiltered distinctAgents) so a query that hides agents before the selection
203− // still maps to the right visible row.
204− val selectedIndex = remember(filtered, selected, defaultMatches) {
205− selectedAgentIndex(filtered, selected, defaultMatches)
206− }
207− // Re-scroll to the selected item whenever the filter changes (not just once on
208− // open), so clearing a filter that had hidden the selection brings it back into
209− // view instead of leaving the list at the top.
210− LaunchedEffect(filtered, defaultMatches) {
211− if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
212− }
213− // Cap the list height against the screen, not the (partial) sheet, so the list
214− // is a predictable height regardless of drag position. weight(1f) would also
215− // work but heightIn keeps the sheet compact for short catalogs.
216− LazyColumn(
217− state = listState,
218− modifier = Modifier
219− .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
220− .imePadding()
221− .navigationBarsPadding()
222− .semantics { selectableGroup() },
223− ) {
224− if (defaultMatches) {
225− item(key = "__default") {
226− Column(
227− modifier = Modifier
228− .fillMaxWidth()
229− .semantics(mergeDescendants = true) {
230− this.selected = (selected == null)
231− }
232− .clickable(role = Role.RadioButton) {
233− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
234− // Animate the sheet out before tearing down, so the selection
235− // reads as a smooth transition instead of a snap disappearance.
236− scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
237− onSelect(null)
238− }
239− .padding(horizontal = 24.dp, vertical = 12.dp),
240− ) {
241− Row(verticalAlignment = Alignment.CenterVertically) {
242− Text(
243− stringResource(R.string.default_agent),
244− style = MaterialTheme.typography.bodyLarge,
245− color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
246− modifier = Modifier.weight(1f),
247− )
248− if (selected == null) {
249− Icon(
250− Icons.Filled.Check,
251− contentDescription = null,
252− tint = MaterialTheme.colorScheme.primary,
253− modifier = Modifier.size(20.dp),
254− )
255− }
256− }
257− Text(
258− stringResource(R.string.default_agent_desc),
259− style = MaterialTheme.typography.bodySmall,
260− color = MaterialTheme.colorScheme.onSurfaceVariant,
261− )
262− }
263− }
264− }
265− // distinctBy the name so a server that returns two agents with the same name
266− // (e.g. a built-in colliding with a project-level one) can't crash the list
267− // with LazyColumn's "Key was already used".
268− // Namespace the agent keys so an agent literally named "__default" can't
269− // collide with the sentinel key of the default item above (a duplicate key
270− // crashes LazyColumn).
271− items(filtered, key = { "agent_" + it.name }) { agent ->
272− val isSelected = agent.name == selected
273− Column(
274− modifier = Modifier
275− .fillMaxWidth()
276− .semantics(mergeDescendants = true) {
277− this.selected = isSelected
278− }
279− .clickable(role = Role.RadioButton) {
280− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
281− scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
282− onSelect(agent)
283− }
284− .padding(horizontal = 24.dp, vertical = 12.dp),
285− ) {
286− Row(verticalAlignment = Alignment.CenterVertically) {
287− Text(
288− agent.name,
289− style = MaterialTheme.typography.bodyLarge,
290− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
291− modifier = Modifier.weight(1f),
292− )
293− if (isSelected) {
294− Icon(
295− Icons.Filled.Check,
296− contentDescription = null,
297− tint = MaterialTheme.colorScheme.primary,
298− modifier = Modifier.size(20.dp),
299− )
300− }
301− }
302− Text(
303− agent.displayDescription,
304− style = MaterialTheme.typography.bodySmall,
305− color = MaterialTheme.colorScheme.onSurfaceVariant,
306− // Cap at 2 lines (matching the command picker) so a verbose
307− // description doesn't make the row arbitrarily tall and push the
308− // radio affordance out of alignment across the list.
309− maxLines = 2,
310− overflow = TextOverflow.Ellipsis,
311− )
312− }
313− }
314− }
315− }
316− }
317− }
318−}
app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.ktdeleted−12
@@ -1,12 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−/** Outcome of turning a picked Uri into a [PendingAttachment] (see `Uri.toAttachmentResult`). */
4−sealed interface AttachmentResult {
5− data class Ok(val attachment: PendingAttachment) : AttachmentResult
6−
7− /** The file exceeded the size cap ([soy.iko.opencode.data.network.NetworkConfig.maxAttachmentBytes]). */
8− data object TooLarge : AttachmentResult
9−
10− /** The file couldn't be read/resolved. */
11− data object Failed : AttachmentResult
12−}
app/src/main/java/soy/iko/opencode/ui/chat/Attachments.ktdeleted−117
@@ -1,117 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import android.content.Context
4−import android.net.Uri
5−import android.provider.OpenableColumns
6−import android.util.Base64
7−import androidx.core.content.FileProvider
8−import soy.iko.opencode.data.model.FilePromptPart
9−import soy.iko.opencode.data.network.NetworkConfig
10−import soy.iko.opencode.util.runCatchingCancellable
11−import kotlinx.coroutines.Dispatchers
12−import kotlinx.coroutines.withContext
13−import java.io.File
14−
15−/**
16− * Read [uri]'s bytes off the main thread, base64-encode them into a `data:` URL, and wrap
17− * the result as a [PendingAttachment] ready to send. Enforces the size cap. The original
18− * [uri] is kept as the thumbnail model for images (Coil loads content Uris directly).
19− */
20−suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withContext(Dispatchers.IO) {
21− val resolver = context.contentResolver
22− val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
23− val name = displayName(context) ?: defaultName(mime)
24− val max = NetworkConfig.maxAttachmentBytes
25− // Cheap pre-check: if the provider reports a size, reject an oversize file before reading
26− // a single byte into memory. The picker can hand back arbitrarily large files (e.g. a
27− // multi-hundred-MB video), so reading first and checking after would OOM the app.
28− val declaredSize = fileSize(context)
29− if (declaredSize != null && declaredSize > max) return@withContext AttachmentResult.TooLarge
30− // Read with a hard cap so a provider that under-reports or omits its size still can't OOM
31− // us: stop as soon as we've read one byte past the cap, then reject.
32− val bytes = runCatchingCancellable {
33− resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
34− }.getOrNull() ?: return@withContext AttachmentResult.Failed
35− if (bytes.size > max) return@withContext AttachmentResult.TooLarge
36− val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
37− val dataUrl = "data:$mime;base64,$b64"
38− AttachmentResult.Ok(
39− PendingAttachment(
40− id = java.util.UUID.randomUUID().toString(),
41− name = name,
42− mime = mime,
43− previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
44− part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
45− ),
46− )
47−}
48−
49−/** The provider-reported byte size of the content [uri], or null if it isn't exposed. */
50−private fun Uri.fileSize(context: Context): Long? = runCatching {
51− context.contentResolver.query(this, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
52− val idx = cursor.getColumnIndex(OpenableColumns.SIZE)
53− if (idx >= 0 && cursor.moveToFirst() && !cursor.isNull(idx)) cursor.getLong(idx) else null
54− }
55−}.getOrNull()
56−
57−/** Read up to [limit] bytes from the stream without materializing the whole file, so an
58− * oversize source can be rejected on a bounded amount of memory. Reaching [limit] bytes is
59− * the caller's signal that the source exceeds the cap. */
60−private fun java.io.InputStream.readAtMost(limit: Long): ByteArray {
61− val out = java.io.ByteArrayOutputStream()
62− val chunk = ByteArray(8 * 1024)
63− var total = 0L
64− while (total < limit) {
65− val want = minOf(chunk.size.toLong(), limit - total).toInt()
66− val read = read(chunk, 0, want)
67− if (read < 0) break
68− out.write(chunk, 0, read)
69− total += read
70− }
71− return out.toByteArray()
72−}
73−
74−/** The raw (pre-base64) byte size encoded in a `data:<mime>;base64,<payload>` URL, derived from
75− * the payload length (len / 4 * 3, less any '=' padding) without allocating or decoding the
76− * payload — important since the payload can be many MB. Returns 0 when the URL has no base64
77− * payload. Lets the cumulative attachment-size cap use the same raw-byte measure as the per-file
78− * [NetworkConfig.maxAttachmentBytes] check above (which counts raw `bytes.size`). */
79−internal fun base64DataUrlByteSize(dataUrl: String): Long {
80− val marker = "base64,"
81− val start = dataUrl.indexOf(marker)
82− if (start < 0) return 0L
83− val payloadLen = dataUrl.length - (start + marker.length)
84− if (payloadLen <= 0) return 0L
85− val padding = when {
86− dataUrl.endsWith("==") -> 2
87− dataUrl.endsWith("=") -> 1
88− else -> 0
89− }
90− return payloadLen.toLong() / 4 * 3 - padding
91−}
92−
93−/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
94−private fun Uri.displayName(context: Context): String? = runCatching {
95− context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
96− val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
97− if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
98− }
99−}.getOrNull()?.takeIf { it.isNotBlank() }
100−
101−private fun defaultName(mime: String): String {
102− val ext = mime.substringAfterLast('/', "bin").takeIf { it.isNotBlank() } ?: "bin"
103− return "attachment.$ext"
104−}
105−
106−/**
107− * Create a temp file in the cache and a shareable content [Uri] for it (via the app's
108− * [FileProvider]) to hand to the camera as the capture target. Returns null if it can't be
109− * created. Uses a fixed filename so each capture overwrites the prior (the OS also evicts
110− * cache under storage pressure); a unique-per-capture timestamped name would leak a .jpg
111− * per canceled capture in cacheDir/captures/ with no cleanup path.
112− */
113−fun newCameraCaptureUri(context: Context): Uri? = runCatching {
114− val dir = File(context.cacheDir, "captures").apply { mkdirs() }
115− val file = File(dir, "capture.jpg")
116− FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
117−}.getOrNull()
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.ktdeleted−258
@@ -1,258 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.Column
6−import androidx.compose.foundation.layout.Row
7−import androidx.compose.foundation.layout.fillMaxSize
8−import androidx.compose.foundation.layout.fillMaxWidth
9−import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
11−import androidx.compose.foundation.layout.widthIn
12−import androidx.compose.foundation.clickable
13−import androidx.compose.foundation.combinedClickable
14−import androidx.compose.foundation.horizontalScroll
15−import androidx.compose.foundation.rememberScrollState
16−import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.filled.Close
18−import androidx.compose.material.icons.filled.Description
19−import androidx.compose.material3.AlertDialog
20−import androidx.compose.material3.CircularProgressIndicator
21−import androidx.compose.material3.Icon
22−import androidx.compose.material3.IconButton
23−import androidx.compose.material3.MaterialTheme
24−import androidx.compose.material3.Surface
25−import androidx.compose.material3.Text
26−import androidx.compose.runtime.Composable
27−import androidx.compose.runtime.getValue
28−import androidx.compose.runtime.mutableStateOf
29−import androidx.compose.runtime.remember
30−import androidx.compose.runtime.setValue
31−import androidx.compose.ui.Alignment
32−import androidx.compose.ui.Modifier
33−import androidx.compose.ui.draw.clip
34−import androidx.compose.ui.layout.ContentScale
35−import androidx.compose.ui.res.stringResource
36−import androidx.compose.ui.semantics.Role
37−import androidx.compose.ui.semantics.contentDescription
38−import androidx.compose.ui.semantics.semantics
39−import androidx.compose.ui.text.style.TextOverflow
40−import androidx.compose.ui.unit.dp
41−import androidx.compose.ui.window.Dialog
42−import androidx.compose.ui.window.DialogProperties
43−import coil3.compose.AsyncImage
44−import soy.iko.opencode.R
45−import soy.iko.opencode.data.network.NetworkConfig
46−import java.util.Locale
47−
48−/**
49− * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
50− * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
51− *
52− * When at least one attachment is staged, a muted total-size label is rendered at the end of
53− * the strip so the user can see how close they are to the cumulative [NetworkConfig.maxTotalAttachmentBytes]
54− * cap — a bare per-file cap doesn't surface the combined total, and a failed send is the only
55− * other feedback.
56− */
57−@Composable
58−internal fun AttachmentStrip(
59− attachments: List<PendingAttachment>,
60− onRemove: (String) -> Unit,
61− staging: Boolean = false,
62− stagingFileCount: Int = 0,
63−) {
64− // Show the strip while an encode is in flight even before the first chip exists, so the
65− // user gets immediate feedback that their pick is being processed.
66− if (attachments.isEmpty() && !staging) return
67− // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
68− Row(
69− modifier = Modifier
70− .fillMaxWidth()
71− .horizontalScroll(rememberScrollState())
72− .padding(start = 8.dp, end = 8.dp, top = 8.dp),
73− horizontalArrangement = Arrangement.spacedBy(8.dp),
74− verticalAlignment = Alignment.CenterVertically,
75− ) {
76− attachments.forEach { att ->
77− AttachmentChip(att, onRemove = { onRemove(att.id) })
78− }
79− // A placeholder chip with an indeterminate spinner while any pick is still being
80− // read + base64-encoded off the main thread (chips only materialize once done).
81− if (staging) StagingChip(stagingFileCount)
82− // Total staged size label — surfaced so the user can see how close they are to the
83− // cumulative cap before a send fails. Hidden while staging (the total is in flux).
84− if (!staging && attachments.isNotEmpty()) {
85− val totalBytes = remember(attachments) {
86− attachments.sumOf { base64DataUrlByteSize(it.part.url) }
87− }
88− val maxBytes = NetworkConfig.maxTotalAttachmentBytes
89− val label = remember(totalBytes, maxBytes) { formatAttachmentTotal(totalBytes, maxBytes) }
90− val over = totalBytes > maxBytes
91− Text(
92− label,
93− style = MaterialTheme.typography.labelSmall,
94− color = if (over) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
95− modifier = Modifier.padding(start = 4.dp, end = 4.dp),
96− )
97− }
98− }
99−}
100−
101−/** Format the staged-attachment total as "N.N MB / max" (or KB when small), for the strip label. */
102−private fun formatAttachmentTotal(bytes: Long, max: Long): String {
103− val mb = 1024.0 * 1024.0
104− val kb = 1024.0
105− val used = when {
106− bytes >= mb -> String.format(Locale.US, "%.1f MB", bytes / mb)
107− bytes >= kb -> String.format(Locale.US, "%.0f KB", bytes / kb)
108− else -> "$bytes B"
109− }
110− val maxStr = when {
111− max >= mb -> String.format(Locale.US, "%.0f MB", max / mb)
112− max >= kb -> String.format(Locale.US, "%.0f KB", max / kb)
113− else -> "$max B"
114− }
115− return "$used / $maxStr"
116−}
117−
118−/** A placeholder chip shown while an attachment is being encoded. */
119−@Composable
120−private fun StagingChip(stagingFileCount: Int) {
121− // Show "Staging N files…" when a multi-file count is known; fall back to the generic
122− // "Attaching…" for the single/unknown case so the label never reads "Staging 1 files".
123− val label = if (stagingFileCount > 1) {
124− stringResource(R.string.staging_n_files, stagingFileCount)
125− } else {
126− stringResource(R.string.attachment_staging)
127− }
128− Surface(
129− shape = MaterialTheme.shapes.small,
130− color = MaterialTheme.colorScheme.surfaceVariant,
131− tonalElevation = 1.dp,
132− ) {
133− Row(
134− verticalAlignment = Alignment.CenterVertically,
135− modifier = Modifier.padding(horizontal = 10.dp),
136− ) {
137− CircularProgressIndicator(
138− Modifier.size(18.dp).semantics { contentDescription = label },
139− strokeWidth = 2.dp,
140− )
141− Text(
142− label,
143− style = MaterialTheme.typography.labelMedium,
144− maxLines = 1,
145− modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
146− )
147− }
148− }
149−}
150−
151−/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
152− * and a remove button. Tapping the thumbnail/name of an image attachment opens a fullscreen
153− * preview so the user can verify it before sending (a sent image costs tokens and can't be
154− * unsent). */
155−@Composable
156−private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
157− var showPreview by remember { mutableStateOf(false) }
158− var showNameDialog by remember { mutableStateOf(false) }
159− val previewLabel = stringResource(R.string.preview_attachment)
160− val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
161− Surface(
162− shape = MaterialTheme.shapes.small,
163− color = MaterialTheme.colorScheme.surfaceVariant,
164− tonalElevation = 1.dp,
165− ) {
166− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
167− if (attachment.previewModel != null) {
168− AsyncImage(
169− model = attachment.previewModel,
170− contentDescription = attachment.name,
171− contentScale = ContentScale.Crop,
172− modifier = Modifier
173− .size(36.dp)
174− .clip(MaterialTheme.shapes.extraSmall)
175− .semantics { contentDescription = previewLabel }
176− .clickable(role = Role.Image) { showPreview = true },
177− )
178− } else {
179− // Long-press the file icon to see the full filename (it's truncated to 120dp
180− // in the chip, so a long name can't be read in full otherwise). Mirrors the
181− // image chip's tap-to-preview pattern for non-image files.
182− Icon(
183− Icons.Filled.Description,
184− contentDescription = null,
185− modifier = Modifier
186− .size(24.dp)
187− .combinedClickable(
188− role = Role.Image,
189− onLongClick = {
190− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
191− showNameDialog = true
192− },
193− onClick = {},
194− ),
195− tint = MaterialTheme.colorScheme.onSurfaceVariant,
196− )
197− }
198− Text(
199− attachment.name,
200− style = MaterialTheme.typography.labelMedium,
201− maxLines = 1,
202− overflow = TextOverflow.Ellipsis,
203− modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
204− )
205− // No .size() override so the IconButton keeps its default 48dp touch target
206− // (a11y minimum) while the visual X stays small (16dp).
207− IconButton(onClick = onRemove) {
208− Icon(
209− Icons.Filled.Close,
210− contentDescription = stringResource(R.string.remove),
211− modifier = Modifier.size(16.dp),
212− )
213− }
214− }
215− }
216− if (showPreview && attachment.previewModel != null) {
217− Dialog(
218− onDismissRequest = { showPreview = false },
219− properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
220− ) {
221− Box(
222− modifier = Modifier.fillMaxSize().padding(16.dp),
223− contentAlignment = Alignment.Center,
224− ) {
225− AsyncImage(
226− model = attachment.previewModel,
227− contentDescription = attachment.name,
228− contentScale = ContentScale.Fit,
229− modifier = Modifier.fillMaxWidth().clickable(role = Role.Image) { showPreview = false },
230− )
231− IconButton(
232− onClick = { showPreview = false },
233− modifier = Modifier.align(Alignment.TopEnd),
234− ) {
235− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
236− }
237− }
238− }
239− }
240− if (showNameDialog) {
241− AlertDialog(
242− onDismissRequest = { showNameDialog = false },
243− title = { Text(stringResource(R.string.attachment_name)) },
244− text = {
245− Text(
246− attachment.name,
247− style = MaterialTheme.typography.bodyMedium,
248− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
249− )
250− },
251− confirmButton = {
252− androidx.compose.material3.TextButton(onClick = { showNameDialog = false }) {
253− Text(stringResource(R.string.close))
254− }
255− },
256− )
257− }
258−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktdeleted−3456
@@ -1,3456 +0,0 @@
1−@file:Suppress("TooManyFunctions")
2−
3−package soy.iko.opencode.ui.chat
4−
5−import android.app.Activity
6−import android.content.Intent
7−import android.net.Uri
8−import android.speech.RecognizerIntent
9−import androidx.activity.compose.BackHandler
10−import androidx.activity.compose.rememberLauncherForActivityResult
11−import androidx.activity.result.PickVisualMediaRequest
12−import androidx.activity.result.contract.ActivityResultContracts
13−import androidx.compose.animation.AnimatedVisibility
14−import androidx.compose.animation.core.RepeatMode
15−import androidx.compose.animation.core.animateFloat
16−import androidx.compose.animation.core.infiniteRepeatable
17−import androidx.compose.animation.core.rememberInfiniteTransition
18−import androidx.compose.animation.core.tween
19−import androidx.compose.foundation.background
20−import androidx.compose.foundation.clickable
21−import androidx.compose.foundation.content.contentReceiver
22−import androidx.compose.foundation.layout.Arrangement
23−import androidx.compose.foundation.layout.Box
24−import androidx.compose.foundation.layout.Column
25−import androidx.compose.foundation.layout.FlowRow
26−import androidx.compose.foundation.layout.PaddingValues
27−import androidx.compose.foundation.layout.Row
28−import androidx.compose.foundation.layout.Spacer
29−import androidx.compose.foundation.layout.WindowInsets
30−import androidx.compose.foundation.layout.fillMaxSize
31−import androidx.compose.foundation.layout.fillMaxWidth
32−import androidx.compose.foundation.layout.heightIn
33−import androidx.compose.foundation.layout.height
34−import androidx.compose.foundation.layout.imePadding
35−import androidx.compose.foundation.layout.ime
36−import androidx.compose.foundation.layout.navigationBars
37−import androidx.compose.foundation.layout.padding
38−import androidx.compose.foundation.layout.size
39−import androidx.compose.foundation.layout.union
40−import androidx.compose.foundation.layout.widthIn
41−import androidx.compose.foundation.layout.windowInsetsPadding
42−import androidx.compose.foundation.layout.wrapContentWidth
43−import androidx.compose.foundation.lazy.LazyColumn
44−import androidx.compose.foundation.lazy.items
45−import androidx.compose.foundation.lazy.rememberLazyListState
46−import androidx.compose.foundation.shape.RoundedCornerShape
47−import androidx.compose.material.icons.Icons
48−import androidx.compose.material.icons.automirrored.filled.ArrowBack
49−import androidx.compose.material.icons.automirrored.filled.Send
50−import androidx.compose.material.icons.filled.ErrorOutline
51−import androidx.compose.material.icons.filled.Add
52−import androidx.compose.material.icons.filled.AutoAwesome
53−import androidx.compose.material.icons.filled.Delete
54−import androidx.compose.material.icons.filled.Description
55−import androidx.compose.material.icons.filled.Close
56−import androidx.compose.material.icons.filled.Edit
57−import androidx.compose.material.icons.filled.Expand
58−import androidx.compose.material.icons.filled.FormatQuote
59−import androidx.compose.material.icons.filled.ExpandLess
60−import androidx.compose.material.icons.filled.ExpandMore
61−import androidx.compose.material.icons.filled.Search
62−import androidx.compose.material.icons.filled.KeyboardArrowDown
63−import androidx.compose.material.icons.filled.KeyboardArrowUp
64−import androidx.compose.material.icons.filled.Mic
65−import androidx.compose.material.icons.filled.MoreVert
66−import androidx.compose.material.icons.filled.PhotoCamera
67−import androidx.compose.material.icons.filled.PhotoLibrary
68−import androidx.compose.material.icons.filled.Refresh
69−import androidx.compose.material.icons.filled.Restore
70−import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
71−import androidx.compose.material.icons.automirrored.filled.Reply
72−import androidx.compose.material.icons.filled.Schedule
73−import androidx.compose.material.icons.filled.Stop
74−import androidx.compose.material3.AlertDialog
75−import androidx.compose.material3.Badge
76−import androidx.compose.material3.BadgedBox
77−import androidx.compose.material3.Button
78−import androidx.compose.material3.CircularProgressIndicator
79−import androidx.compose.material3.SmallFloatingActionButton
80−import androidx.compose.material3.DropdownMenu
81−import androidx.compose.material3.DropdownMenuItem
82−import androidx.compose.material3.ExperimentalMaterial3Api
83−import androidx.compose.material3.ExtendedFloatingActionButton
84−import androidx.compose.material3.FloatingActionButton
85−import androidx.compose.material3.Icon
86−import androidx.compose.material3.IconButton
87−import androidx.compose.material3.MaterialTheme
88−import androidx.compose.material3.OutlinedTextField
89−import androidx.compose.material3.Scaffold
90−import androidx.compose.material3.SnackbarDuration
91−import androidx.compose.material3.SwipeToDismissBox
92−import androidx.compose.material3.SwipeToDismissBoxValue
93−import androidx.compose.material3.rememberSwipeToDismissBoxState
94−import androidx.compose.material3.SnackbarHost
95−import androidx.compose.material3.SnackbarHostState
96−import androidx.compose.material3.SnackbarResult
97−import androidx.compose.material3.Surface
98−import androidx.compose.material3.Text
99−import androidx.compose.material3.TextButton
100−import androidx.compose.material3.TopAppBar
101−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
102−import androidx.compose.runtime.Composable
103−import androidx.compose.runtime.CompositionLocalProvider
104−import androidx.compose.runtime.DisposableEffect
105−import androidx.compose.runtime.LaunchedEffect
106−import androidx.compose.runtime.derivedStateOf
107−import androidx.compose.runtime.getValue
108−import androidx.compose.runtime.mutableIntStateOf
109−import androidx.compose.runtime.mutableLongStateOf
110−import androidx.compose.runtime.mutableStateOf
111−import androidx.compose.runtime.produceState
112−import androidx.compose.runtime.remember
113−import androidx.compose.runtime.rememberCoroutineScope
114−import androidx.compose.runtime.rememberUpdatedState
115−import androidx.compose.runtime.saveable.rememberSaveable
116−import androidx.compose.runtime.setValue
117−import androidx.compose.runtime.snapshotFlow
118−import androidx.compose.foundation.text.KeyboardActions
119−import androidx.compose.foundation.text.KeyboardOptions
120−import androidx.compose.ui.text.input.KeyboardCapitalization
121−import androidx.compose.ui.Alignment
122−import androidx.compose.ui.Modifier
123−import androidx.compose.ui.draw.alpha
124−import androidx.compose.ui.draw.clip
125−import androidx.compose.ui.focus.focusRequester
126−import androidx.compose.ui.input.key.Key
127−import androidx.compose.ui.input.key.KeyEventType
128−import androidx.compose.ui.input.key.isCtrlPressed
129−import androidx.compose.ui.input.key.isShiftPressed
130−import androidx.compose.ui.input.key.key
131−import androidx.compose.ui.input.key.onPreviewKeyEvent
132−import androidx.compose.ui.input.key.type
133−import androidx.compose.ui.platform.LocalContext
134−import androidx.compose.ui.platform.LocalDensity
135−import androidx.compose.ui.platform.LocalHapticFeedback
136−import androidx.compose.ui.platform.LocalLayoutDirection
137−import androidx.compose.ui.platform.LocalView
138−import androidx.compose.ui.platform.testTag
139−import androidx.compose.ui.unit.LayoutDirection
140−import androidx.compose.ui.res.pluralStringResource
141−import androidx.compose.ui.res.stringResource
142−import androidx.compose.ui.semantics.Role
143−import androidx.compose.ui.semantics.contentDescription
144−import androidx.compose.ui.semantics.heading
145−import androidx.compose.ui.semantics.liveRegion
146−import androidx.compose.ui.semantics.LiveRegionMode
147−import androidx.compose.ui.semantics.invisibleToUser
148−import androidx.compose.ui.semantics.semantics
149−import androidx.compose.ui.text.input.ImeAction
150−import androidx.compose.ui.text.style.TextOverflow
151−import androidx.compose.ui.unit.dp
152−import androidx.lifecycle.compose.collectAsStateWithLifecycle
153−import androidx.lifecycle.viewmodel.compose.viewModel
154−import kotlinx.coroutines.Dispatchers
155−import kotlinx.coroutines.coroutineScope
156−import kotlinx.coroutines.delay
157−import kotlinx.coroutines.flow.collectLatest
158−import kotlinx.coroutines.flow.distinctUntilChanged
159−import kotlinx.coroutines.flow.first
160−import kotlinx.coroutines.launch
161−import kotlinx.coroutines.withContext
162−import soy.iko.opencode.data.model.Part
163−import soy.iko.opencode.data.model.MessageWithParts
164−import soy.iko.opencode.data.model.AssistantMessage
165−import soy.iko.opencode.data.model.Command
166−import soy.iko.opencode.data.model.ReasoningPart
167−import soy.iko.opencode.data.model.TextPart
168−import soy.iko.opencode.data.model.ToolCompleted
169−import soy.iko.opencode.data.model.ToolError
170−import soy.iko.opencode.data.model.ToolPart
171−import soy.iko.opencode.data.model.ToolRunning
172−import soy.iko.opencode.data.model.FilePart
173−import soy.iko.opencode.data.model.sourcePath
174−import soy.iko.opencode.data.model.inputElement
175−import soy.iko.opencode.data.network.EventStreamClient
176−import soy.iko.opencode.data.network.NetworkConfig
177−import soy.iko.opencode.di.AppContainer
178−import soy.iko.opencode.R
179−import soy.iko.opencode.ui.components.CommandPalette
180−import soy.iko.opencode.ui.components.ConnectionBanner
181−import soy.iko.opencode.ui.components.DiffView
182−import soy.iko.opencode.ui.components.PaletteAction
183−import soy.iko.opencode.ui.components.LocalReducedMotion
184−import soy.iko.opencode.ui.components.LocalSearchHighlight
185−
186−import soy.iko.opencode.ui.components.copyToClipboard
187−import soy.iko.opencode.ui.components.LocalRelativeTimeTick
188−import soy.iko.opencode.ui.components.rememberRelativeTimeTick
189−import soy.iko.opencode.ui.components.rememberVisibilityTransitions
190−import soy.iko.opencode.ui.components.showToast
191−import soy.iko.opencode.ui.components.toImageContext
192−import soy.iko.opencode.ui.vmFactory
193−import soy.iko.opencode.util.runCatchingCancellable
194−
195−// Optional message id to scroll to and briefly highlight on first load (from global search).
196−// Consumed once: after the message is scrolled into view and highlighted, the focus is
197−// cleared so subsequent recompositions don't re-trigger the scroll.
198−@OptIn(ExperimentalMaterial3Api::class)
199−@Composable
200−fun ChatScreen(
201− container: AppContainer,
202− sessionId: String,
203− onBack: () -> Unit,
204− onOpenFile: ((String) -> Unit)? = null,
205− onOpenSession: ((String) -> Unit)? = null,
206− focusMessageId: String? = null,
207− onEditProfile: ((String) -> Unit)? = null,
208−) {
209− val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
210− val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
211− val running by vm.running.collectAsStateWithLifecycle()
212− val streamInterrupted by vm.streamInterrupted.collectAsStateWithLifecycle()
213− val aborting by vm.aborting.collectAsStateWithLifecycle()
214− val loading by vm.loading.collectAsStateWithLifecycle()
215− val loadError by vm.loadError.collectAsStateWithLifecycle()
216− val loadErrorInline by vm.loadErrorInline.collectAsStateWithLifecycle()
217− val refreshing by vm.refreshing.collectAsStateWithLifecycle()
218− val models by vm.models.collectAsStateWithLifecycle()
219− val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
220− val modelsError by vm.modelsError.collectAsStateWithLifecycle()
221− val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
222− val connectionState by vm.connectionState.collectAsStateWithLifecycle()
223− val reconnectAttempts by vm.reconnectAttempts.collectAsStateWithLifecycle()
224− val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
225− val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
226− val agents by vm.agents.collectAsStateWithLifecycle()
227− val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
228− val agentsError by vm.agentsError.collectAsStateWithLifecycle()
229− val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
230− val commands by vm.commands.collectAsStateWithLifecycle()
231− val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
232− val commandsError by vm.commandsError.collectAsStateWithLifecycle()
233− val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
234− val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
235− val draft by vm.draft.collectAsStateWithLifecycle()
236− val pendingQuote by vm.pendingQuote.collectAsStateWithLifecycle()
237− val attachments by vm.attachments.collectAsStateWithLifecycle()
238− val reverted by vm.reverted.collectAsStateWithLifecycle()
239− val editing by vm.editing.collectAsStateWithLifecycle()
240− val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
241− val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
242− val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
243− val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
244− val preferredModelId by container.settingsStore.preferredModelId.collectAsStateWithLifecycle(initialValue = "")
245− val recentModelEntries by container.recentModelsStore.entries.collectAsStateWithLifecycle()
246− val compactSpacing by container.settingsStore.compactMessageSpacing.collectAsStateWithLifecycle(initialValue = false)
247− val notifPermission by container.settingsStore.notifPermission.collectAsStateWithLifecycle(initialValue = true)
248− val isOnline by container.isOnline.collectAsStateWithLifecycle()
249− val haptics = LocalHapticFeedback.current
250− val layoutDirection = LocalLayoutDirection.current
251− val scope = rememberCoroutineScope()
252− val shareContext = LocalContext.current
253− val defaultShareSubject = stringResource(R.string.share_subject)
254− val sessionLabel = stringResource(R.string.session)
255− val defaultModelLabel = stringResource(R.string.default_model)
256− val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
257− buildString {
258− append(selectedModel?.modelLabel ?: defaultModelLabel)
259− selectedAgent?.let { append(" · $it") }
260− }
261− }
262−
263− // Connection profile (with auth) for rendering inline image attachments.
264− // Collect as state so a server switch recomposes the image context.
265− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
266− val imageContext = remember(activeConnection?.profile) {
267− activeConnection?.profile?.toImageContext()
268− }
269−
270− // Mark this session as read while the user is viewing it; clear on leave so new
271− // background activity can badge it again.
272− DisposableEffect(sessionId) {
273− container.setCurrentSession(sessionId)
274− onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
275− }
276−
277− // Navigate away when the session is deleted via SSE so the user isn't left
278− // on a zombie screen showing a conversation that no longer exists. Surface a
279− // toast (not a snackbar) because the screen is about to be popped — a snackbar
280− // would be torn down before the user could read it, while a toast survives.
281− // Covers both SSE-driven deletion (deleted on another device) and a user-
282− // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
283− // sets the same flag on success).
284− val deletedContext = LocalContext.current
285− LaunchedEffect(sessionDeleted) {
286− if (sessionDeleted) {
287− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
288− onBack()
289− }
290− }
291−
292− val snackbar = remember { SnackbarHostState() }
293− val retryLabel = stringResource(R.string.retry)
294− val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
295− // Dedicated focus requester for the in-conversation search field. The overflow menu's
296− // "Find in conversation" and Ctrl+F previously called inputFocusRequester.requestFocus()
297− // (the composer), so typing went to the composer instead of the search field.
298− val searchFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
299− var showModelPicker by rememberSaveable { mutableStateOf(false) }
300− var showAgentPicker by rememberSaveable { mutableStateOf(false) }
301− var showTitleMenu by rememberSaveable { mutableStateOf(false) }
302− var showCommandPicker by rememberSaveable { mutableStateOf(false) }
303− var showExitConfirm by rememberSaveable { mutableStateOf(false) }
304− var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
305− var showRenameDialog by rememberSaveable { mutableStateOf(false) }
306− var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
307− var showShellDialog by rememberSaveable { mutableStateOf(false) }
308− var showPalette by rememberSaveable { mutableStateOf(false) }
309− var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
310− var searchActive by rememberSaveable { mutableStateOf(false) }
311− var chatSearch by rememberSaveable { mutableStateOf("") }
312− // Index of the currently-focused in-conversation search match (into the filtered list).
313− // Drives the up/down navigation: the search bar's arrows step through matches and scroll
314− // the list to each one, mirroring desktop chat apps (the bare filtered list previously
315− // required reading each message to find the term).
316− var searchPos by rememberSaveable { mutableIntStateOf(0) }
317− var searchFiltering by remember { mutableStateOf(false) }
318− // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
319− // a confirmation dialog (from both the overflow menu and the command palette).
320− var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
321− var showInitConfirm by rememberSaveable { mutableStateOf(false) }
322−
323− // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
324− // action list doesn't need a Composable context. Actions map to the same handlers the
325− // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
326− val paletteModelLabel = stringResource(R.string.palette_switch_model)
327− val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
328− val paletteCommandLabel = stringResource(R.string.palette_run_command)
329− val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
330− val paletteRenameLabel = stringResource(R.string.rename_session_chat)
331− val paletteShellLabel = stringResource(R.string.run_shell_command)
332− val paletteActions = remember(
333− paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
334− paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
335− ) {
336− buildList {
337− add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
338− add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
339− add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
340− if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
341− if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
342− add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
343− }
344− }
345−
346− // Keep the screen awake while the agent is working in *this* session. The foreground
347− // service that holds process priority during a run is managed app-wide off
348− // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
349− // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
350− // SSE stream before the completion notification fires.
351− val currentView = LocalView.current
352− val appContext = LocalContext.current.applicationContext
353− DisposableEffect(running) {
354− currentView.keepScreenOn = running
355− onDispose { currentView.keepScreenOn = false }
356− }
357−
358− LaunchedEffect(Unit) {
359− vm.errorEvents.collect { event ->
360− // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
361− // message-load failure) would silently re-submit the last prompt on tap.
362− val result = if (event.retryable) {
363− snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
364− } else {
365− snackbar.showSnackbar(event.message)
366− }
367− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
368− }
369− }
370−
371− // Stop-undo: abort() emits the last-sent prompt here so this collector can show a
372− // "Stopped — Undo" snackbar. Tapping Undo re-sends the prompt, recovering an accidental
373− // Stop without a confirmation dialog (Stop now happens on first tap).
374− val undoStopLabel = stringResource(R.string.undo)
375− val stoppedLabel = stringResource(R.string.run_stopped_undo)
376− LaunchedEffect(Unit) {
377− vm.stopUndoEvents.collect { prompt ->
378− if (prompt != null) {
379− val result = snackbar.showSnackbar(message = stoppedLabel, actionLabel = undoStopLabel)
380− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.resendLastPrompt()
381− }
382− }
383− }
384−
385− // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
386− // window on the container; show the snackbar over the conversation the user is viewing so
387− // they can cancel without being navigated to the session list first. If the window expires
388− // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
389− //
390− // collectLatest (not collect): a serialized collect queues each snackbar behind the
391− // previous one's full window, so under a rapid re-delete (delete A, then delete the same
392− // session again, or a delete timed just after the first) the VM delete timer (started at
393− // emit time) fires before the second snackbar is ever shown — a dead Undo button.
394− // collectLatest cancels the current snackbar and shows the newest immediately, keeping its
395− // Undo window aligned with its timer. Tracking `pending` mirrors SessionListScreen: when a
396− // newer delete supersedes the prior snackbar mid-window, the prior session's deferred REST
397− // delete (still scheduled on the container's scope) would fire with no reachable Undo.
398− // Withdrawing it via cancelSessionDelete cancels the timer (no-op if it already fired), so
399− // no session is silently deleted without a chance to undo.
400− val undoLabel = stringResource(R.string.undo)
401− val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
402− LaunchedEffect(Unit) {
403− var pending: String? = null
404− vm.deleteUndoEvents.collectLatest { id ->
405− pending?.let { vm.cancelSessionDelete(it) }
406− pending = id
407− coroutineScope {
408− val dismisser = launch {
409− delay(NetworkConfig.undoDeleteDelayMs)
410− snackbar.currentSnackbarData?.dismiss()
411− }
412− val result = snackbar.showSnackbar(
413− message = sessionDeletedLabel,
414− actionLabel = undoLabel,
415− duration = SnackbarDuration.Indefinite,
416− )
417− dismisser.cancel()
418− if (result == SnackbarResult.ActionPerformed) {
419− vm.cancelSessionDelete(id)
420− pending = null
421− }
422− }
423− }
424− }
425−
426− // --- Attachments, voice, and share-link plumbing ---
427− val attachFailedMsg = stringResource(R.string.attachment_failed)
428− val noCameraMsg = stringResource(R.string.no_camera_app)
429− val noVoiceMsg = stringResource(R.string.no_voice_app)
430− val voicePrompt = stringResource(R.string.voice_prompt)
431− val linkCopiedMsg = stringResource(R.string.link_copied)
432− val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
433−
434− // Count of picks currently being read + base64-encoded off the main thread, so the
435− // composer can show a staging placeholder immediately (chips only materialize once done).
436− // Backed by the ViewModel (vm.stagingCount / vm.stagingFileTotal) so the staging state
437− // survives a configuration change: the encode work runs on viewModelScope (which
438− // survives rotation), so the counters must live on the VM too — remember { mutableStateOf
439− // } in the composable would reset to 0 on rotation and lose the staging chip mid-encode.
440− val stagingCount by vm.stagingCount.collectAsStateWithLifecycle()
441− val stagingFileTotal by vm.stagingFileTotal.collectAsStateWithLifecycle()
442−
443− // Convert each picked Uri to a base64 attachment on the VM's scope (survives rotation),
444− // honoring the per-prompt count cap and surfacing per-file errors via the VM's errorEvents.
445− fun stageUris(uris: List<Uri>) {
446− vm.stageUris(uris, appContext)
447− }
448−
449− val photoPicker = rememberLauncherForActivityResult(
450− ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
451− ) { uris -> stageUris(uris) }
452−
453− val filePicker = rememberLauncherForActivityResult(
454− ActivityResultContracts.GetMultipleContents(),
455− ) { uris -> stageUris(uris) }
456−
457− // Uri survives a config change mid-capture so the TakePicture result still resolves.
458− var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
459− val cameraLauncher = rememberLauncherForActivityResult(
460− ActivityResultContracts.TakePicture(),
461− ) { success ->
462− val uri = pendingCaptureUri
463− pendingCaptureUri = null
464− if (success && uri != null) stageUris(listOf(uri))
465− }
466−
467− val voiceLauncher = rememberLauncherForActivityResult(
468− ActivityResultContracts.StartActivityForResult(),
469− ) { result ->
470− if (result.resultCode == Activity.RESULT_OK) {
471− val spoken = result.data
472− ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
473− ?.firstOrNull()
474− ?.takeIf { it.isNotBlank() }
475− if (spoken != null) {
476− // Append to the existing draft rather than replacing, so dictation adds to
477− // whatever the user already typed.
478− val current = vm.draft.value
479− val combined = if (current.isBlank()) spoken else "$current $spoken"
480− vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
481− }
482− }
483− }
484−
485− fun launchCamera() {
486− val uri = newCameraCaptureUri(appContext)
487− if (uri == null) {
488− scope.launch { snackbar.showSnackbar(attachFailedMsg) }
489− return
490− }
491− pendingCaptureUri = uri
492− runCatching { cameraLauncher.launch(uri) }
493− .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
494− }
495−
496− fun launchVoice() {
497− val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
498− putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
499− putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
500− }
501− runCatching { voiceLauncher.launch(intent) }
502− .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
503− }
504−
505− // A freshly-created share link is copied to the clipboard so the user can paste it right away.
506− LaunchedEffect(Unit) {
507− vm.shareLinkEvents.collect { url ->
508− copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
509− snackbar.showSnackbar(linkCopiedMsg)
510− }
511− }
512−
513− // Navigate to a session freshly branched from a message (branchFrom), once it's created.
514− // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
515− // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
516− // a dropped branch-navigation event in the gap. The lambda is wrapped in rememberUpdatedState
517− // so the Unit-keyed collector invokes the LATEST lambda instead of the one captured at first
518− // composition — a latent footgun if a future change makes onOpenSession depend on current
519− // Compose state (e.g. pane-aware routing). The collector itself is never cancelled, so no
520− // event is dropped.
521− val branchedMsg = stringResource(R.string.branch_created)
522− val currentOnOpenSession by rememberUpdatedState(onOpenSession)
523− LaunchedEffect(Unit) {
524− vm.branchEvents.collect { newId ->
525− showToast(shareContext, branchedMsg)
526− currentOnOpenSession?.invoke(newId)
527− }
528− }
529−
530− // Stage images shared into the app (via the system share sheet) as attachments.
531− // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
532− // while this ChatScreen is already composed — which happens in two-pane mode, where the
533− // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
534− // only fire on first composition. consumePendingSharedMedia() atomically drains the
535− // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
536− val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
537− LaunchedEffect(pendingSharedMedia) {
538− val shared = container.consumePendingSharedMedia()
539− if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
540− }
541−
542− fun doSend() {
543− if (vm.send(draft)) {
544− // TextHandleMove (a light tick) is semantically right for a send commit;
545− // LongPress is reserved for long-press/swipe gestures.
546− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
547− }
548− }
549−
550− // The in-conversation search bar is an AnimatedVisibility overlay (not a Dialog/Dropdown),
551− // so it has no built-in back handling. Close it first on back press, before the exit
552− // confirmation or navigation-away can fire — standard Android "back closes the topmost
553− // overlay" behavior. Declared before the exit-confirm BackHandler so it takes precedence
554− // when both could be active (search open + a run in progress).
555− BackHandler(enabled = searchActive) {
556− searchActive = false
557− chatSearch = ""
558− }
559−
560− BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker && !searchActive) { showExitConfirm = true }
561−
562− Scaffold(
563− // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
564− // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
565− // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
566− // typing (and the composer's own Enter-to-send handling) is untouched.
567− modifier = Modifier.onPreviewKeyEvent { ev ->
568− if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
569− when {
570− ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
571− // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
572− // the field so the user can type immediately.
573− ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
574− ev.key == Key.Escape && showPalette -> { showPalette = false; true }
575− ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
576− ev.key == Key.Escape && running && !showExitConfirm -> {
577− vm.abort(); true
578− }
579− else -> false
580− }
581− },
582− topBar = {
583− TopAppBar(
584− title = {
585− val changeLabel = stringResource(R.string.change_model_agent)
586− // The subtitle reads "model · agent", so tapping the title offers *both*
587− // — a small menu picks which picker to open. Previously the tap went
588− // straight to the model picker, leaving the agent changeable only via
589− // the overflow menu despite the title implying otherwise.
590− Box {
591− Row(
592− // mergeDescendants so TalkBack reads the title/subtitle Texts as one
593− // node; the click label rides on `clickable` (onClickLabel) instead of
594− // an explicit contentDescription, which would have SUPPRESSED the child
595− // Texts and announced only "Change model and agent".
596− modifier = Modifier
597− .clickable(
598− role = Role.Button,
599− onClickLabel = changeLabel,
600− ) { showTitleMenu = true }
601− .semantics(mergeDescendants = true) {},
602− verticalAlignment = Alignment.CenterVertically,
603− ) {
604− Column(modifier = Modifier.weight(1f, fill = false)) {
605− // maxLines=1 + Ellipsis so a long session title can't wrap and
606− // push the top bar to multiple lines, shifting the whole layout.
607− Text(
608− sessionTitle ?: sessionLabel,
609− maxLines = 1,
610− overflow = TextOverflow.Ellipsis,
611− )
612− Text(
613− subtitle,
614− style = MaterialTheme.typography.labelSmall,
615− color = MaterialTheme.colorScheme.onSurfaceVariant,
616− maxLines = 1,
617− overflow = TextOverflow.Ellipsis,
618− )
619− }
620− // Always show the affordance so the title reads as tappable even when
621− // the model catalog failed to load (models empty). Without it the title
622− // is indistinguishable from plain text and the pickers are effectively
623− // undiscoverable — and the dropdown items already disable themselves
624− // while a catalog is loading or unavailable.
625− Icon(
626− Icons.Filled.KeyboardArrowDown,
627− contentDescription = null,
628− tint = MaterialTheme.colorScheme.onSurfaceVariant,
629− modifier = Modifier.size(20.dp),
630− )
631− }
632− DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
633− DropdownMenuItem(
634− text = { Text(stringResource(R.string.palette_switch_model)) },
635− enabled = !modelsLoading || models.isNotEmpty(),
636− onClick = { showTitleMenu = false; showModelPicker = true },
637− )
638− DropdownMenuItem(
639− text = { Text(stringResource(R.string.palette_choose_agent)) },
640− enabled = !agentsLoading || agents.isNotEmpty(),
641− onClick = { showTitleMenu = false; showAgentPicker = true },
642− )
643− }
644− }
645− },
646− navigationIcon = {
647− IconButton(onClick = {
648− if (running) showExitConfirm = true else onBack()
649− }) {
650− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
651− }
652− },
653− actions = {
654− // At-a-glance run status in the top bar. When the user is scrolled
655− // up (jump-to-latest FAB visible), the input-bar Stop button and the
656− // trailing "working…" row are off-screen, so a top-bar spinner is the
657− // only signal the agent is still working.
658− if (running) {
659− val workingLabel = stringResource(R.string.working)
660− CircularProgressIndicator(
661− Modifier.size(18.dp).semantics { contentDescription = workingLabel },
662− strokeWidth = 2.dp,
663− )
664− }
665− // Manual refresh: forces an SSE reconnect, which re-seeds messages
666− // from REST. A recovery path when the stream silently drops and the
667− // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
668− // immediate tap feedback — the SSE reconnect may not visibly change
669− // the connection state when already Connected, so without it the tap
670− // appears to do nothing. Disabled while a run is active: a mid-run
671− // refresh forces a reconnect that can interrupt the streaming reply,
672− // and the working spinner already signals "wait" — the recovery path
673− // is only needed when nothing is actively streaming.
674− val refreshLabel = stringResource(R.string.refresh)
675− IconButton(
676− onClick = { vm.refreshMessages() },
677− enabled = activeConnection != null && !refreshing && !running,
678− ) {
679− if (refreshing) {
680− CircularProgressIndicator(
681− Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
682− strokeWidth = 2.dp,
683− )
684− } else {
685− Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
686− }
687− }
688− // Overflow menu consolidates the less-frequent actions (share,
689− // commands, agent, model, rename, delete) so the top bar stays
690− // scannable on narrow phones — previously 5 icons crowded the bar
691− // alongside the back button and tappable title. Catalog-loading
692− // spinners stay inline (next to the overflow) so a load state is
693− // visible without opening the menu.
694− val moreLabel = stringResource(R.string.more)
695− val loadingLabel = stringResource(R.string.loading)
696− val shareLabel = stringResource(R.string.share_conversation)
697− val copyAsMarkdownLabel = stringResource(R.string.copy_as_markdown)
698− val shareAsJsonLabel = stringResource(R.string.share_as_json)
699− val copyAsJsonLabel = stringResource(R.string.copy_as_json)
700− val commandsLabel = stringResource(R.string.commands)
701− val renameLabel = stringResource(R.string.rename_session_chat)
702− val deleteLabel = stringResource(R.string.delete_session_chat)
703− val createShareLabel = stringResource(R.string.create_share_link)
704− val copyShareLabel = stringResource(R.string.copy_share_link)
705− val stopShareLabel = stringResource(R.string.stop_sharing)
706− val summarizeLabel = stringResource(R.string.summarize_conversation)
707− val initLabel = stringResource(R.string.generate_agents_md)
708− val shellLabel = stringResource(R.string.run_shell_command)
709− Box {
710− IconButton(onClick = { showOverflowMenu = true }) {
711− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
712− }
713− DropdownMenu(
714− expanded = showOverflowMenu,
715− onDismissRequest = { showOverflowMenu = false },
716− ) {
717− DropdownMenuItem(
718− text = { Text(shareLabel) },
719− enabled = hasMessages,
720− onClick = {
721− showOverflowMenu = false
722− scope.launch {
723− val md = withContext(Dispatchers.Default) {
724− buildConversationMarkdown(vm.messages.value, sessionTitle)
725− }
726− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
727− type = "text/markdown"
728− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
729− putExtra(android.content.Intent.EXTRA_TEXT, md)
730− }
731− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
732− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
733− }
734− },
735− )
736− // Copy the whole conversation as Markdown to the clipboard, so a
737− // user can paste into another app without the share-sheet round-trip.
738− DropdownMenuItem(
739− text = { Text(copyAsMarkdownLabel) },
740− enabled = hasMessages,
741− onClick = {
742− showOverflowMenu = false
743− scope.launch {
744− val md = withContext(Dispatchers.Default) {
745− buildConversationMarkdown(vm.messages.value, sessionTitle)
746− }
747− copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), md)
748− }
749− },
750− )
751− // Lossless JSON share: the full List<MessageWithParts> serialized with
752− // the shared OpencodeJson, so tool outputs, file sources, and reasoning
753− // survive verbatim (the Markdown export truncates/summarizes them).
754− DropdownMenuItem(
755− text = { Text(shareAsJsonLabel) },
756− enabled = hasMessages,
757− onClick = {
758− showOverflowMenu = false
759− scope.launch {
760− val json = withContext(Dispatchers.Default) {
761− buildConversationJson(vm.messages.value)
762− }
763− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
764− type = "application/json"
765− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
766− putExtra(android.content.Intent.EXTRA_TEXT, json)
767− }
768− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareAsJsonLabel)) }
769− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
770− }
771− },
772− )
773− DropdownMenuItem(
774− text = { Text(copyAsJsonLabel) },
775− enabled = hasMessages,
776− onClick = {
777− showOverflowMenu = false
778− scope.launch {
779− val json = withContext(Dispatchers.Default) {
780− buildConversationJson(vm.messages.value)
781− }
782− copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_message), json)
783− }
784− },
785− )
786− DropdownMenuItem(
787− text = { Text(commandsLabel) },
788− enabled = !commandsLoading || commands.isNotEmpty(),
789− onClick = { showOverflowMenu = false; showCommandPicker = true },
790− )
791− DropdownMenuItem(
792− text = { Text(stringResource(R.string.keyboard_shortcuts)) },
793− onClick = { showOverflowMenu = false; showShortcutsDialog = true },
794− )
795− DropdownMenuItem(
796− text = { Text(stringResource(R.string.find_in_conversation)) },
797− onClick = {
798− showOverflowMenu = false
799− searchActive = true
800− // Focus is requested by the LaunchedEffect(searchActive)
801− // below, which waits for the search field to be composed
802− // before requesting focus (calling requestFocus here would
803− // target a field that isn't in the composition yet).
804− },
805− )
806− androidx.compose.material3.HorizontalDivider()
807− // Session sharing: create a public link, copy an existing one, or revoke it.
808− if (shareUrl == null) {
809− DropdownMenuItem(
810− text = { Text(createShareLabel) },
811− enabled = hasMessages,
812− onClick = { showOverflowMenu = false; vm.shareSession() },
813− )
814− } else {
815− DropdownMenuItem(
816− text = { Text(copyShareLabel) },
817− onClick = {
818− showOverflowMenu = false
819− shareUrl?.let {
820− copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
821− scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
822− }
823− },
824− )
825− DropdownMenuItem(
826− text = { Text(stopShareLabel) },
827− onClick = { showOverflowMenu = false; vm.unshareSession() },
828− )
829− }
830− DropdownMenuItem(
831− text = { Text(summarizeLabel) },
832− enabled = hasMessages && !running,
833− onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
834− )
835− DropdownMenuItem(
836− text = { Text(initLabel) },
837− enabled = !running,
838− onClick = { showOverflowMenu = false; showInitConfirm = true },
839− )
840− DropdownMenuItem(
841− text = { Text(shellLabel) },
842− enabled = !running,
843− onClick = { showOverflowMenu = false; showShellDialog = true },
844− )
845− androidx.compose.material3.HorizontalDivider()
846− DropdownMenuItem(
847− text = { Text(renameLabel) },
848− onClick = { showOverflowMenu = false; showRenameDialog = true },
849− )
850− DropdownMenuItem(
851− text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
852− onClick = { showOverflowMenu = false; showDeleteDialog = true },
853− )
854− }
855− // While a catalog is initially loading (empty list + loading flag),
856− // show a tiny inline spinner beside the overflow so the user can see
857− // a load is in progress without opening the menu. Once loaded the
858− // spinner disappears; the menu items are always enabled (tapping a
859− // picker with no items shows the "no models/agents/commands" state).
860− val catalogLoading = (commandsLoading && commands.isEmpty()) ||
861− (agentsLoading && agents.isEmpty()) ||
862− (modelsLoading && models.isEmpty())
863− if (catalogLoading) {
864− CircularProgressIndicator(
865− Modifier
866− .align(Alignment.TopEnd)
867− .size(10.dp)
868− .semantics { contentDescription = loadingLabel },
869− strokeWidth = 1.dp,
870− )
871− }
872− }
873− },
874− )
875− },
876− snackbarHost = { SnackbarHost(snackbar) },
877− bottomBar = {
878− val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
879− val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
880− val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
881− val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
882− Column {
883− // Pinned above the composer so the agent's plan and progress stay visible
884− // while scrolled up mid-run; renders nothing when there's no plan.
885− TodoPlanBar(todoPlan)
886− val bannerMotion = rememberVisibilityTransitions()
887− AnimatedVisibility(
888− visible = reverted,
889− enter = bannerMotion.enter,
890− exit = bannerMotion.exit,
891− ) {
892− RevertBanner(
893− diff = revertDiff,
894− isEditing = editing,
895− onUndo = { vm.unrevert() },
896− )
897− }
898− AnimatedVisibility(
899− visible = queuedOffline.isNotEmpty(),
900− enter = bannerMotion.enter,
901− exit = bannerMotion.exit,
902− ) {
903− OutboxBanner(
904− count = queuedOffline.size,
905− sending = outboxSending,
906− onFlush = { vm.flushQueued() },
907− onDiscard = { vm.discardAllQueued() },
908− )
909− }
910− AnimatedVisibility(
911− visible = pendingQuote != null,
912− enter = bannerMotion.enter,
913− exit = bannerMotion.exit,
914− ) {
915− pendingQuote?.let { QuoteReplyBanner(it, onCancel = { vm.cancelQuoteReply() }) }
916− }
917− ChatInputBar(
918− value = draft,
919− onValueChange = vm::updateDraft,
920− running = running,
921− aborting = aborting,
922− enabled = activeConnection != null,
923− sendOnEnter = sendOnEnter,
924− onSend = ::doSend,
925− onAbort = { vm.abort() },
926− queuedFollowUp = queuedFollowUp,
927− onQueueFollowUp = vm::queueFollowUp,
928− onCancelQueue = { vm.queueFollowUp("") },
929− focusRequester = inputFocusRequester,
930− attachments = attachments,
931− staging = stagingCount > 0,
932− stagingFileCount = stagingFileTotal,
933− onRemoveAttachment = vm::removeAttachment,
934− onPickPhoto = {
935− photoPicker.launch(
936− PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
937− )
938− },
939− onPickFile = { filePicker.launch("*/*") },
940− onCamera = { launchCamera() },
941− onVoice = { launchVoice() },
942− onPasteImage = { stageUris(it) },
943− commands = commands,
944− onRunCommand = { cmd ->
945− vm.runCommand(cmd)
946− // Clear the draft so the typed /prefix doesn't linger after the
947− // command fires (the command picker confirmation, if any, is
948− // handled inside runCommand).
949− vm.updateDraft("")
950− },
951− )
952− }
953− },
954− ) { padding ->
955− // Collect messages inside the content lambda so streaming token updates
956− // recompose only this subtree, not the top bar / input bar / 20+ other
957− // state reads in the ChatScreen body.
958− val messages by vm.messages.collectAsStateWithLifecycle()
959− val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
960− val listState = rememberLazyListState()
961− val contentScope = rememberCoroutineScope()
962− val timeTick = rememberRelativeTimeTick()
963− // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
964− // chat affordance for reading while the IME is open. Watching isScrollInProgress via
965− // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
966− val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
967− androidx.compose.runtime.LaunchedEffect(listState) {
968− androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
969− .distinctUntilChanged()
970− .collect { scrolling -> if (scrolling) keyboardController?.hide() }
971− } // Group messages by day and interleave date separators so a long conversation
972− // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
973− // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
974− // outside the PullToRefreshBox so the auto-scroll effects below can reference
975− // the same list the LazyColumn renders (separators shift the indices vs. the
976− // raw messages list, so scroll targets must be in listItems space).
977− val todayLabel = stringResource(R.string.today)
978− val yesterdayLabel = stringResource(R.string.yesterday)
979− // In-conversation search: when active, narrow to messages whose text contains the query.
980− // Computed on the filtered set so day separators still align with the visible messages.
981− // Debounced + off-main-thread so a keystroke-by-keystroke filter walk over a long
982− // conversation (with many tool parts whose state stringification is non-trivial)
983− // doesn't jank the UI. produceState yields the empty list immediately and publishes
984− // the filtered result once the debounce settles.
985− val searchQuery = chatSearch.trim()
986− // When no search is active, skip the produceState indirection entirely and use the
987− // messages list directly. produceState keyed on (messages, searchQuery) re-launches
988− // its coroutine on every messages emission (every streamed token) even when the
989− // query is empty — the body just re-assigns `value = messages`, but the coroutine
990− // churn (launch + cancel + state write) ~20x/sec is pure overhead. Using `messages`
991− // directly when there's no search avoids that, and `buildMessageListItems` keys its
992− // own remember on the resulting list identity either way.
993− val searchMessages: List<MessageWithParts> = if (searchQuery.isEmpty()) {
994− messages
995− } else {
996− // Capture the messages list via rememberUpdatedState so the latest list is read
997− // when the debounce completes, WITHOUT re-launching the produceState coroutine on
998− // every messages emission. Keying produceState on `messages` restarts the debounce
999− // delay from zero on every streamed token (~20×/sec); if tokens arrive faster than
1000− // the debounce interval the filter never runs and search shows "no matches" for the
1001− // entire run. Keying only on `searchQuery` lets the debounce settle once per query
1002− // while still filtering against the most recent list.
1003− val currentMessages by rememberUpdatedState(messages)
1004− val filtered by produceState(
1005− initialValue = emptyList(),
1006− searchQuery,
1007− ) {
1008− // Debounce: wait for the user to stop typing before running the filter,
1009− // so each keystroke doesn't kick off a fresh scan.
1010− kotlinx.coroutines.delay(NetworkConfig.chatSearchDebounceMs)
1011− // Run the filter off the main thread — the walk stringifies tool state
1012− // and scans every part of every message, which is non-trivial work for a
1013− // long conversation.
1014− value = withContext(Dispatchers.Default) {
1015− currentMessages.filter { m ->
1016− m.parts.any { p ->
1017− when (p) {
1018− is TextPart -> p.text.contains(searchQuery, ignoreCase = true)
1019− is ReasoningPart -> p.text.contains(searchQuery, ignoreCase = true)
1020− is ToolPart -> {
1021− // Match the tool name plus its running/completed/error text and the
1022− // stringified input, so searching for an error string or a file the
1023− // tool touched actually finds the message it appeared in.
1024− p.tool.contains(searchQuery, ignoreCase = true) ||
1025− (p.state is ToolRunning && p.state.title?.contains(searchQuery, ignoreCase = true) == true) ||
1026− (p.state is ToolCompleted && (p.state.title?.contains(searchQuery, ignoreCase = true) == true ||
1027− p.state.output?.contains(searchQuery, ignoreCase = true) == true)) ||
1028− (p.state is ToolError && p.state.error?.contains(searchQuery, ignoreCase = true) == true) ||
1029− p.state.inputElement()?.toString()?.contains(searchQuery, ignoreCase = true) == true
1030− }
1031− is FilePart -> p.filename?.contains(searchQuery, ignoreCase = true) == true ||
1032− p.sourcePath?.contains(searchQuery, ignoreCase = true) == true
1033− else -> false
1034− }
1035− }
1036− }
1037− }
1038− searchFiltering = false
1039− }
1040− filtered
1041− }
1042− // Keep the focused-match index in range as the query (and thus the match set) changes.
1043− // A new query resets to the first match (keyed on searchQuery alone so any change —
1044− // even one that still yields enough matches — jumps to index 0 rather than carrying
1045− // the old index over to a semantically unrelated message). A shrunk set clamps to
1046− // the last valid index (keyed on size so a content refresh that shrinks the matches
1047− // without a query change doesn't leave searchPos out of range).
1048− LaunchedEffect(searchQuery) {
1049− if (searchQuery.isNotEmpty()) searchFiltering = true
1050− searchPos = 0
1051− }
1052− LaunchedEffect(searchMessages.size) {
1053− if (searchPos >= searchMessages.size) searchPos = (searchMessages.size - 1).coerceAtLeast(0)
1054− }
1055− val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
1056− buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
1057− }
1058− // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
1059− // derivedStateOf and snapshotFlow lambdas below are created once (keyless
1060− // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
1061− // would capture the first composition's list — which is empty, since `messages`
1062− // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
1063− // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
1064− // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
1065− // whose value tracks the latest list.
1066− val currentListItems by rememberUpdatedState(listItems)
1067−
1068− // New-content signal for the jump-to-latest FAB: counts how many new items arrived
1069− // while the user was scrolled away from the bottom, cleared once they return. A count
1070− // (vs. a bare dot) tells the user how much they missed at a glance.
1071− // rememberSaveable (not plain remember) so the badge count survives rotation: plain
1072− // remember resets to 0 on recreation, hiding the "new content" signal even though new
1073− // messages are still arriving. Int is Bundle-savable.
1074− var newContentCount by rememberSaveable { mutableIntStateOf(0) }
1075−
1076− val isPinnedToBottom by remember {
1077− derivedStateOf {
1078− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1079− // When a run is active, the LazyColumn has one trailing "__typing"
1080− // item after the last list item. Account for it so "pinned to bottom"
1081− // includes the working indicator — otherwise the pin check targets
1082− // listItems.lastIndex, the typing row sits just below the viewport,
1083− // and the user never sees the progress indicator even when pinned.
1084− val items = currentListItems
1085− val effectiveLast = if (running) items.size else items.lastIndex
1086− lastVisible >= effectiveLast || items.isEmpty()
1087− }
1088− }
1089−
1090− // Jump to the newest message once, when the conversation first loads. Guarded by a
1091− // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
1092− // (rotation) — rememberLazyListState restores the user's scroll offset, and an
1093− // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
1094− var didInitialScroll by rememberSaveable { mutableStateOf(false) }
1095− LaunchedEffect(listItems.isNotEmpty()) {
1096− if (listItems.isNotEmpty() && !didInitialScroll) {
1097− // When a run is active the LazyColumn has a trailing "__typing" row at
1098− // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
1099− // running session lands truly pinned to bottom and streaming auto-scroll engages.
1100− listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
1101− didInitialScroll = true
1102− }
1103− }
1104−
1105− // When a run starts, the LazyColumn gains a trailing "__typing" row, so
1106− // effectiveLast jumps from listItems.lastIndex to listItems.size and the
1107− // isPinnedToBottom check flips to false the instant you send — freezing
1108− // auto-scroll. If the user was pinned to the last item just before the run
1109− // began, bring the typing row into view so the pin (and streaming follow) is
1110− // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
1111− // recomputed to false by now) so we don't scroll when the user had scrolled up.
1112− LaunchedEffect(running) {
1113− if (running && listItems.isNotEmpty()) {
1114− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
1115− if (lastVisible >= listItems.lastIndex) {
1116− runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
1117− }
1118− }
1119− }
1120−
1121− // Scroll the focused search match into view whenever the user steps through results with
1122− // the up/down arrows. Resolves the filtered message to its slot in listItems (which
1123− // includes date separators) and animates there.
1124− //
1125− // Keyed ONLY on searchPos and searchQuery (not listItems): a bare listItems key re-keys
1126− // on every streamed token (the list is rebuilt per snapshot), which re-fires this effect
1127− // ~20×/sec during an active stream — re-scrolling to the focused match on every token
1128− // and making it impossible to browse other matches with the up/down arrows while content
1129− // is streaming. snapshotFlow (reading currentListItems, a rememberUpdatedState) waits for
1130− // the target to appear without re-keying the effect on every list change, mirroring the
1131− // focusedMessageId pattern below.
1132− LaunchedEffect(searchPos, searchQuery) {
1133− if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
1134− val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
1135− val targetId = target.info.id
1136− val index = snapshotFlow {
1137− currentListItems.indexOfFirst {
1138− it is MessageListItem.Message && it.message.info.id == targetId
1139− }
1140− }.first { it >= 0 }
1141− runCatchingCancellable { listState.animateScrollToItem(index) }
1142− }
1143−
1144− // Global-search deep link: scroll to and briefly highlight the matched message. The
1145− // focus id is threaded through the chat route from GlobalSearchScreen. The highlight
1146− // clears itself after a short delay so the user can re-read the surrounding context
1147− // without a persistent marker. Runs once per focusMessageId (cleared on consume).
1148− //
1149− // Keyed ONLY on focusedMessageId (not listItems): a bare listItems key re-keys on every
1150− // streamed token (the list is rebuilt per snapshot), which cancels the 2.5s delay before
1151− // it can clear the highlight — re-scrolling to the focus on every token and making an
1152− // actively-streaming chat unreadable. snapshotFlow waits for the message to appear in the
1153− // list (it may not be present on the first composition while messages load) without
1154− // re-keying the effect on every list change.
1155− // rememberSaveable (not plain remember) so the cleared null survives rotation:
1156− // plain remember keyed on focusMessageId re-seeds the non-null nav arg on every
1157− // recreation, re-firing this effect and yanking scroll back to the focus after the
1158− // user already dismissed the highlight. String? is Bundle-savable.
1159− var focusedMessageId by rememberSaveable(focusMessageId) { mutableStateOf(focusMessageId) }
1160− val currentListItemsForFocus by rememberUpdatedState(listItems)
1161− LaunchedEffect(focusedMessageId) {
1162− val focus = focusedMessageId ?: return@LaunchedEffect
1163− // Wait for the focused message to appear in the list, then resolve its index. A
1164− // first-composition empty list (messages still loading) will populate as the seed
1165− // arrives; snapshotFlow re-evaluates on each list change until the match is found.
1166− val index = snapshotFlow {
1167− currentListItemsForFocus.indexOfFirst {
1168− it is MessageListItem.Message && it.message.info.id == focus
1169− }
1170− }.first { it >= 0 }
1171− runCatchingCancellable { listState.animateScrollToItem(index) }
1172− kotlinx.coroutines.delay(2500)
1173− focusedMessageId = null
1174− }
1175−
1176− // Scroll to bottom when the IME opens and the user was already pinned to bottom, so the
1177− // latest reply stays visible as the composer rises. Without this the IME can obscure the
1178− // last message even though the user was reading it a moment ago. WindowInsets.ime is a
1179− // @Composable property, so we read it inside a snapshotFlow registered in the composable
1180− // scope; the flow re-emits whenever the IME inset changes (open/close transitions).
1181− val density = androidx.compose.ui.platform.LocalDensity.current
1182− var wasImeOpen by rememberSaveable { mutableStateOf(false) }
1183− val imeBottomPx = WindowInsets.ime.getBottom(density)
1184− androidx.compose.runtime.LaunchedEffect(imeBottomPx, listItems.isNotEmpty(), isPinnedToBottom) {
1185− if (listItems.isEmpty()) return@LaunchedEffect
1186− val imeOpen = imeBottomPx > 0
1187− // Scroll to bottom only on the open transition (not close) and only when the user
1188− // was already pinned to bottom — otherwise we'd yank them out of a mid-history read.
1189− val shouldScroll = imeOpen && !wasImeOpen && isPinnedToBottom
1190− if (shouldScroll) {
1191− runCatchingCancellable {
1192− listState.animateScrollToItem(if (running) listItems.size else listItems.lastIndex)
1193− }
1194− }
1195− wasImeOpen = imeOpen
1196− }
1197−
1198− LaunchedEffect(Unit) {
1199− var prevSize = 0
1200− var prevLen = 0
1201− var initialized = false
1202− snapshotFlow {
1203− // Track list size + the streaming length of the last part so we auto-scroll
1204− // as content arrives, plus the pinned flag. A small data class with primitive
1205− // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
1206− // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
1207− // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
1208− // and tool output — not just TextPart — keeps a pinned view following a long
1209− // reasoning/tool block while it streams (previously it stalled at 0).
1210− val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
1211− AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
1212− }.collect { signal ->
1213− if (!initialized) {
1214− prevSize = signal.size
1215− prevLen = signal.lastTextLength
1216− initialized = true
1217− return@collect
1218− }
1219− if (signal.pinned) {
1220− // Back at the bottom: clear the new-content badge.
1221− newContentCount = 0
1222− if (signal.size > 0) {
1223− // Scroll to the effective last index, including the trailing
1224− // "__typing" row when a run is active so the working indicator
1225− // is brought into view (not just the last message).
1226− val items = currentListItems
1227− val target = if (running) items.size else items.lastIndex
1228− listState.scrollToItem(target)
1229− }
1230− } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
1231− // Content arrived while scrolled up — increment the jump-to-latest badge.
1232− // Only count whole new items (not per-token growth) so the number reflects
1233− // new messages, not a token count.
1234− if (signal.size > prevSize) newContentCount += signal.size - prevSize
1235− else if (newContentCount == 0) newContentCount = 1
1236− }
1237− prevSize = signal.size
1238− prevLen = signal.lastTextLength
1239− }
1240− }
1241−
1242− CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
1243− // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
1244− // content padding accounts for the bottomBar's raised height, so the message
1245− // list is already above the keyboard. Adding imePadding here would double-apply
1246− // the IME inset and push messages too far up.
1247− //
1248− // BoxWithConstraints so the FABs and search bar can align to the capped (800dp)
1249− // message list on wide screens instead of floating at the screen edges — on a
1250− // tablet the list is a centered column, so edge-aligned FABs are visually detached.
1251− // The horizontal margin below is applied to the FAB/search containers so they
1252− // track the list's left/right edges.
1253− androidx.compose.foundation.layout.BoxWithConstraints(
1254− modifier = Modifier.fillMaxSize().padding(padding),
1255− ) {
1256− val screenMaxWidth = maxWidth
1257− val listMaxWidth = NetworkConfig.chatContentMaxWidthDp.dp
1258− // Horizontal margin between the capped list's edge and the screen edge (0 on
1259− // screens narrower than the cap). Applied as start/end padding so the FABs and
1260− // search bar sit at the list's edges, not the screen's.
1261− val sideMargin = ((screenMaxWidth - listMaxWidth) / 2).coerceAtLeast(0.dp)
1262− if (activeConnection == null) {
1263− Column(
1264− modifier = Modifier.align(Alignment.Center).padding(24.dp),
1265− horizontalAlignment = Alignment.CenterHorizontally,
1266− ) {
1267− Text(
1268− stringResource(R.string.not_connected),
1269− color = MaterialTheme.colorScheme.error,
1270− )
1271− Spacer(Modifier.size(12.dp))
1272− Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
1273− if (reconnecting) {
1274− CircularProgressIndicator(
1275− Modifier.size(20.dp),
1276− color = MaterialTheme.colorScheme.onPrimary,
1277− strokeWidth = 2.dp,
1278− )
1279− } else {
1280− Text(stringResource(R.string.reconnect))
1281− }
1282− }
1283− }
1284− } else {
1285− val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
1286− // Measure the banner's actual height instead of hardcoding 44dp. At large
1287− // accessibility font scales the banner's labelMedium text + spinner + Retry
1288− // button exceed 44dp, so a fixed inset lets the first message hide behind it
1289− // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
1290− // fires while the banner is composed (visible), so gate the inset on
1291− // bannerVisible and fall back to 44dp until the first measurement arrives.
1292− val topPad = if (bannerVisible) {
1293− NetworkConfig.connectionBannerHeightDp.dp + 4.dp
1294− } else {
1295− 16.dp
1296− } + if (searchActive) {
1297− NetworkConfig.chatSearchBarHeightDp.dp + 4.dp
1298− } else {
1299− 0.dp
1300− }
1301− ConnectionBanner(
1302− state = connectionState,
1303− modifier = Modifier.align(Alignment.TopCenter),
1304− isOnline = isOnline,
1305− // On a hard endpoint failure, retry by forcing an SSE reconnect (which
1306− // re-seeds from REST). refreshMessages is the right recovery path
1307− // when the connection is present but the stream died; reconnect()
1308− // is the path when the whole connection is gone (handled by the
1309− // separate "Not connected" state below).
1310− onRetry = { vm.refreshMessages() },
1311− // On an auth failure (401/403), retrying with the same bad credentials
1312− // is futile — offer a one-tap path to edit the active profile's
1313− // credentials instead of making the user hunt for the server list.
1314− onEditCredentials = {
1315− val profileId = activeConnection?.profile?.id
1316− if (profileId != null) onEditProfile?.invoke(profileId)
1317− },
1318− reconnectAttempts = reconnectAttempts,
1319− )
1320− // Persistent inline error banner for a mid-conversation load failure.
1321− // Unlike the one-shot snackbar (which fires once per streak then goes
1322− // silent), this stays visible until the stream recovers, so a persistent
1323− // outage doesn't read as "everything is fine" after the snackbar dismisses.
1324− // Anchored at the top, below the connection banner, so it doesn't overlap
1325− // the message list.
1326− if (loadErrorInline && hasMessages) {
1327− Surface(
1328− color = MaterialTheme.colorScheme.errorContainer,
1329− shape = MaterialTheme.shapes.small,
1330− modifier = Modifier
1331− .align(Alignment.TopCenter)
1332− .padding(top = if (bannerVisible) topPad else 8.dp)
1333− .padding(horizontal = 16.dp)
1334− .semantics { liveRegion = LiveRegionMode.Polite },
1335− ) {
1336− Row(
1337− modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
1338− verticalAlignment = Alignment.CenterVertically,
1339− horizontalArrangement = Arrangement.spacedBy(8.dp),
1340− ) {
1341− Icon(
1342− Icons.Filled.ErrorOutline,
1343− contentDescription = null,
1344− modifier = Modifier.size(16.dp),
1345− tint = MaterialTheme.colorScheme.onErrorContainer,
1346− )
1347− Text(
1348− stringResource(R.string.load_error_persistent),
1349− style = MaterialTheme.typography.labelMedium,
1350− color = MaterialTheme.colorScheme.onErrorContainer,
1351− modifier = Modifier.weight(1f),
1352− )
1353− TextButton(
1354− onClick = { vm.refreshMessages() },
1355− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
1356− ) {
1357− Text(stringResource(R.string.retry))
1358− }
1359− }
1360− }
1361− }
1362− // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1363− // recovery is a swipe away everywhere — not just when the message list is
1364− // populated. Previously PTR was only around the populated list, leaving the
1365− // error/loading states reachable only via a button tap.
1366− PullToRefreshBox(
1367− isRefreshing = refreshing,
1368− // Suppress the refresh action while a run is active: a mid-stream
1369− // refresh forces an SSE reconnect that can interrupt the actively
1370− // streaming reply (matching the top-bar refresh button, which is
1371− // also disabled while running). The pull gesture still responds, but
1372− // the release no-ops instead of triggering a disruptive re-seed.
1373− onRefresh = { if (!running) vm.refreshMessages() },
1374− // Cap width and center on large screens for readability — full-width
1375− // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1376− modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1377− .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1378− ) {
1379− // Reserve top space when the banner is visible so it doesn't overlap the
1380− // first message / state content. (topPad computed above.)
1381− if (loading && messages.isEmpty()) {
1382− // Skeleton bubbles pre-structure the layout so the perceived load is faster
1383− // than a bare centered spinner and the first real messages pop into an
1384− // already-shaped list instead of a blank-then-pop.
1385− ChatSkeleton(topPad = topPad)
1386− } else if (loadError && messages.isEmpty()) {
1387− // A failed load with nothing to show. Distinct from the empty
1388− // conversation state below — the conversation isn't empty, it
1389− // failed to load, and offering "Start a conversation" here is
1390− // misleading. Offer a Retry instead, mirroring SessionListScreen.
1391− Column(
1392− modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1393− horizontalAlignment = Alignment.CenterHorizontally,
1394− ) {
1395− Text(
1396− stringResource(R.string.load_messages_failed),
1397− color = MaterialTheme.colorScheme.error,
1398− )
1399− Spacer(Modifier.size(12.dp))
1400− Button(onClick = { vm.refreshMessages() }) {
1401− Text(stringResource(R.string.retry))
1402− }
1403− // When the VM's retryWhen loop is actively retrying (loading is true
1404− // on top of the error state), surface a small "retrying…" indicator so
1405− // the user knows the system is working — without it, the static error
1406− // + Retry button reads as "broken, tap to fix", and the user may not
1407− // realize an automatic retry is already in progress.
1408− if (loading) {
1409− Spacer(Modifier.size(12.dp))
1410− val retryingLabel = stringResource(R.string.retrying_ellipsis)
1411− Row(verticalAlignment = Alignment.CenterVertically) {
1412− CircularProgressIndicator(
1413− Modifier.size(14.dp),
1414− strokeWidth = 2.dp,
1415− )
1416− Text(
1417− retryingLabel,
1418− style = MaterialTheme.typography.labelSmall,
1419− color = MaterialTheme.colorScheme.onSurfaceVariant,
1420− modifier = Modifier.padding(start = 6.dp),
1421− )
1422− }
1423− }
1424− }
1425− } else if (messages.isEmpty() && running) {
1426− // A run just started but no parts have arrived yet — the empty list with
1427− // only the trailing "working…" row looks broken. Reuse the skeleton bubbles
1428− // from the initial-load branch so the perceived start latency is lower and
1429− // the first real assistant part pops into an already-shaped list.
1430− ChatSkeleton(topPad = topPad)
1431− } else if (messages.isEmpty()) {
1432− EmptyConversation(
1433− onSuggestion = {
1434− vm.updateDraft(it)
1435− // Focus the input so the user can edit the suggestion before
1436− // sending, instead of having to manually tap the field.
1437− runCatching { inputFocusRequester.requestFocus() }
1438− },
1439− modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1440− )
1441− } else {
1442− val lastMessageId = messages.lastOrNull()?.info?.id
1443− // Text-to-speech for reading assistant replies aloud; shuts down when the
1444− // message list leaves composition.
1445− val tts = rememberTtsController()
1446− val speakingMessageId by tts.speakingId
1447− val ttsState by tts.state
1448− // Provide the in-conversation search query to the markdown renderer so matching
1449− // text spans are highlighted in-place (in addition to the focused-row highlight
1450− // above). Scoped to the list so cleared search stops highlighting immediately.
1451− val activeSearchQuery = searchQuery.takeIf { searchActive && it.isNotEmpty() }
1452− CompositionLocalProvider(LocalSearchHighlight provides activeSearchQuery) {
1453− LazyColumn(
1454− state = listState,
1455− modifier = Modifier.fillMaxSize(),
1456− // Extra bottom padding reserves room for the jump-to-latest FAB so it
1457− // never floats over the last message. Top padding grows when the
1458− // connection banner is visible so it doesn't overlap the first message.
1459− contentPadding = PaddingValues(
1460− start = 16.dp,
1461− top = topPad,
1462− end = 16.dp,
1463− bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1464− ),
1465− // Compact spacing (8dp) vs the default 16dp — a power-user toggle to fit
1466− // more conversation on screen without scrolling as much.
1467− verticalArrangement = Arrangement.spacedBy(if (compactSpacing) 8.dp else 16.dp),
1468− ) {
1469− items(
1470− items = listItems,
1471− key = { it.key },
1472− contentType = { it.contentType },
1473− ) { item ->
1474− // When in-conversation search is stepping through matches, highlight the
1475− // focused one's row so the user can spot it at a glance among the filtered
1476− // results (full in-text term highlighting would require threading the query
1477− // through the markdown renderer; the row highlight + scroll-to-match covers
1478− // the navigation use case desktop chat apps solve with next/prev).
1479− val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1480− searchMessages.getOrNull(searchPos)?.info?.id
1481− } else null
1482− val isFocusedMatch = focusedMatchId != null &&
1483− item is MessageListItem.Message &&
1484− item.message.info.id == focusedMatchId
1485− // No animateItem() here. It is backed by SizeAnimationModifierNode, which
1486− // forces a draw-phase remeasure (the display-list rebuild triggered by
1487− // AndroidComposeView.dispatchDraw) when its target's size changes. That
1488− // remeasure runs inside the draw pass and reads Placeable.getWidth()/
1489− // getHeight() of a child whose measured size hasn't been committed yet —
1490− // an NPE in PaddingValuesModifier.measure / FillNode.measure (the R8 map
1491− // marks both sites with a throws(NullPointerException) rewriteFrame).
1492− //
1493− // This is the same crash class previously fixed for the markdown library's
1494− // animateContentSize and MessageBubble's send-status AnimatedContent. An
1495− // earlier fix kept animateItem() but skipped it for the actively-streaming
1496− // row; that was insufficient because the crash also fires when OPENING a
1497− // chat (running = false, so every row kept animateItem()): historical rows
1498− // with async-loading content (DiffView's produceState starts empty and
1499− // fills a frame later; RemoteImage loading; large markdown) change size
1500− // after mount and trigger the same draw-phase remeasure. animateItem() is
1501− // fundamentally unsafe on any chat row whose size can change post-mount,
1502− // so it is removed entirely. The glide-in animation it provided is
1503− // decorative (the list already auto-scrolls to new messages) and is not
1504− // worth a 4th round of this crash.
1505− Box(
1506− Modifier
1507− .then(
1508− if (isFocusedMatch) {
1509− Modifier.background(
1510− MaterialTheme.colorScheme.secondaryContainer,
1511− MaterialTheme.shapes.medium,
1512− )
1513− } else {
1514− Modifier
1515− },
1516− ),
1517− ) {
1518− when (item) {
1519− is MessageListItem.Separator -> DateSeparator(item.label)
1520− is MessageListItem.Message -> {
1521− val message = item.message
1522− val messageId = message.info.id
1523− // Only the last (streaming) message needs isRunning — it drives the
1524− // reasoning-block spinner. Passing the live flag to every bubble
1525− // makes all visible messages recompose whenever a run starts or stops.
1526− val modelLabel = remember(message.info, models) {
1527− (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1528− ?.let { resolveModelLabel(it, models) }
1529− }
1530− // Key agentLabel/quoteText on the message id + the id of the
1531− // last TextPart/AgentPart, so the streaming message's label
1532− // updates when a NEW text/agent part is appended (its id
1533− // changes) without re-running filterIsInstance per token (a
1534− // streaming TextPart's id is stable across token updates, so
1535− // the key only changes on append, not on every token). The
1536− // prior `remember(messageId)` pinned these at first
1537− // composition: for a message that started with reasoning/tool
1538− // parts before any TextPart, quoteText stayed "" for the life
1539− // of the list item, so swipe-to-reply yielded "cannot quote
1540− // this" even after text arrived.
1541− val lastTextPartId = remember(message.parts) {
1542− message.parts.lastOrNull { it is TextPart }?.id
1543− }
1544− val lastAgentPartId = remember(message.parts) {
1545− message.parts.filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1546− .lastOrNull { it.name.isNotBlank() }?.id
1547− }
1548− val agentLabel = remember(messageId, lastAgentPartId) {
1549− message.parts
1550− .filterIsInstance<soy.iko.opencode.data.model.AgentPart>()
1551− .firstOrNull { it.name.isNotBlank() }?.name
1552− }
1553− // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1554− // image/code-only messages, which then opt out of the swipe gesture.
1555− val quoteText = remember(messageId, lastTextPartId) {
1556− message.parts
1557− .filterIsInstance<TextPart>()
1558− .joinToString("\n\n") { it.text }
1559− .trim()
1560− .takeIf { it.isNotEmpty() }
1561− }
1562− // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1563− // snapping back (the message isn't dismissed — the quote fills the composer).
1564− // Enabled for non-text messages too (image/tool-only), but the confirm
1565− // callback gives a haptic without triggering reply — so the user gets a
1566− // perceptible "can't quote this" signal instead of a silently dead gesture.
1567− val cantQuoteMsg = stringResource(R.string.cannot_quote_this)
1568− val undoLabel = stringResource(R.string.undo)
1569− val quoteReplyLabel = stringResource(R.string.quote_reply_banner)
1570− // Memoize the swipe confirmValueChange lambda on the message id + quoteText
1571− // so it's stable across recompositions (a fresh lambda would force the
1572− // SwipeToDismissBox to re-instantiate its state capture). quoteText is a
1573− // derived value that's stable for non-streaming messages.
1574− val swipeState = rememberSwipeToDismissBoxState(
1575− confirmValueChange = remember(messageId, quoteText) {
1576− { value ->
1577− val replyValue = if (layoutDirection == LayoutDirection.Rtl) {
1578− SwipeToDismissBoxValue.EndToStart
1579− } else {
1580− SwipeToDismissBoxValue.StartToEnd
1581− }
1582− if (value == replyValue) {
1583− if (quoteText != null) {
1584− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1585− vm.quoteReply(quoteText)
1586− runCatching { inputFocusRequester.requestFocus() }
1587− scope.launch {
1588− val res = snackbar.showSnackbar(
1589− message = quoteReplyLabel,
1590− actionLabel = undoLabel,
1591− )
1592− if (res == androidx.compose.material3.SnackbarResult.ActionPerformed) {
1593− vm.cancelQuoteReply()
1594− }
1595− }
1596− } else {
1597− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1598− scope.launch { snackbar.showSnackbar(cantQuoteMsg) }
1599− }
1600− }
1601− false
1602− }
1603− },
1604− )
1605− // In RTL locales, mirror the swipe direction so the reply
1606− // gesture reads naturally (swipe from the trailing edge
1607− // toward the leading edge), matching how LTR users swipe
1608− // left-to-right. Without this, RTL users had no reply swipe.
1609− val replyFromStartToEnd = layoutDirection != LayoutDirection.Rtl
1610− // Memoize every MessageBubble lambda on the message id so a recomposition
1611− // triggered by a sibling (e.g. the streaming message's parts changing,
1612− // which gives `listItems` a new identity) doesn't allocate fresh lambdas
1613− // for the unchanged bubbles. MessageBubble is non-skippable (it takes
1614− // many `(() -> Unit)?` params, which Compose treats as unstable), so
1615− // without memoization every visible bubble recomposes on every token,
1616− // defeating the @Immutable MessageWithParts reference-stability the
1617− // reducer's Holder cache provides. This mirrors the SessionListScreen
1618− // per-card lambda memoization pattern.
1619− val onRevert = remember(messageId) { { vm.revertTo(messageId) } }
1620− val onEdit = remember(messageId) {
1621− { text: String ->
1622− // editMessage reverts to before this message (hiding it and
1623− // everything after); the revert banner surfaces the rewind
1624− // with an Undo. Focus + scroll the composer into view like the
1625− // Quote action so the prefilled draft isn't off-screen.
1626− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1627− vm.editMessage(messageId, text)
1628− runCatching { inputFocusRequester.requestFocus() }
1629− contentScope.launch {
1630− runCatchingCancellable {
1631− listState.animateScrollToItem(currentListItems.lastIndex.coerceAtLeast(0))
1632− }
1633− }
1634− Unit
1635− }
1636− }
1637− val onSpeak = remember(messageId) {
1638− { text: String ->
1639− if (!tts.toggle(messageId, text)) {
1640− scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1641− }
1642− Unit
1643− }
1644− }
1645− val onPause = remember { { tts.pause() } }
1646− val onResume = remember { { tts.resume() } }
1647− val onStop = remember { { tts.stop() } }
1648− val onQuote = remember(messageId) {
1649− { text: String ->
1650− vm.quoteReply(text)
1651− runCatching { inputFocusRequester.requestFocus() }
1652− Unit
1653− }
1654− }
1655− val onBranch = remember { { text: String -> vm.branchFrom(text); Unit } }
1656− // Regenerate is only meaningful for a finished assistant reply
1657− // (not the actively-streaming one), so gate it on !running.
1658− val onRegenerate = if (!running) {
1659− remember(messageId) { { vm.regenerate(messageId) } }
1660− } else {
1661− null
1662− }
1663− // Continue: resume a partial reply. Shown only for
1664− // incomplete assistant messages (a partial reply that
1665− // was aborted) and not while a run is active.
1666− val canContinue = !running && message.info is AssistantMessage && !message.info.isComplete
1667− val onContinue = if (canContinue) {
1668− remember(messageId) { { vm.continueRun(messageId) } }
1669− } else {
1670− null
1671− }
1672− val messageFailed = optimisticStatuses[messageId] == true
1673− val onRetry = if (messageFailed) {
1674− remember(messageId) { { vm.retryOptimisticMessage(messageId); Unit } }
1675− } else {
1676− null
1677− }
1678− val onDismiss = if (messageFailed) {
1679− remember(messageId) { { vm.dismissOptimistic(messageId) } }
1680− } else {
1681− null
1682− }
1683− val onShare = remember(messageId, sessionTitle, defaultShareSubject) {
1684− {
1685− // Per-message share: build a single-message Markdown
1686− // transcript off-thread and fire ACTION_SEND, mirroring the
1687− // whole-conversation share but scoped to just this message.
1688− scope.launch {
1689− val md = withContext(Dispatchers.Default) {
1690− buildMessageMarkdown(message)
1691− } ?: return@launch
1692− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1693− type = "text/markdown"
1694− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1695− putExtra(android.content.Intent.EXTRA_TEXT, md)
1696− }
1697− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1698− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1699− }
1700− Unit
1701− }
1702− }
1703− SwipeToDismissBox(
1704− state = swipeState,
1705− enableDismissFromEndToStart = !replyFromStartToEnd,
1706− enableDismissFromStartToEnd = replyFromStartToEnd,
1707− backgroundContent = {
1708− Box(
1709− Modifier.fillMaxSize().padding(horizontal = 16.dp),
1710− contentAlignment = if (replyFromStartToEnd) Alignment.CenterStart else Alignment.CenterEnd,
1711− ) {
1712− Icon(
1713− Icons.AutoMirrored.Filled.Reply,
1714− contentDescription = null,
1715− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1716− )
1717− }
1718− },
1719− ) {
1720− MessageBubble(
1721− message,
1722− isRunning = running && messageId == lastMessageId,
1723− imageContext = imageContext,
1724− modelLabel = modelLabel,
1725− agentLabel = agentLabel,
1726− onOpenFile = onOpenFile,
1727− onRevert = onRevert,
1728− onEdit = onEdit,
1729− onSpeak = onSpeak,
1730− isSpeaking = messageId == speakingMessageId,
1731− ttsState = if (messageId == speakingMessageId) ttsState else TtsState.IDLE,
1732− onPause = onPause,
1733− onResume = onResume,
1734− onStop = onStop,
1735− onQuote = onQuote,
1736− onBranch = onBranch,
1737− onRegenerate = onRegenerate,
1738− onContinue = onContinue,
1739− sendStatus = optimisticStatuses[messageId]?.let { failed ->
1740− if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1741− else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1742− },
1743− isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1744− onRetry = onRetry,
1745− onDismiss = onDismiss,
1746− onShare = onShare,
1747− isFirstOfSpeaker = item.isFirstOfSpeaker,
1748− highlighted = focusedMessageId != null && messageId == focusedMessageId,
1749− )
1750− }
1751− }
1752− }
1753− }
1754− }
1755− if (running) {
1756− item(key = "__typing") {
1757− val workingText = stringResource(R.string.working)
1758− // When the SSE stream dropped mid-run, show a "reconnecting" label
1759− // instead of the plain "working" text so the user understands the
1760− // run is still active but the stream is reconnecting (not finished).
1761− val reconnectingText = stringResource(R.string.reconnecting)
1762− val interruptedLabel = if (streamInterrupted) reconnectingText else workingText
1763− // Elapsed-since-run chip driven by the VM's runStartMs so the timer
1764− // survives LazyColumn recycling — a local remember reset to 0:00 when
1765− // the row was disposed and scrolled back into view. The VM stamps the
1766− // start on the real false→true transition (send/run/init), not on
1767− // SSE-reconnect relight, so reconnects don't restart the clock. Updates
1768− // once per second; the value is decorative (the a11y label is the row's).
1769− // The clock freezes when an abort is confirmed (aborting = true) so the
1770− // user can see how long the run lasted before they stopped it, rather
1771− // than the clock continuing to tick until SessionIdle clears _running.
1772− val startMs by vm.runStartMs.collectAsStateWithLifecycle()
1773− // Seed the initial value synchronously from startMs so the timer
1774− // doesn't flash 0:00 when the __typing row is recycled by LazyColumn
1775− // (scrolled out then back in): produceState's coroutine is cancelled on
1776− // disposal and re-launched on re-entry, and a 0L initialValue would
1777− // briefly show "0:00" before the producer's first iteration runs.
1778− val elapsedMs by produceState(
1779− initialValue = if (startMs > 0L) (System.currentTimeMillis() - startMs).coerceAtLeast(0L) else 0L,
1780− startMs, aborting,
1781− ) {
1782− if (startMs == 0L) return@produceState
1783− if (aborting) {
1784− // Freeze at the last-computed value: one final snapshot then stop.
1785− value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1786− return@produceState
1787− }
1788− while (true) {
1789− value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1790− delay(1000)
1791− }
1792− }
1793− val elapsedText = remember(elapsedMs) {
1794− val total = elapsedMs / 1000
1795− // Locale.US for stable digit formatting: on locales with
1796− // non-ASCII digits (ar-/fa-) the default locale would render
1797− // localized digits inside the Monospace block, breaking the
1798− // fixed-width alignment the monospace family was chosen for.
1799− String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1800− }
1801− Row(
1802− verticalAlignment = Alignment.CenterVertically,
1803− // No animateItem() here for the same reason as the message rows
1804− // above: the __typing row's size changes (the elapsed-time Text
1805− // grows from "0:00" to "M:SS" and the spinner mounts on first
1806− // frame), and animateItem()'s SizeAnimationModifierNode forces a
1807− // draw-phase remeasure that NPEs on Placeable.getWidth(). The
1808− // working row is mounted/unmounted as a whole item (key="__typing")
1809− // by LazyColumn, which already crossfades the appearance; the
1810− // placement animation is not needed.
1811− modifier = Modifier
1812− .padding(vertical = 4.dp),
1813− ) {
1814− // Merge the indicator + label + clock into one TalkBack node so
1815− // it's announced once (the working label, then the elapsed time)
1816− // instead of "working… / working… / 0:14" with the label read
1817− // twice. The Stop IconButton below stays separately focusable.
1818− Row(
1819− verticalAlignment = Alignment.CenterVertically,
1820− modifier = Modifier.semantics(mergeDescendants = true) {
1821− contentDescription = "$interruptedLabel $elapsedText"
1822− },
1823− ) {
1824− // Dim the spinner when interrupted so the row reads as
1825− // "paused/reconnecting" rather than actively progressing.
1826− CircularProgressIndicator(
1827− Modifier.size(16.dp),
1828− strokeWidth = 2.dp,
1829− color = if (streamInterrupted) MaterialTheme.colorScheme.onSurfaceVariant
1830− else MaterialTheme.colorScheme.primary,
1831− )
1832− Text(interruptedLabel, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1833− Spacer(Modifier.size(8.dp))
1834− Icon(
1835− Icons.Filled.Schedule,
1836− contentDescription = null,
1837− modifier = Modifier.size(14.dp),
1838− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1839− )
1840− Text(
1841− elapsedText,
1842− style = MaterialTheme.typography.labelMedium,
1843− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1844− color = MaterialTheme.colorScheme.onSurfaceVariant,
1845− modifier = Modifier.padding(start = 4.dp),
1846− )
1847− }
1848− // A trailing Stop on the working row so the run can be aborted
1849− // without scrolling back to the composer (which may be off-screen
1850− // while the user reads the streaming output up-thread).
1851− Spacer(Modifier.weight(1f))
1852− val stopLabel = stringResource(R.string.stop)
1853− IconButton(
1854− onClick = { vm.abort() },
1855− modifier = Modifier.semantics { contentDescription = stopLabel },
1856− ) {
1857− Icon(Icons.Filled.Stop, contentDescription = null)
1858− }
1859− }
1860− }
1861− }
1862− }
1863− }
1864− }
1865− }
1866− // Jump-to-latest affordance when the user has scrolled away during a stream.
1867− val fabMotion = rememberVisibilityTransitions()
1868− AnimatedVisibility(
1869− visible = !isPinnedToBottom && listItems.isNotEmpty(),
1870− enter = fabMotion.enter,
1871− exit = fabMotion.exit,
1872− // Inset by sideMargin on wide screens so the FAB tracks the capped list's
1873− // edge instead of floating at the screen edge (visually detached on tablets).
1874− modifier = Modifier.align(Alignment.BottomEnd).padding(end = sideMargin),
1875− ) {
1876− // Badge the FAB when new content arrived while the user was scrolled up, so
1877− // they know there's something new to jump to (cleared once back at bottom).
1878− BadgedBox(
1879− badge = {
1880− if (newContentCount > 0) {
1881− // Merge the count into the badge announcement so TalkBack reads
1882− // "5 new messages" instead of the bare label and a separate number.
1883− val newContentLabel = pluralStringResource(
1884− R.plurals.plurals_new_messages,
1885− newContentCount,
1886− newContentCount,
1887− )
1888− Badge(
1889− modifier = Modifier.semantics {
1890− contentDescription = newContentLabel
1891− // Announce new-content counts as they change so a
1892− // TalkBack user hears "5 new messages" without having
1893− // to focus the FAB. Polite so it doesn't interrupt an
1894− // in-progress utterance (e.g. streaming token reads).
1895− liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite
1896− },
1897− ) {
1898− Text(
1899− if (newContentCount > 99) "99+" else newContentCount.toString(),
1900− modifier = Modifier.semantics { invisibleToUser() },
1901− )
1902− }
1903− }
1904− },
1905− modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1906− ) {
1907− ExtendedFloatingActionButton(
1908− onClick = {
1909− newContentCount = 0
1910− if (listItems.isNotEmpty()) {
1911− // Scroll to the effective last index, including the
1912− // trailing "__typing" row when a run is active.
1913− val target = if (running) listItems.size else listItems.lastIndex
1914− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1915− }
1916− },
1917− // contentDescription null: the visible "Latest" text already labels
1918− // the FAB, so a description here makes TalkBack announce "Latest" twice.
1919− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1920− text = { Text(stringResource(R.string.latest)) },
1921− )
1922− }
1923− }
1924− // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1925− // user has scrolled down past the first few messages. Symmetric to the
1926− // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1927− val scrolledDown by remember {
1928− derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1929− }
1930− AnimatedVisibility(
1931− visible = scrolledDown && listItems.size > 4,
1932− enter = fabMotion.enter,
1933− exit = fabMotion.exit,
1934− modifier = Modifier.align(Alignment.BottomStart).padding(start = sideMargin),
1935− ) {
1936− SmallFloatingActionButton(
1937− onClick = {
1938− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1939− },
1940− modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1941− ) {
1942− Icon(
1943− Icons.Filled.KeyboardArrowUp,
1944− contentDescription = stringResource(R.string.scroll_to_top),
1945− )
1946− }
1947− }
1948− // In-conversation search bar. Overlays the list so typing filters messages in
1949− // place; the match count + a no-matches note give immediate feedback.
1950− val searchMotion = rememberVisibilityTransitions()
1951− AnimatedVisibility(
1952− visible = searchActive,
1953− enter = searchMotion.enter,
1954− exit = searchMotion.exit,
1955− // Inset by sideMargin on wide screens so the search bar spans the capped
1956− // list's width, not the full screen width.
1957− modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = sideMargin),
1958− ) {
1959− Surface(
1960− tonalElevation = 3.dp,
1961− modifier = Modifier
1962− .padding(horizontal = 12.dp, vertical = 4.dp)
1963− .fillMaxWidth(),
1964− ) {
1965− // Focus the search field when the bar appears (from the overflow menu's
1966− // "Find in conversation" or Ctrl+F), so the user can start typing
1967− // immediately. Runs after the search field enters the composition.
1968− LaunchedEffect(searchActive) {
1969− if (searchActive) runCatching { searchFocusRequester.requestFocus() }
1970− }
1971− Row(
1972− modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1973− verticalAlignment = Alignment.CenterVertically,
1974− ) {
1975− OutlinedTextField(
1976− value = chatSearch,
1977− onValueChange = { chatSearch = it },
1978− modifier = Modifier.weight(1f).focusRequester(searchFocusRequester),
1979− placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1980− singleLine = true,
1981− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1982− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1983− )
1984− // Match counter: "N of M" where N is the focused match (1-based) and
1985− // M is the total matching messages. Only shown when there's a query.
1986− val matchCount = searchMessages.size
1987− Text(
1988− if (searchQuery.isNotEmpty() && matchCount > 0) {
1989− stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1990− } else if (searchQuery.isNotEmpty()) {
1991− stringResource(R.string.search_no_matches)
1992− } else "",
1993− style = MaterialTheme.typography.labelSmall,
1994− color = MaterialTheme.colorScheme.onSurfaceVariant,
1995− modifier = Modifier
1996− .padding(horizontal = 4.dp)
1997− // Live region so a TalkBack user hears "3 of 12" / "No
1998− // matches" announce as they step through results, not just
1999− // the sighted user seeing the counter update.
2000− .semantics { liveRegion = androidx.compose.ui.semantics.LiveRegionMode.Polite },
2001− )
2002− // Step through matches (Previous / Next), disabled when there are
2003− // fewer than two to navigate. Wrap around at the ends so repeated
2004− // taps cycle through every hit.
2005− val prevLabel = stringResource(R.string.find_previous)
2006− val nextLabel = stringResource(R.string.find_next)
2007− IconButton(
2008− onClick = {
2009− if (matchCount > 0) {
2010− searchPos = (searchPos - 1 + matchCount) % matchCount
2011− }
2012− },
2013− enabled = matchCount > 1,
2014− ) {
2015− Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
2016− }
2017− IconButton(
2018− onClick = {
2019− if (matchCount > 0) {
2020− searchPos = (searchPos + 1) % matchCount
2021− }
2022− },
2023− enabled = matchCount > 1,
2024− ) {
2025− Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
2026− }
2027− IconButton(onClick = {
2028− searchActive = false
2029− chatSearch = ""
2030− }) {
2031− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
2032− }
2033− }
2034− }
2035− }
2036− // When a search yields no matches (but the conversation isn't empty), say so
2037− // instead of showing a blank list.
2038− val noMatches = searchActive && searchQuery.isNotEmpty() &&
2039− !searchFiltering && searchMessages.isEmpty() && messages.isNotEmpty()
2040− if (noMatches) {
2041− Text(
2042− stringResource(R.string.search_no_matches),
2043− style = MaterialTheme.typography.bodyMedium,
2044− color = MaterialTheme.colorScheme.onSurfaceVariant,
2045− modifier = Modifier.align(Alignment.Center),
2046− )
2047− }
2048− }
2049− }
2050− }
2051− }
2052−
2053− if (showExitConfirm) {
2054− AlertDialog(
2055− onDismissRequest = { showExitConfirm = false },
2056− title = { Text(stringResource(R.string.stop_and_exit_title)) },
2057− text = { Text(stringResource(R.string.stop_and_exit_text)) },
2058− confirmButton = {
2059− TextButton(onClick = {
2060− showExitConfirm = false
2061− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2062− vm.abort()
2063− onBack()
2064− }) { Text(stringResource(R.string.stop_and_exit)) }
2065− },
2066− dismissButton = {
2067− TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
2068− },
2069− )
2070− }
2071−
2072− if (showSummarizeConfirm) {
2073− AlertDialog(
2074− onDismissRequest = { showSummarizeConfirm = false },
2075− title = { Text(stringResource(R.string.summarize_confirm_title)) },
2076− text = { Text(stringResource(R.string.summarize_confirm_text)) },
2077− confirmButton = {
2078− TextButton(onClick = {
2079− showSummarizeConfirm = false
2080− vm.summarize()
2081− }) { Text(stringResource(R.string.action_continue)) }
2082− },
2083− dismissButton = {
2084− TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
2085− },
2086− )
2087− }
2088−
2089− if (showInitConfirm) {
2090− AlertDialog(
2091− onDismissRequest = { showInitConfirm = false },
2092− title = { Text(stringResource(R.string.init_confirm_title)) },
2093− text = { Text(stringResource(R.string.init_confirm_text)) },
2094− confirmButton = {
2095− TextButton(onClick = {
2096− showInitConfirm = false
2097− vm.initProject()
2098− }) { Text(stringResource(R.string.action_continue)) }
2099− },
2100− dismissButton = {
2101− TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
2102− },
2103− )
2104− }
2105−
2106− if (showRenameDialog) {
2107− RenameSessionChatDialog(
2108− initialTitle = sessionTitle ?: "",
2109− onDismiss = { showRenameDialog = false },
2110− onConfirm = { newName ->
2111− showRenameDialog = false
2112− vm.renameSession(newName)
2113− },
2114− )
2115− }
2116−
2117− if (showDeleteDialog) {
2118− AlertDialog(
2119− onDismissRequest = { showDeleteDialog = false },
2120− title = { Text(stringResource(R.string.delete_session_chat_title)) },
2121− text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
2122− confirmButton = {
2123− TextButton(onClick = {
2124− showDeleteDialog = false
2125− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2126− vm.deleteSession()
2127− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
2128− },
2129− dismissButton = {
2130− TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
2131− },
2132− )
2133− }
2134−
2135− if (showShellDialog) {
2136− ShellCommandDialog(
2137− onDismiss = { showShellDialog = false },
2138− onRun = { cmd ->
2139− showShellDialog = false
2140− vm.runShell(cmd)
2141− },
2142− )
2143− }
2144−
2145− if (showModelPicker) {
2146− // Resolve persisted recent composite keys to the live ModelOption entries they refer
2147− // to, dropping any whose model is no longer offered by the server. Ordered by
2148− // recency (head of the store first).
2149− val recentOptions = remember(models, recentModelEntries) {
2150− val byKey = models.associateBy { it.providerID to it.modelID }
2151− recentModelEntries.mapNotNull { entry ->
2152− container.recentModelsStore.split(entry)?.let { (p, m) -> byKey[p to m] }
2153− }
2154− }
2155− // Resolve snackbar strings in the composable scope; the onSetPreferredModel lambda
2156− // below isn't @Composable, so stringResource can't be called inside it.
2157− val defaultModelClearedMsg = stringResource(R.string.default_model_cleared)
2158− val defaultModelSetFmt = stringResource(R.string.default_model_set, "%s")
2159− ModelPickerSheet(
2160− options = models,
2161− selected = selectedModel,
2162− loading = modelsLoading,
2163− error = modelsError,
2164− onSelect = {
2165− vm.selectModel(it)
2166− container.recentModelsStore.add(it.providerID, it.modelID)
2167− },
2168− onRetry = { vm.reloadModels() },
2169− onDismiss = { showModelPicker = false },
2170− preferredModelId = preferredModelId,
2171− onSetPreferredModel = { id ->
2172− scope.launch {
2173− runCatchingCancellable { container.settingsStore.setPreferredModelId(id) }
2174− // Confirm the change with a snackbar so the user sees it took effect —
2175− // the toggle's label flip can be missed, especially when clearing.
2176− val msg = if (id.isEmpty()) {
2177− defaultModelClearedMsg
2178− } else {
2179− val label = models.firstOrNull { it.modelID == id }?.modelLabel ?: id
2180− defaultModelSetFmt.format(label)
2181− }
2182− snackbar.showSnackbar(msg)
2183− }
2184− },
2185− recent = recentOptions,
2186− )
2187− }
2188−
2189− if (showAgentPicker) {
2190− AgentPickerSheet(
2191− agents = agents,
2192− selected = selectedAgent,
2193− loading = agentsLoading,
2194− error = agentsError,
2195− onSelect = { vm.selectAgent(it?.name) },
2196− onRetry = { vm.reloadAgents() },
2197− onDismiss = { showAgentPicker = false },
2198− )
2199− }
2200−
2201− if (showCommandPicker) {
2202− CommandPickerSheet(
2203− commands = commands,
2204− loading = commandsLoading,
2205− error = commandsError,
2206− onSelect = { vm.runCommand(it) },
2207− onRetry = { vm.reloadCommands() },
2208− onDismiss = { showCommandPicker = false },
2209− )
2210− }
2211−
2212− pendingPermission?.let { permission ->
2213− PermissionDialog(
2214− permission = permission,
2215− position = permissionProgress.position,
2216− total = permissionProgress.total,
2217− onRespond = { response -> vm.respondPermission(permission, response) },
2218− // On auto-reject timeout, post a notification so a returning user finds a
2219− // persistent record of why the run stopped (the dialog itself is gone).
2220− // Gated by the user's notification setting (notifPermission).
2221− onAutoReject = {
2222− val conn = activeConnection
2223− val ctx = appContext
2224− val title = sessionTitle ?: sessionId
2225− if (notifPermission) {
2226− soy.iko.opencode.notification.SessionNotifications.postPermissionAutoRejected(
2227− ctx, sessionId, title, conn?.profile?.id,
2228− )
2229− }
2230− },
2231− )
2232− }
2233−
2234− if (showPalette) {
2235− CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
2236− }
2237−
2238− if (showShortcutsDialog) {
2239− // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
2240− // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
2241− // depends on the "Send on Enter" setting, noted inline.
2242− AlertDialog(
2243− onDismissRequest = { showShortcutsDialog = false },
2244− title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
2245− text = {
2246− Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
2247− ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
2248− ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
2249− ShortcutRow("Esc", stringResource(R.string.shortcut_close))
2250− ShortcutRow("Enter", stringResource(R.string.shortcut_send))
2251− ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
2252− }
2253− },
2254− confirmButton = {
2255− TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
2256− },
2257− )
2258− }
2259−}
2260−
2261−@Composable
2262−private fun ShortcutRow(keys: String, desc: String) {
2263− Row(verticalAlignment = Alignment.CenterVertically) {
2264− Text(
2265− keys,
2266− style = MaterialTheme.typography.labelLarge,
2267− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2268− modifier = Modifier.widthIn(min = 160.dp),
2269− )
2270− Text(
2271− desc,
2272− style = MaterialTheme.typography.bodyMedium,
2273− color = MaterialTheme.colorScheme.onSurfaceVariant,
2274− )
2275− }
2276−}
2277−
2278−/**
2279− * Slash-command autocomplete: when the draft starts with "/", show a filtered dropdown
2280− * of matching commands above the composer. Tapping a command fires it immediately.
2281− * Extracted from [ChatInputBar] to keep that function's cyclomatic complexity under
2282− * the detekt threshold. Suppressed while running or when the command catalog is empty.
2283− */
2284−@Composable
2285−private fun SlashCommandAutocomplete(
2286− value: String,
2287− commands: List<Command>,
2288− running: Boolean,
2289− onRunCommand: (Command) -> Unit,
2290−) {
2291− if (running || commands.isEmpty()) return
2292− val slashQuery = remember(value) {
2293− if (value.startsWith("/")) value.removePrefix("/") else null
2294− } ?: return
2295− val matchingCommands = remember(slashQuery, commands) {
2296− commands.filter { cmd ->
2297− slashQuery.isEmpty() ||
2298− cmd.name.contains(slashQuery, ignoreCase = true) ||
2299− (cmd.description?.contains(slashQuery, ignoreCase = true) == true)
2300− }.take(8)
2301− }
2302− if (matchingCommands.isNotEmpty()) {
2303− CommandAutocompleteDropdown(
2304− commands = matchingCommands,
2305− onPick = onRunCommand,
2306− )
2307− }
2308−}
2309−
2310−/**
2311− * Slash-command autocomplete dropdown shown above the composer when the draft starts
2312− * with "/". Renders up to 8 matching commands as a scrollable, elevated surface so the
2313− * user can discover and invoke commands by typing the conventional "/" prefix instead
2314− * of opening the overflow menu / command palette. Tapping a row fires the command.
2315− */
2316−@Composable
2317−private fun CommandAutocompleteDropdown(
2318− commands: List<Command>,
2319− onPick: (Command) -> Unit,
2320−) {
2321− val haptics = LocalHapticFeedback.current
2322− Surface(
2323− color = MaterialTheme.colorScheme.surface,
2324− tonalElevation = 3.dp,
2325− shape = MaterialTheme.shapes.medium,
2326− modifier = Modifier
2327− .fillMaxWidth()
2328− .padding(horizontal = 8.dp, vertical = 4.dp)
2329− .heightIn(max = 240.dp),
2330− ) {
2331− LazyColumn {
2332− items(
2333− count = commands.size,
2334− key = { commands[it].name },
2335− ) { i ->
2336− val cmd = commands[i]
2337− Row(
2338− modifier = Modifier
2339− .fillMaxWidth()
2340− .clickable(role = Role.Button) {
2341− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2342− onPick(cmd)
2343− }
2344− .padding(horizontal = 16.dp, vertical = 10.dp),
2345− verticalAlignment = Alignment.CenterVertically,
2346− horizontalArrangement = Arrangement.spacedBy(12.dp),
2347− ) {
2348− Text(
2349− "/${cmd.name}",
2350− style = MaterialTheme.typography.labelLarge,
2351− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
2352− color = MaterialTheme.colorScheme.primary,
2353− )
2354− cmd.description?.takeIf { it.isNotBlank() }?.let { desc ->
2355− Text(
2356− desc,
2357− style = MaterialTheme.typography.bodySmall,
2358− color = MaterialTheme.colorScheme.onSurfaceVariant,
2359− maxLines = 1,
2360− overflow = TextOverflow.Ellipsis,
2361− )
2362− }
2363− }
2364− }
2365− }
2366− }
2367−}
2368−
2369−@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
2370−@Suppress("CyclomaticComplexMethod")
2371−@Composable
2372−private fun ChatInputBar(
2373− value: String,
2374− onValueChange: (String) -> Unit,
2375− running: Boolean,
2376− aborting: Boolean,
2377− enabled: Boolean,
2378− sendOnEnter: Boolean,
2379− onSend: () -> Unit,
2380− onAbort: () -> Unit,
2381− queuedFollowUp: String?,
2382− onQueueFollowUp: (String) -> Unit,
2383− onCancelQueue: () -> Unit,
2384− focusRequester: androidx.compose.ui.focus.FocusRequester,
2385− attachments: List<PendingAttachment>,
2386− staging: Boolean,
2387− stagingFileCount: Int,
2388− onRemoveAttachment: (String) -> Unit,
2389− onPickPhoto: () -> Unit,
2390− onPickFile: () -> Unit,
2391− onCamera: () -> Unit,
2392− onVoice: () -> Unit,
2393− onPasteImage: (List<Uri>) -> Unit,
2394− commands: List<Command> = emptyList(),
2395− onRunCommand: (Command) -> Unit = {},
2396−) {
2397− // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
2398− val hasContent = value.isNotBlank() || attachments.isNotEmpty()
2399− val context = LocalContext.current
2400− // Full-screen editor state: opened when the user wants more space for a long prompt.
2401− var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
2402− Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
2403− Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
2404− // A queued follow-up replaces the Stop button with a "queued" chip so the
2405− // user sees their message will be sent when the run finishes, and can cancel.
2406− if (queuedFollowUp != null) {
2407− val editLabel = stringResource(R.string.edit)
2408− Row(
2409− modifier = Modifier
2410− .fillMaxWidth()
2411− .padding(start = 12.dp, top = 6.dp, end = 4.dp),
2412− verticalAlignment = Alignment.CenterVertically,
2413− ) {
2414− // Tapping the chip reloads the queued text into the draft (and clears the
2415− // queue) so a typo spotted while queuing can be fixed without retyping.
2416− Column(
2417− modifier = Modifier
2418− .weight(1f)
2419− .clip(MaterialTheme.shapes.small)
2420− .clickable(role = Role.Button, onClickLabel = editLabel) {
2421− onValueChange(queuedFollowUp)
2422− onCancelQueue()
2423− }
2424− .padding(vertical = 4.dp),
2425− ) {
2426− Text(
2427− stringResource(R.string.queued),
2428− style = MaterialTheme.typography.labelSmall,
2429− color = MaterialTheme.colorScheme.onSurfaceVariant,
2430− )
2431− Text(
2432− queuedFollowUp,
2433− style = MaterialTheme.typography.bodySmall,
2434− color = MaterialTheme.colorScheme.onSurfaceVariant,
2435− maxLines = 1,
2436− overflow = TextOverflow.Ellipsis,
2437− )
2438− }
2439− TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
2440− Text(stringResource(R.string.cancel))
2441− }
2442− }
2443− }
2444− // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
2445− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2446− // Slash-command autocomplete: extracted to a helper to keep this function's
2447− // complexity under the detekt threshold. Returns no-op when the draft doesn't
2448− // start with "/" or the command catalog is empty.
2449− SlashCommandAutocomplete(
2450− value = value,
2451− commands = commands,
2452− running = running,
2453− onRunCommand = onRunCommand,
2454− )
2455− Row(
2456− modifier = Modifier.fillMaxWidth().padding(8.dp),
2457− verticalAlignment = Alignment.Bottom,
2458− ) {
2459− // Attach menu: photo picker, camera, or any file.
2460− var showAttachMenu by remember { mutableStateOf(false) }
2461− Box {
2462− IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
2463− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
2464− }
2465− DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
2466− DropdownMenuItem(
2467− leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
2468− text = { Text(stringResource(R.string.attach_photo)) },
2469− onClick = { showAttachMenu = false; onPickPhoto() },
2470− )
2471− DropdownMenuItem(
2472− leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
2473− text = { Text(stringResource(R.string.attach_camera)) },
2474− onClick = { showAttachMenu = false; onCamera() },
2475− )
2476− DropdownMenuItem(
2477− leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
2478− text = { Text(stringResource(R.string.attach_file)) },
2479− onClick = { showAttachMenu = false; onPickFile() },
2480− )
2481− }
2482− }
2483− // Voice dictation: appends recognized speech to the draft. Hidden (not just
2484− // disabled) when the device has no speech-recognition activity, so a user
2485− // without a voice app doesn't get a button that only fails noisily on tap.
2486− val voiceAvailable = remember {
2487− val pm = context.packageManager
2488− val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
2489− pm.queryIntentActivities(intent, 0).isNotEmpty()
2490− }
2491− if (voiceAvailable) {
2492− IconButton(onClick = onVoice, enabled = enabled) {
2493− Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
2494− }
2495− }
2496− // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
2497− IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
2498− Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
2499− }
2500− OutlinedTextField(
2501− value = value,
2502− onValueChange = { v ->
2503− // Cap input length so a huge paste can't stall the UI. Shares are
2504− // capped separately in MainActivity; this guards typed/pasted drafts.
2505− onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
2506− },
2507− modifier = Modifier
2508− .weight(1f)
2509− .focusRequester(focusRequester)
2510− .testTag("chat_input")
2511− // Intercept image content pasted/dropped into the field (keyboard paste
2512− // of a copied screenshot, drag from Files, etc.) and route it through the
2513− // same staging path as the attach menu. Non-image content (text) falls
2514− // through unchanged so normal typing/paste works.
2515− .contentReceiver(
2516− remember {
2517− androidx.compose.foundation.content.ReceiveContentListener { transferable ->
2518− val clipData = transferable.clipEntry?.clipData
2519− if (clipData != null) {
2520− val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
2521− val item = clipData.getItemAt(i)
2522− item.uri?.takeIf { uri ->
2523− item.text == null && uri.toString().let { s ->
2524− s.startsWith("content:") || s.startsWith("file:")
2525− }
2526− }
2527− }
2528− if (imageUris.isNotEmpty()) {
2529− onPasteImage(imageUris)
2530− }
2531− }
2532− transferable
2533− }
2534− },
2535− )
2536− .onPreviewKeyEvent { event ->
2537− if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2538− // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
2539− // With it off, Enter inserts a newline and Ctrl+Enter sends.
2540− val send = enterShouldSend(
2541− enabled = enabled,
2542− hasContent = hasContent,
2543− sendOnEnter = sendOnEnter,
2544− shift = event.isShiftPressed,
2545− ctrl = event.isCtrlPressed,
2546− )
2547− if (send) {
2548− if (running) onQueueFollowUp(value) else onSend()
2549− true
2550− } else {
2551− false
2552− }
2553− },
2554− // While a run is active a typed message only queues (sends after the run), so
2555− // the placeholder says so instead of the plain "Message…".
2556− placeholder = {
2557− Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
2558− },
2559− enabled = enabled,
2560− maxLines = 6,
2561− // Show a "N / max" countdown once the draft crosses a high fraction of
2562− // the cap, so the user knows a paste is about to be truncated instead
2563− // of being silently cut off. Hidden for normal short prompts to avoid
2564− // clutter under a typical one-line message.
2565− supportingText = {
2566− val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
2567− if (value.length >= threshold) {
2568− Text(
2569− stringResource(
2570− R.string.draft_chars_remaining,
2571− NetworkConfig.maxDraftLengthChars - value.length,
2572− NetworkConfig.maxDraftLengthChars,
2573− ),
2574− style = MaterialTheme.typography.labelSmall,
2575− color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
2576− else MaterialTheme.colorScheme.onSurfaceVariant,
2577− )
2578− } else if (value.lines().size > 6) {
2579− // The inline field caps at 6 lines; once the draft crosses that,
2580− // hint that a bigger full-screen editor exists (the Expand icon
2581− // above is easy to miss among the icon row). A tappable label
2582− // would be ideal, but a plain hint is enough to draw attention.
2583− Text(
2584− stringResource(R.string.open_full_editor_hint),
2585− style = MaterialTheme.typography.labelSmall,
2586− color = MaterialTheme.colorScheme.onSurfaceVariant,
2587− )
2588− }
2589− },
2590− keyboardOptions = KeyboardOptions(
2591− // When "Send on Enter" is on, expose ImeAction.Send so the soft
2592− // keyboard shows a send key — matching the setting's intent and
2593− // giving touch-only users a keyboard send affordance. When off,
2594− // ImeAction.Default keeps the enter/newline key for multi-line input.
2595− imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2596− capitalization = KeyboardCapitalization.Sentences,
2597− ),
2598− keyboardActions = KeyboardActions(
2599− onSend = {
2600− // Mirror the hardware-Enter handler: during a run the soft-keyboard
2601− // send key must queue the follow-up, not silently drop it. vm.send()
2602− // returns false while a run is active (beginRun() rejects), so calling
2603− // onSend() directly here would lose the typed message with no feedback
2604− // for touch-only users (no hardware Enter path available).
2605− if (enabled && hasContent) {
2606− if (running) onQueueFollowUp(value) else onSend()
2607− }
2608− },
2609− ),
2610− )
2611− ComposerTrailingButton(
2612− running = running,
2613− aborting = aborting,
2614− canSend = enabled && hasContent,
2615− canQueue = enabled && value.isNotBlank(),
2616− onSend = onSend,
2617− onAbort = onAbort,
2618− onQueue = { onQueueFollowUp(value) },
2619− )
2620− }
2621− }
2622− if (showFullScreenEditor) {
2623− FullScreenEditor(
2624− value = value,
2625− onValueChange = onValueChange,
2626− onDismiss = { showFullScreenEditor = false },
2627− onSend = {
2628− showFullScreenEditor = false
2629− // Mirror the inline composer's behavior: while a run is active a send
2630− // can't go immediately, so queue the follow-up instead of silently
2631− // dropping it (vm.send() returns false during a run, which previously
2632− // lost the typed message with no feedback).
2633− if (running) onQueueFollowUp(value) else onSend()
2634− },
2635− canSend = enabled && hasContent,
2636− running = running,
2637− sendOnEnter = sendOnEnter,
2638− attachments = attachments,
2639− onRemoveAttachment = onRemoveAttachment,
2640− staging = staging,
2641− stagingFileCount = stagingFileCount,
2642− )
2643− }
2644− }
2645−}
2646−
2647−/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
2648− * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
2649−@OptIn(ExperimentalMaterial3Api::class)
2650−@Composable
2651−private fun FullScreenEditor(
2652− value: String,
2653− onValueChange: (String) -> Unit,
2654− onDismiss: () -> Unit,
2655− onSend: () -> Unit,
2656− canSend: Boolean,
2657− running: Boolean = false,
2658− sendOnEnter: Boolean = false,
2659− attachments: List<PendingAttachment> = emptyList(),
2660− onRemoveAttachment: (String) -> Unit = {},
2661− staging: Boolean = false,
2662− stagingFileCount: Int = 0,
2663−) {
2664− androidx.compose.ui.window.Dialog(
2665− onDismissRequest = onDismiss,
2666− properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2667− ) {
2668− // Cap the editor width on large screens (tablets) so the text field doesn't stretch
2669− // edge-to-edge — matching the chat list's readability rationale. BoxWithConstraints
2670− // gives the available width so we can center a capped-width column.
2671− androidx.compose.foundation.layout.BoxWithConstraints(
2672− modifier = Modifier.fillMaxSize(),
2673− contentAlignment = Alignment.Center,
2674− ) {
2675− val hPad = if (maxWidth > NetworkConfig.twoPaneWidthThresholdDp.dp)
2676− ((maxWidth - NetworkConfig.composerDialogMaxWidthDp.dp) / 2).coerceAtLeast(0.dp)
2677− else 0.dp
2678− Scaffold(
2679− modifier = Modifier.padding(horizontal = hPad),
2680− topBar = {
2681− androidx.compose.material3.TopAppBar(
2682− title = { Text(stringResource(R.string.compose_editor_title)) },
2683− navigationIcon = {
2684− androidx.compose.material3.TextButton(onClick = onDismiss) {
2685− Text(stringResource(R.string.collapse_editor))
2686− }
2687− },
2688− )
2689− },
2690− bottomBar = {
2691− Surface(tonalElevation = 3.dp) {
2692− Row(
2693− modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2694− horizontalArrangement = Arrangement.End,
2695− ) {
2696− androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2697− Text(stringResource(if (running) R.string.queue else R.string.send))
2698− }
2699− }
2700− }
2701− },
2702− ) { padding ->
2703− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2704− // Show staged attachments in the full-screen editor too, so a user who staged
2705− // images then expanded for a long prompt can still see and remove them without
2706− // collapsing first (mirrors the inline composer's AttachmentStrip).
2707− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging, stagingFileCount = stagingFileCount)
2708− OutlinedTextField(
2709− value = value,
2710− onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2711− modifier = Modifier
2712− .fillMaxSize()
2713− .weight(1f)
2714− // Honor the "Send on Enter" setting here too, so the full-screen
2715− // editor matches the inline composer: Enter sends (Shift+Enter
2716− // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2717− // when off. Previously this hard-coded ImeAction.Default and had no
2718− // key handler, so a hardware-keyboard user got a different behaviour
2719− // from the inline composer depending on which they happened to open.
2720− .onPreviewKeyEvent { event ->
2721− if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2722− val send = enterShouldSend(
2723− enabled = canSend,
2724− hasContent = canSend,
2725− sendOnEnter = sendOnEnter,
2726− shift = event.isShiftPressed,
2727− ctrl = event.isCtrlPressed,
2728− )
2729− if (send) { onSend(); true } else false
2730− },
2731− placeholder = { Text(stringResource(R.string.message_placeholder)) },
2732− maxLines = Int.MAX_VALUE,
2733− keyboardOptions = KeyboardOptions(
2734− imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2735− capitalization = KeyboardCapitalization.Sentences,
2736− ),
2737− keyboardActions = KeyboardActions(
2738− onSend = { if (canSend) onSend() },
2739− ),
2740− )
2741− }
2742− }
2743− }
2744− }
2745−}
2746−
2747−/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2748− * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2749−private fun enterShouldSend(
2750− enabled: Boolean,
2751− hasContent: Boolean,
2752− sendOnEnter: Boolean,
2753− shift: Boolean,
2754− ctrl: Boolean,
2755−): Boolean = when {
2756− !enabled || !hasContent -> false
2757− sendOnEnter -> !shift
2758− else -> ctrl
2759−}
2760−
2761−/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2762− * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2763− * [ChatInputBar] stays under the complexity threshold. */
2764−@Composable
2765−private fun ComposerTrailingButton(
2766− running: Boolean,
2767− aborting: Boolean,
2768− canSend: Boolean,
2769− canQueue: Boolean,
2770− onSend: () -> Unit,
2771− onAbort: () -> Unit,
2772− onQueue: () -> Unit,
2773−) {
2774− if (running) {
2775− Row(verticalAlignment = Alignment.CenterVertically) {
2776− // While a run is active the trailing action is Stop, so the only send affordance is
2777− // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2778− // That leaves a touch-only user with the setting off no way to queue a follow-up
2779− // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2780− // there's typed TEXT so queuing is always reachable, without changing the user's
2781− // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2782− // canSend, which is also true for an attachment-only composer — because queuing "" just
2783− // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2784− // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2785− if (canQueue) {
2786− IconButton(
2787− onClick = onQueue,
2788− modifier = Modifier.testTag("queue_button"),
2789− ) {
2790− // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2791− // icon (which can be read as "snooze" or "later"), and isn't confused with
2792− // the Send action sitting right beside the Stop button.
2793− Icon(
2794− Icons.AutoMirrored.Filled.PlaylistAdd,
2795− contentDescription = stringResource(R.string.queue_followup),
2796− )
2797− }
2798− }
2799− // Show a spinner while the abort REST call is in flight so the user sees the stop
2800− // was sent, and disable to prevent a double-tap.
2801− IconButton(
2802− onClick = onAbort,
2803− enabled = !aborting,
2804− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2805− ) {
2806− if (aborting) {
2807− val stopLabel = stringResource(R.string.stop)
2808− CircularProgressIndicator(
2809− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2810− strokeWidth = 2.dp,
2811− )
2812− } else {
2813− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2814− }
2815− }
2816− }
2817− } else {
2818− IconButton(
2819− onClick = onSend,
2820− enabled = canSend,
2821− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2822− ) {
2823− // Tint the send icon with primary when there's content to send so the commit action
2824− // draws the eye; muted otherwise. A plain enabled/disabled flip is easy to miss.
2825− Icon(
2826− Icons.AutoMirrored.Filled.Send,
2827− contentDescription = stringResource(R.string.send),
2828− tint = if (canSend) MaterialTheme.colorScheme.primary
2829− else MaterialTheme.colorScheme.onSurfaceVariant,
2830− )
2831− }
2832− }
2833−}
2834−
2835−@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2836−@Composable
2837−private fun EmptyConversation(
2838− onSuggestion: (String) -> Unit,
2839− modifier: Modifier = Modifier,
2840−) {
2841− // A few example prompts so a first-time user has a one-tap path to their first
2842− // message instead of staring at a blank input. Tapping a chip fills the draft
2843− // (without sending) so the user can edit it before sending.
2844− val suggestions = remember {
2845− listOf(
2846− R.string.empty_chat_suggest_1,
2847− R.string.empty_chat_suggest_2,
2848− R.string.empty_chat_suggest_3,
2849− )
2850− }
2851− Column(
2852− modifier = modifier.padding(32.dp),
2853− horizontalAlignment = Alignment.CenterHorizontally,
2854− ) {
2855− Icon(
2856− Icons.Filled.AutoAwesome,
2857− contentDescription = null,
2858− modifier = Modifier.size(48.dp),
2859− tint = MaterialTheme.colorScheme.onSurfaceVariant,
2860− )
2861− Spacer(Modifier.size(16.dp))
2862− Text(
2863− stringResource(R.string.empty_chat_title),
2864− style = MaterialTheme.typography.titleMedium,
2865− )
2866− Spacer(Modifier.size(8.dp))
2867− Text(
2868− stringResource(R.string.empty_chat_hint),
2869− style = MaterialTheme.typography.bodyMedium,
2870− color = MaterialTheme.colorScheme.onSurfaceVariant,
2871− )
2872− Spacer(Modifier.size(20.dp))
2873− // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2874− // so a longer localized prompt doesn't push the chips off-screen on a phone.
2875− FlowRow(
2876− modifier = Modifier.fillMaxWidth(),
2877− horizontalArrangement = Arrangement.spacedBy(8.dp),
2878− verticalArrangement = Arrangement.spacedBy(8.dp),
2879− ) {
2880− suggestions.forEach { resId ->
2881− val text = stringResource(resId)
2882− androidx.compose.material3.AssistChip(
2883− onClick = { onSuggestion(text) },
2884− label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2885− )
2886− }
2887− }
2888− }
2889−}
2890−
2891−/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2892− * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2893− * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2894−@Composable
2895−private fun RevertBanner(diff: String?, isEditing: Boolean, onUndo: () -> Unit) {
2896− val haptics = LocalHapticFeedback.current
2897− var showDiff by rememberSaveable { mutableStateOf(false) }
2898− val expandMotion = rememberVisibilityTransitions()
2899− Surface(
2900− color = MaterialTheme.colorScheme.tertiaryContainer,
2901− tonalElevation = 2.dp,
2902− modifier = Modifier.fillMaxWidth(),
2903− ) {
2904− Column {
2905− Row(
2906− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2907− verticalAlignment = Alignment.CenterVertically,
2908− horizontalArrangement = Arrangement.SpaceBetween,
2909− ) {
2910− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2911− Icon(
2912− if (isEditing) Icons.Filled.Edit else Icons.Filled.Restore,
2913− contentDescription = null,
2914− tint = MaterialTheme.colorScheme.onTertiaryContainer,
2915− modifier = Modifier.size(18.dp),
2916− )
2917− Text(
2918− stringResource(if (isEditing) R.string.editing_banner else R.string.reverted_banner),
2919− style = MaterialTheme.typography.bodySmall,
2920− color = MaterialTheme.colorScheme.onTertiaryContainer,
2921− modifier = Modifier.padding(start = 8.dp),
2922− )
2923− // "Show what changed" toggle, shown only when the server provided a diff.
2924− // Collapsible so the banner stays compact by default; a user curious about
2925− // what the revert rolled back can expand it inline without leaving the chat.
2926− if (!diff.isNullOrBlank()) {
2927− TextButton(
2928− onClick = {
2929− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2930− showDiff = !showDiff
2931− },
2932− contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2933− ) {
2934− Icon(
2935− if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2936− contentDescription = null,
2937− modifier = Modifier.size(16.dp),
2938− )
2939− Text(
2940− stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2941− style = MaterialTheme.typography.labelSmall,
2942− modifier = Modifier.padding(start = 4.dp),
2943− )
2944− }
2945− }
2946− }
2947− TextButton(onClick = {
2948− // Haptic on undo to match the other destructive/important confirmations: a
2949− // revert restores potentially hundreds of hidden messages.
2950− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2951− onUndo()
2952− }) { Text(stringResource(R.string.undo)) }
2953− }
2954− // The diff render lives inside the banner's Surface so it inherits the tertiary
2955− // tonal container and reads as part of the banner, not a floating panel.
2956− if (!diff.isNullOrBlank()) {
2957− AnimatedVisibility(
2958− visible = showDiff,
2959− enter = expandMotion.enter,
2960− exit = expandMotion.exit,
2961− ) {
2962− Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2963− Text(
2964− stringResource(R.string.revert_diff_header),
2965− style = MaterialTheme.typography.labelSmall,
2966− color = MaterialTheme.colorScheme.onTertiaryContainer,
2967− modifier = Modifier.padding(bottom = 4.dp),
2968− )
2969− DiffView(diff, saveKey = "revertDiff")
2970− }
2971− }
2972− }
2973− }
2974− }
2975−}
2976−
2977−/** A dismissible preview card shown above the composer when the user quote-replied to a
2978− * message. Displays the quoted text (truncated) with a Cancel affordance so the user can
2979− * abort the quote without digging into the draft to strip the `>`-prefixed lines. Mirrors
2980− * the Discord/Slack quote-preview pattern. */
2981−@Composable
2982−private fun QuoteReplyBanner(quotedText: String, onCancel: () -> Unit) {
2983− val haptics = LocalHapticFeedback.current
2984− Surface(
2985− color = MaterialTheme.colorScheme.secondaryContainer,
2986− tonalElevation = 2.dp,
2987− modifier = Modifier.fillMaxWidth(),
2988− ) {
2989− Row(
2990− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
2991− verticalAlignment = Alignment.Top,
2992− ) {
2993− Icon(
2994− Icons.Filled.FormatQuote,
2995− contentDescription = null,
2996− tint = MaterialTheme.colorScheme.onSecondaryContainer,
2997− modifier = Modifier.size(18.dp).padding(top = 2.dp),
2998− )
2999− Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
3000− Text(
3001− stringResource(R.string.quote_reply_banner),
3002− style = MaterialTheme.typography.labelSmall,
3003− color = MaterialTheme.colorScheme.onSecondaryContainer,
3004− )
3005− Text(
3006− quotedText,
3007− style = MaterialTheme.typography.bodySmall,
3008− color = MaterialTheme.colorScheme.onSecondaryContainer,
3009− maxLines = 2,
3010− overflow = TextOverflow.Ellipsis,
3011− )
3012− }
3013− IconButton(onClick = {
3014− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
3015− onCancel()
3016− }) {
3017− Icon(
3018− Icons.Filled.Close,
3019− contentDescription = stringResource(R.string.cancel_quote_reply),
3020− tint = MaterialTheme.colorScheme.onSecondaryContainer,
3021− modifier = Modifier.size(18.dp),
3022− )
3023− }
3024− }
3025− }
3026−}
3027−
3028−/** A banner shown while messages composed offline are queued in the outbox, with a
3029− * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
3030−@Composable
3031−private fun OutboxBanner(
3032− count: Int,
3033− sending: Boolean,
3034− onFlush: () -> Unit,
3035− onDiscard: () -> Unit,
3036−) {
3037− var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
3038− Surface(
3039− color = MaterialTheme.colorScheme.secondaryContainer,
3040− tonalElevation = 2.dp,
3041− modifier = Modifier.fillMaxWidth(),
3042− ) {
3043− Row(
3044− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
3045− verticalAlignment = Alignment.CenterVertically,
3046− horizontalArrangement = Arrangement.SpaceBetween,
3047− ) {
3048− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
3049− if (sending) {
3050− CircularProgressIndicator(
3051− Modifier.size(16.dp),
3052− strokeWidth = 2.dp,
3053− color = MaterialTheme.colorScheme.onSecondaryContainer,
3054− )
3055− Spacer(Modifier.size(8.dp))
3056− }
3057− Text(
3058− if (sending) {
3059− stringResource(R.string.outbox_sending)
3060− } else {
3061− val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
3062− stringResource(res, count)
3063− },
3064− style = MaterialTheme.typography.bodySmall,
3065− color = MaterialTheme.colorScheme.onSecondaryContainer,
3066− )
3067− }
3068− if (!sending) {
3069− val haptics = LocalHapticFeedback.current
3070− TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
3071− TextButton(onClick = {
3072− // Confirm before discarding all queued messages (irreversible), matching
3073− // the confirmation pattern used by deleteSession and other destructive
3074− // actions. Previously a single tap dropped every queued message.
3075− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
3076− showDiscardConfirm = true
3077− }) {
3078− Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
3079− }
3080− }
3081− }
3082− }
3083− if (showDiscardConfirm) {
3084− androidx.compose.material3.AlertDialog(
3085− onDismissRequest = { showDiscardConfirm = false },
3086− title = { Text(stringResource(R.string.discard_queued_title)) },
3087− text = { Text(stringResource(R.string.discard_queued_text, count)) },
3088− confirmButton = {
3089− androidx.compose.material3.TextButton(onClick = {
3090− showDiscardConfirm = false
3091− onDiscard()
3092− }) { Text(stringResource(R.string.discard)) }
3093− },
3094− dismissButton = {
3095− androidx.compose.material3.TextButton(onClick = { showDiscardConfirm = false }) {
3096− Text(stringResource(R.string.cancel))
3097− }
3098− },
3099− )
3100− }
3101−}
3102−
3103−/** Prompt for a one-off shell command to run in the session's worktree. */
3104−@Composable
3105−private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
3106− var command by rememberSaveable { mutableStateOf("") }
3107− AlertDialog(
3108− onDismissRequest = onDismiss,
3109− title = { Text(stringResource(R.string.run_shell_command)) },
3110− text = {
3111− OutlinedTextField(
3112− value = command,
3113− onValueChange = { command = it },
3114− modifier = Modifier.fillMaxWidth(),
3115− placeholder = { Text(stringResource(R.string.shell_command_hint)) },
3116− label = { Text(stringResource(R.string.shell_command_label)) },
3117− singleLine = true,
3118− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3119− keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
3120− )
3121− },
3122− confirmButton = {
3123− TextButton(
3124− onClick = { if (command.isNotBlank()) onRun(command.trim()) },
3125− enabled = command.isNotBlank(),
3126− ) { Text(stringResource(R.string.run)) }
3127− },
3128− dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
3129− )
3130−}
3131−
3132−/**
3133− * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
3134− * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
3135− * equality is structural so `snapshotFlow` emits only when something relevant changes.
3136− */
3137−private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
3138−
3139−/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
3140− * Covers text, reasoning, and tool output so a pinned view keeps following non-text
3141− * streaming, not just plain assistant text. O(1). */
3142−private fun streamingContentLength(part: Part?): Int = when (part) {
3143− is TextPart -> part.text.length
3144− is ReasoningPart -> part.text.length
3145− is ToolPart -> when (val st = part.state) {
3146− is ToolCompleted -> st.output?.length ?: 0
3147− is ToolError -> st.error?.length ?: 0
3148− is ToolRunning -> st.title?.length ?: 0
3149− else -> 0
3150− }
3151− else -> 0
3152−}
3153−
3154−/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
3155− * dialog but lives in the chat screen so the user can rename without backing out to
3156− * the list. Pre-filled with the current title; the cap matches the list dialog. */
3157−@Composable
3158−private fun RenameSessionChatDialog(
3159− initialTitle: String,
3160− onDismiss: () -> Unit,
3161− onConfirm: (String) -> Unit,
3162−) {
3163− // Seed the field once (no key input) so a later external title change — e.g. opencode
3164− // auto-generating a session title after the first prompt, arriving via SessionUpdated —
3165− // doesn't re-initialize the box and discard the user's in-progress edit.
3166− var title by rememberSaveable { mutableStateOf(initialTitle) }
3167− AlertDialog(
3168− onDismissRequest = onDismiss,
3169− title = { Text(stringResource(R.string.rename_session_chat)) },
3170− text = {
3171− OutlinedTextField(
3172− value = title,
3173− onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
3174− modifier = Modifier.fillMaxWidth(),
3175− placeholder = { Text(stringResource(R.string.session_title_hint)) },
3176− label = { Text(stringResource(R.string.rename_session_chat)) },
3177− singleLine = true,
3178− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
3179− keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
3180− )
3181− },
3182− confirmButton = {
3183− TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
3184− Text(stringResource(R.string.save))
3185− }
3186− },
3187− dismissButton = {
3188− TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
3189− },
3190− )
3191−}
3192−
3193−/**
3194− * A single item in the chat message list — either a [Message] bubble or a day
3195− * [Separator] inserted before the first message of a new day. The separator shifts
3196− * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
3197− * the [List] of these items (not `messages.size`/`lastIndex`).
3198− */
3199−private sealed interface MessageListItem {
3200− val key: Any
3201− val contentType: Any
3202−
3203− data class Message(
3204− val message: MessageWithParts,
3205− override val key: Any,
3206− // Whether this is the first message in a consecutive run of the same speaker (role).
3207− // Used to suppress the assistant avatar/header on follow-up messages in the same run,
3208− // reducing visual noise in tool-heavy conversations where many assistant messages
3209− // appear back-to-back. A separator or a role change resets the flag.
3210− val isFirstOfSpeaker: Boolean,
3211− ) : MessageListItem {
3212− override val contentType: Any get() = message.info::class
3213− }
3214−
3215− data class Separator(val label: String, val ordinal: Int) : MessageListItem {
3216− // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
3217− // can share a label (e.g. an untimestamped message — empty label — between two
3218− // same-day messages, or two untimestamped groups), and a label-only key would
3219− // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
3220− // stable for a given message ordering so slots aren't needlessly recreated.
3221− override val key: Any get() = "sep_${ordinal}_$label"
3222− // Separators share a contentType so the LazyColumn can recycle their slots.
3223− override val contentType: Any get() = "separator"
3224− }
3225−}
3226−
3227−/**
3228− * Build the interleaved list of [MessageListItem]s for the message list, inserting a
3229− * [MessageListItem.Separator] before the first message of each new calendar day. Uses
3230− * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
3231− * Messages with no timestamp are grouped under an empty-label separator only if they
3232− * start the list, so a server that omits timestamps doesn't suppress the first divider.
3233− *
3234− * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
3235− * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
3236− * labels; older days fall back to a locale-stable medium-date format.
3237− */
3238−private fun buildMessageListItems(
3239− messages: List<MessageWithParts>,
3240− todayLabel: String,
3241− yesterdayLabel: String,
3242−): List<MessageListItem> {
3243− if (messages.isEmpty()) return emptyList()
3244− val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
3245− val dateFmt = mediumDateFormat()
3246− val result = ArrayList<MessageListItem>(messages.size + 4)
3247− val seenKeys = HashSet<String>(messages.size * 2)
3248− var lastDayKey: String? = null
3249− var sepOrdinal = 0
3250− var emptyIdOrdinal = 0
3251− var first = true
3252− // Track the previous message's role to detect consecutive same-speaker runs, so the
3253− // avatar/header can be suppressed on follow-up messages in the same run. A null means
3254− // "no previous message or a separator just interrupted the run", both of which make the
3255− // next message the first of its group.
3256− var prevRoleClass: kotlin.reflect.KClass<out soy.iko.opencode.data.model.MessageInfo>? = null
3257− for (message in messages) {
3258− val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
3259− val dayKey = ts?.let { dayKey(it) } ?: ""
3260− // Emit a separator before the very first message (so a leading un-timestamped message
3261− // still gets the first divider, empty label and all), and thereafter only when crossing
3262− // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
3263− // neither emits its own separator nor resets the day — otherwise a following same-day
3264− // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
3265− if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
3266− result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
3267− // A date separator visually breaks a speaker run, so the next message is treated
3268− // as the first of its group (even if the same role continues across the day break).
3269− prevRoleClass = null
3270− }
3271− if (dayKey.isNotEmpty()) lastDayKey = dayKey
3272− first = false
3273− // Most messages have a unique non-empty id. An unrecognized-role message can carry an
3274− // empty id — and the reducer intentionally keeps several such messages as distinct
3275− // holders — so give each a stable synthetic key and defensively de-collide any remaining
3276− // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
3277− var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
3278− if (!seenKeys.add(key)) {
3279− var suffix = 1
3280− while (!seenKeys.add("$key#$suffix")) suffix++
3281− key = "$key#$suffix"
3282− }
3283− val roleClass = message.info::class
3284− val isFirstOfSpeaker = prevRoleClass == null || roleClass != prevRoleClass
3285− result.add(MessageListItem.Message(message, key, isFirstOfSpeaker))
3286− prevRoleClass = roleClass
3287− }
3288− return result
3289−}
3290−
3291−/**
3292− * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
3293− *
3294− * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
3295− * whole `messages` list, which must stay so the live message's fresh parts render), so a
3296− * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
3297− * every message on every token. Message timestamps are immutable once assigned, so we
3298− * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
3299− * a cheap hash lookup. Bounded; only touched on the main thread during composition.
3300− *
3301− * Backed by a [ConcurrentHashMap] (not an access-ordered LinkedHashMap): the previous
3302− * synchronized access-ordered LinkedHashMap reordered its linked list node on every
3303− * `getOrPut` hit, which is real work multiplied by N messages × ~20 snapshots/sec during
3304− * streaming. A ConcurrentHashMap has no access-order bookkeeping and is lock-free for
3305− * reads, so the common case (a hit) is a single volatile read + hash probe.
3306− * [removeEldestEntry] is not available on ConcurrentHashMap, so the bound is enforced via
3307− * a size check in [dayKey]: once the cap ([DAY_KEY_CACHE_MAX]) is reached the cache is
3308− * cleared; in practice the cache size is bounded by the number of distinct message
3309− * timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]) and the cap is a
3310− * safety valve for the process-lifetime cache across many sessions.
3311− */
3312−private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
3313−
3314−/** Upper bound on [dayKeyCache] entries. Message timestamps are immutable once assigned, so
3315− * the natural bound is the number of distinct timestamps in a session (≤
3316− * [NetworkConfig.maxInMemoryMessages]); this cap is a safety valve for the process-lifetime
3317− * cache across many sessions, keeping a runaway leak from accumulating unbounded memory. */
3318−private const val DAY_KEY_CACHE_MAX = 1024
3319−
3320−private fun dayKey(epochMillis: Long): String {
3321− val cached = dayKeyCache[epochMillis]
3322− if (cached != null) return cached
3323− val value = java.time.Instant.ofEpochMilli(epochMillis)
3324− .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3325− // Guard the size under the cache's concurrency: only the first thread to see the cap
3326− // exceeded evicts (draining is O(N) but rare and idempotent), so concurrent inserts
3327− // past the cap don't each pay the scan. computeIfAbsent would hold the segment lock
3328− // during the (cheap) date computation but can't express the size guard, so use put
3329− // after the fact; a duplicate put of the same key/value is harmless.
3330− if (dayKeyCache.size >= DAY_KEY_CACHE_MAX) dayKeyCache.clear()
3331− dayKeyCache[epochMillis] = value
3332− return value
3333−}
3334−
3335−// The medium-date formatter is an ICU locale lookup; cache one instance instead of
3336−// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
3337−// locale changes at runtime. Main-thread-only, so no synchronization is needed.
3338−private var cachedDateFmt: java.text.DateFormat? = null
3339−private var cachedDateFmtLocale: java.util.Locale? = null
3340−
3341−private fun mediumDateFormat(): java.text.DateFormat {
3342− val locale = java.util.Locale.getDefault()
3343− val existing = cachedDateFmt
3344− if (existing != null && cachedDateFmtLocale == locale) return existing
3345− return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
3346− cachedDateFmt = it
3347− cachedDateFmtLocale = locale
3348− }
3349−}
3350−
3351−/**
3352− * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
3353− * recent days, otherwise a locale-stable medium-date format. Returns an empty string
3354− * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
3355− * messages.
3356− */
3357−private fun dayLabel(
3358− dayKey: String,
3359− ts: Long,
3360− today: java.time.LocalDate,
3361− todayLabel: String,
3362− yesterdayLabel: String,
3363− dateFmt: java.text.DateFormat,
3364−): String {
3365− if (dayKey.isEmpty()) return ""
3366− val epochDay = dayKey.toLongOrNull() ?: return ""
3367− val date = java.time.LocalDate.ofEpochDay(epochDay)
3368− return when {
3369− date == today -> todayLabel
3370− date == today.minusDays(1) -> yesterdayLabel
3371− else -> dateFmt.format(java.util.Date(ts))
3372− }
3373−}
3374−
3375−/**
3376− * A centered day divider in the message list. Renders nothing for an empty label
3377− * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
3378− */
3379−@Composable
3380−private fun DateSeparator(label: String) {
3381− if (label.isBlank()) return
3382− Row(
3383− modifier = Modifier.fillMaxWidth(),
3384− horizontalArrangement = Arrangement.Center,
3385− ) {
3386− Surface(
3387− color = MaterialTheme.colorScheme.surfaceVariant,
3388− shape = MaterialTheme.shapes.small,
3389− // Marked as a heading so TalkBack users can navigate day-by-day with the
3390− // heading-skip gesture — the canonical affordance for date dividers in a list.
3391− modifier = Modifier.semantics(mergeDescendants = true) { heading() },
3392− ) {
3393− Text(
3394− label,
3395− style = MaterialTheme.typography.labelSmall,
3396− color = MaterialTheme.colorScheme.onSurfaceVariant,
3397− modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
3398− )
3399− }
3400− }
3401−}
3402−
3403−/** Skeleton placeholder bubbles for the chat loading and run-started states. Alpha pulses
3404− * (gated by reduced-motion) to convey ongoing work; the column carries the a11y label so
3405− * TalkBack announces "Loading" instead of reading the skeleton bars. Rendered as a
3406− * [BoxScope] extension so it can align itself to the top center of its host box. */
3407−@Composable
3408−private fun androidx.compose.foundation.layout.BoxScope.ChatSkeleton(topPad: androidx.compose.ui.unit.Dp) {
3409− val loadingLabel = stringResource(R.string.loading)
3410− val reducedMotion = LocalReducedMotion.current
3411− val transition = rememberInfiniteTransition(label = "skeleton")
3412− val pulse by transition.animateFloat(
3413− initialValue = 0.35f,
3414− targetValue = 0.7f,
3415− animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
3416− label = "skeletonAlpha",
3417− )
3418− val skeletonAlpha = if (reducedMotion) 0.5f else pulse
3419− val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
3420− @Composable
3421− fun bar(widthFraction: Float) {
3422− Box(
3423− Modifier
3424− .fillMaxWidth(widthFraction)
3425− .height(14.dp)
3426− .clip(RoundedCornerShape(4.dp))
3427− .background(skeletonColor)
3428− .alpha(skeletonAlpha),
3429− )
3430− }
3431− Column(
3432− modifier = Modifier
3433− .align(Alignment.TopCenter)
3434− .padding(top = topPad)
3435− .padding(16.dp)
3436− .fillMaxWidth()
3437− .semantics { contentDescription = loadingLabel },
3438− verticalArrangement = Arrangement.spacedBy(14.dp),
3439− ) {
3440− repeat(4) { i ->
3441− val left = i % 2 == 0
3442− Row(
3443− modifier = Modifier.fillMaxWidth(),
3444− horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
3445− ) {
3446− Column(
3447− modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
3448− verticalArrangement = Arrangement.spacedBy(6.dp),
3449− ) {
3450− bar(1f)
3451− bar(0.8f)
3452− }
3453− }
3454− }
3455− }
3456−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktdeleted−2055
@@ -1,2055 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewModelScope
5−import soy.iko.opencode.data.model.Agent
6−import soy.iko.opencode.data.model.Command
7−import soy.iko.opencode.data.model.FilePromptPart
8−import soy.iko.opencode.data.model.MessageWithParts
9−import soy.iko.opencode.data.model.ModelOption
10−import soy.iko.opencode.data.model.UserMessage
11−import soy.iko.opencode.data.model.Permission
12−import soy.iko.opencode.data.model.PermissionReplied
13−import soy.iko.opencode.data.model.PermissionResponse
14−import soy.iko.opencode.data.model.PermissionUpdated
15−import soy.iko.opencode.data.model.SessionDeleted
16−import soy.iko.opencode.data.model.SessionUpdated
17−import soy.iko.opencode.data.model.TextPart
18−import soy.iko.opencode.data.model.TodoItem
19−import soy.iko.opencode.data.model.TodoPlanCache
20−import soy.iko.opencode.data.model.currentTodoPlan
21−import soy.iko.opencode.data.model.defaultOption
22−import soy.iko.opencode.data.model.toOptions
23−import soy.iko.opencode.data.network.EventStreamClient
24−import soy.iko.opencode.data.network.NetworkConfig
25−import soy.iko.opencode.data.repo.OutboxMessage
26−import soy.iko.opencode.data.repo.SessionRepository
27−import soy.iko.opencode.di.AppContainer
28−import soy.iko.opencode.R
29−import soy.iko.opencode.util.runCatchingCancellable
30−import soy.iko.opencode.util.safeExceptionSummary
31−import android.util.Log
32−import kotlinx.coroutines.ExperimentalCoroutinesApi
33−import kotlinx.coroutines.channels.BufferOverflow
34−import kotlinx.coroutines.channels.Channel
35−import kotlinx.coroutines.flow.Flow
36−import kotlinx.coroutines.flow.MutableSharedFlow
37−import kotlinx.coroutines.flow.MutableStateFlow
38−import kotlinx.coroutines.flow.SharedFlow
39−import kotlinx.coroutines.flow.SharingStarted
40−import kotlinx.coroutines.flow.StateFlow
41−import kotlinx.coroutines.flow.asSharedFlow
42−import kotlinx.coroutines.flow.asStateFlow
43−import kotlinx.coroutines.flow.receiveAsFlow
44−import kotlinx.coroutines.CancellationException
45−import kotlinx.coroutines.flow.collectLatest
46−import kotlinx.coroutines.flow.combine
47−import kotlinx.coroutines.flow.debounce
48−import kotlinx.coroutines.flow.distinctUntilChanged
49−import kotlinx.coroutines.flow.drop
50−import kotlinx.coroutines.flow.first
51−import kotlinx.coroutines.flow.filterIsInstance
52−import kotlinx.coroutines.flow.flatMapLatest
53−import kotlinx.coroutines.flow.flowOf
54−import kotlinx.coroutines.flow.flowOn
55−import kotlinx.coroutines.flow.map
56−import kotlinx.coroutines.flow.merge
57−import kotlinx.coroutines.flow.onEach
58−import kotlinx.coroutines.flow.retryWhen
59−import kotlinx.coroutines.flow.stateIn
60−import kotlinx.coroutines.flow.update
61−import kotlinx.coroutines.Dispatchers
62−import kotlinx.coroutines.Job
63−import kotlinx.coroutines.NonCancellable
64−import kotlinx.coroutines.delay
65−import kotlinx.coroutines.isActive
66−import kotlinx.coroutines.currentCoroutineContext
67−import kotlinx.coroutines.launch
68−import kotlinx.coroutines.withContext
69−
70−/**
71− * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
72− * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
73− * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
74− * inherit a Retry that silently re-submits the last prompt.
75− */
76−data class ChatError(val message: String, val retryable: Boolean = false)
77−
78−/**
79− * Progress of the current permission within a burst of requests. [total] is the max number
80− * simultaneously pending since the queue was last empty; [position] advances as the user clears
81− * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
82− */
83−@androidx.compose.runtime.Immutable
84−data class PermissionProgress(val position: Int, val total: Int)
85−
86−/**
87− * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
88− * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
89− * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
90− * the server.
91− */
92−@androidx.compose.runtime.Immutable
93−data class PendingAttachment(
94− val id: String,
95− val name: String,
96− val mime: String,
97− val previewModel: Any?,
98− val part: FilePromptPart,
99−) {
100− val isImage: Boolean get() = mime.startsWith("image/")
101−}
102−
103−/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
104−private fun PendingAttachment.toPersisted() =
105− soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
106−
107−/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
108− * preview model for images (the original content Uri didn't survive the process restart). */
109−private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
110− id = id,
111− name = name,
112− mime = mime,
113− previewModel = if (mime.startsWith("image/")) url else null,
114− part = FilePromptPart(mime = mime, url = url, filename = filename),
115−)
116−
117−// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
118−// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
119−// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
120−// suppressed for the same reason. A future split into sub-controllers is tracked separately.
121−@Suppress("LargeClass")
122−@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
123−class ChatViewModel(
124− private val container: AppContainer,
125− private val sessionId: String,
126−) : ViewModel() {
127−
128− private val connection get() = container.activeConnection.value
129−
130− val connected: Boolean get() = connection != null
131−
132− private val _loading = MutableStateFlow(true)
133− val loading: StateFlow<Boolean> = _loading.asStateFlow()
134−
135− /** Set when the messages flow failed to load (and the list is empty), so the UI
136− * can render a distinct error state instead of masquerading as an empty
137− * conversation. Cleared on a successful non-empty emission. */
138− private val _loadError = MutableStateFlow(false)
139− val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
140−
141− /** Set when a re-fetch fails mid-conversation (messages already visible). Distinct
142− * from [loadError] (which replaces the whole content with an error screen): this
143− * surfaces as a persistent inline banner above the message list so a persistent
144− * outage doesn't go silent after the one-shot snackbar. Cleared on the next
145− * successful emission. */
146− private val _loadErrorInline = MutableStateFlow(false)
147− val loadErrorInline: StateFlow<Boolean> = _loadErrorInline.asStateFlow()
148−
149− /** Tracks whether the messages flow has ever emitted a non-empty list, so
150− * [messages]' retryWhen can decide whether a re-fetch failure should set
151− * [loadError] (nothing shown yet → error screen) or just emit a snackbar
152− * (already showing messages → don't replace the conversation with an error). */
153− private var hasShownMessages = false
154−
155− /** True once we've surfaced a snackbar for the current message-load failure streak.
156− * Reset on any successful emission so a persistent failure doesn't spam a fresh
157− * snackbar every retry cycle (every few seconds). */
158− private var loadErrorSnackbarShown = false
159−
160− /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
161− * doesn't conflict with the messages flow's loading state. */
162− private val _reconnecting = MutableStateFlow(false)
163− val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
164−
165− /** When true, the debounced draft collector skips persisting an empty draft so
166− * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
167− * debounce timer firing before the send completes. */
168− private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
169−
170− /** The draft value this VM last persisted, so the drafts observer can tell its own
171− * (debounced, possibly stale) echo apart from a genuine external write. Without this
172− * a debounced persist of an older value could echo back and overwrite newer text the
173− * user has since typed. */
174− @Volatile private var lastPersistedDraft: String? = null
175−
176− /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
177− * collectLatest to cancel any in-flight fetch and start a fresh one, so a
178− * manual reload supersedes a stale observeCatalog fetch. */
179− private val _modelsReload = MutableStateFlow(0)
180− private val _agentsReload = MutableStateFlow(0)
181− private val _commandsReload = MutableStateFlow(0)
182−
183− /** An optimistically-injected user message shown immediately on send, before the server
184− * echoes the real message back via SSE. Removed once a matching UserMessage arrives. */
185− data class OptimisticEntry(val tempId: String, val text: String, val timestamp: Long, val failed: Boolean)
186−
187− private val _optimisticMessages = MutableStateFlow<List<OptimisticEntry>>(emptyList())
188−
189− /** SSE-driven message stream with retry/backoff and loading/error side effects. Combined
190− * with [_optimisticMessages] by [messages] so the user sees their outgoing prompt instantly. */
191− private val sseMessages: Flow<List<MessageWithParts>> =
192− container.activeConnection
193− .flatMapLatest { conn ->
194− conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
195− }
196− .onEach {
197− _loading.value = false
198− // A value flowed through: the stream recovered, so allow the next distinct
199− // failure to surface a fresh snackbar again.
200− loadErrorSnackbarShown = false
201− // Clear the error on ANY successful emission, including an empty list. An empty
202− // session that hit one transient error would otherwise stay latched on the
203− // "Failed to load / Retry" screen forever, because the later successful EMPTY
204− // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
205− // (an empty session has genuinely shown nothing yet).
206− _loadError.value = false
207− _loadErrorInline.value = false
208− if (it.isNotEmpty()) {
209− hasShownMessages = true
210− }
211− // Reconcile optimistic messages: drop any whose text now matches a real SSE
212− // UserMessage (the server echoed the prompt back). Matching by trimmed text,
213− // removing the oldest match first so duplicate texts are handled in order.
214− reconcileOptimistic(it)
215− }
216− .retryWhen { cause, attempt ->
217− _loading.value = false
218− // Only surface the persistent error state when there's nothing to
219− // show — if we already have messages, a transient re-fetch failure
220− // is better surfaced as a snackbar (via errorEvents) than by
221− // replacing the visible conversation with an error screen.
222− if (!hasShownMessages) _loadError.value = true
223− // Mid-conversation failure: surface a persistent inline banner so
224− // the user knows the stream is broken (the one-shot snackbar alone
225− // reads as "recovered" once it dismisses). Cleared on recovery.
226− if (hasShownMessages) _loadErrorInline.value = true
227− // Surface the snackbar only once per failure streak — retryWhen loops on a
228− // backoff, so emitting here unconditionally would spam a new snackbar every
229− // retry on a persistent load failure.
230− if (!loadErrorSnackbarShown) {
231− _errorEvents.trySend(ChatError(container.friendlyError(cause)))
232− loadErrorSnackbarShown = true
233− }
234− // Exponential backoff capped at retryMaxDelayMs so a persistently failing
235− // server is retried with growing delays (retryInitialDelayMs, then doubling)
236− // instead of hammered at a fixed interval forever. Clamp the shift so a long
237− // failure streak can't overflow the Long delay. Apply symmetric jitter
238− // (matching withRetry's jitteredBackoff) so concurrent retrying flows (e.g.
239− // messages + catalogs after a server blip) don't retry in lockstep and
240− // thundering-herd the recovering server.
241− val shift = attempt.coerceAtMost(16L).toInt()
242− val baseDelay = (NetworkConfig.retryInitialDelayMs shl shift)
243− .coerceAtMost(NetworkConfig.retryMaxDelayMs)
244− val jitter = ((baseDelay * NetworkConfig.retryJitterFactor) *
245− (kotlin.random.Random.nextDouble() * 2 - 1)).toLong()
246− val backoffMs = (baseDelay + jitter).coerceAtLeast(0)
247− delay(backoffMs)
248− true
249− }
250−
251− val messages: StateFlow<List<MessageWithParts>> =
252− combine(sseMessages, _optimisticMessages) { sse, optimistic ->
253− mergeOptimistic(sse, optimistic)
254− }.stateIn(
255− scope = viewModelScope,
256− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
257− initialValue = emptyList(),
258− )
259−
260− /** Whether the conversation has any messages. Derived separately so the top bar
261− * (share button enabled state) can observe this cheap boolean instead of the
262− * full messages list, avoiding per-token recomposition of the app bar during
263− * streaming. */
264− val hasMessages: StateFlow<Boolean> = messages
265− .map { it.isNotEmpty() }
266− .stateIn(
267− scope = viewModelScope,
268− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
269− initialValue = false,
270− )
271−
272− /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
273− * or "Failed to send" indicator on the corresponding message bubble. */
274− val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
275− .map { entries -> entries.associate { it.tempId to it.failed } }
276− .stateIn(
277− scope = viewModelScope,
278− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
279− initialValue = emptyMap(),
280− )
281−
282− /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
283− * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
284− * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
285− * are kept so the user sees the failed indicator until they retry or dismiss.
286− *
287− * Only the OLDEST matching entry per real text is dropped, so duplicate prompts (the same
288− * text sent twice) each match their own server-echoed UserMessage instead of both
289− * vanishing on the first echo. */
290− private fun mergeOptimistic(
291− sse: List<MessageWithParts>,
292− optimistic: List<OptimisticEntry>,
293− ): List<MessageWithParts> {
294− if (optimistic.isEmpty()) return sse
295− // Collect the text of real user messages so optimistic entries can be matched.
296− val realUserTexts = mutableSetOf<String>()
297− for (msg in sse) {
298− if (msg.info !is UserMessage) continue
299− val text = msg.parts.filterIsInstance<TextPart>()
300− .joinToString("\n\n") { it.text }
301− .trim()
302− if (text.isNotEmpty()) realUserTexts.add(text)
303− }
304− val surviving = dropOldestMatches(optimistic, realUserTexts)
305− if (surviving.isEmpty()) return sse
306− return sse + surviving.map { it.toMessageWithParts() }
307− }
308−
309− /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
310− * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly.
311− * Only the OLDEST matching entry per real text is removed, so duplicate prompts (the same
312− * text sent twice) each match their own server-echoed UserMessage instead of both
313− * vanishing on the first echo. */
314− private fun reconcileOptimistic(sse: List<MessageWithParts>) {
315− if (_optimisticMessages.value.isEmpty()) return
316− val realUserTexts = mutableSetOf<String>()
317− for (msg in sse) {
318− if (msg.info !is UserMessage) continue
319− val text = msg.parts.filterIsInstance<TextPart>()
320− .joinToString("\n\n") { it.text }
321− .trim()
322− if (text.isNotEmpty()) realUserTexts.add(text)
323− }
324− if (realUserTexts.isEmpty()) return
325− _optimisticMessages.update { entries -> dropOldestMatches(entries, realUserTexts) }
326− }
327−
328− /** Drop the first (oldest) entry whose trimmed text matches each real user text, once per
329− * text. A second optimistic entry with the same text survives until a second matching
330− * UserMessage arrives, so duplicate prompts aren't both cleared by the first echo. */
331− private fun dropOldestMatches(
332− entries: List<OptimisticEntry>,
333− realUserTexts: Set<String>,
334− ): List<OptimisticEntry> {
335− if (entries.isEmpty() || realUserTexts.isEmpty()) return entries
336− val remaining = realUserTexts.toMutableSet()
337− val result = ArrayList<OptimisticEntry>(entries.size)
338− for (entry in entries) {
339− val text = entry.text.trim()
340− if (text in remaining) {
341− remaining.remove(text)
342− } else {
343− result.add(entry)
344− }
345− }
346− return result
347− }
348−
349− /** Convert an optimistic entry to a [MessageWithParts] for display. */
350− private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
351− info = UserMessage(
352− id = tempId,
353− sessionID = sessionId,
354− time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
355− ),
356− parts = listOf(TextPart(id = "$tempId-text", text = text)),
357− )
358−
359− /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
360− * live progress checklist above the composer. distinctUntilChanged so a per-token messages
361− * emission that doesn't change the plan doesn't recompose the bar.
362− *
363− * A [TodoPlanCache] memoizes the scan against the input list's identity, so a `combine`
364− * re-evaluation that re-emits the same list instance (e.g. when the optimistic-messages
365− * StateFlow updates without an SSE change) skips the O(messages×parts) re-scan. */
366− private val todoPlanCache = TodoPlanCache()
367− val todoPlan: StateFlow<List<TodoItem>> = messages
368− .map { todoPlanCache.plan(it) }
369− // Run the scan off the main thread: it's an O(messages×parts) scan (worst case
370− // when the conversation has no todowrite) and runs on every conflated messages emission.
371− // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
372− // scan — without flowOn this scans on Main.immediate per streamed token → jank.
373− .flowOn(Dispatchers.Default)
374− .distinctUntilChanged()
375− .stateIn(
376− scope = viewModelScope,
377− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
378− initialValue = emptyList(),
379− )
380−
381− private val _running = MutableStateFlow(false)
382− val running: StateFlow<Boolean> = _running.asStateFlow()
383−
384− /**
385− * True when the SSE stream dropped mid-run (a transient [Disconnected] while a run is
386− * active). The UI uses this to switch the working indicator to a "stream interrupted /
387− * reconnecting" visual instead of clearing [_running] outright — the run is still active
388− * on the server, so showing it as finished would mislead the user into typing a follow-up
389− * or navigating away. Cleared when the stream reconnects ([Connected]) or when a hard
390− * failure ([Failed]/[AuthFailed]) genuinely clears [_running]. See the state watcher in
391− * [init]. */
392− private val _streamInterrupted = MutableStateFlow(false)
393− val streamInterrupted: StateFlow<Boolean> = _streamInterrupted.asStateFlow()
394−
395− /**
396− * Wall-clock millis at which the current run started (0L when no run is active). Drives
397− * the typing row's elapsed timer so it survives LazyColumn recycling — a local `remember`
398− * in the row resets to 0:00 when the row is disposed and scrolled back into view. Stamped
399− * only on a real false→[beginRun] transition (send/run/init), not on SSE-reconnect relight
400− * (which continues an existing run and should keep its original elapsed time).
401− */
402− private val _runStartMs = MutableStateFlow(0L)
403− val runStartMs: StateFlow<Long> = _runStartMs.asStateFlow()
404−
405− /**
406− * Atomically transition `_running` false→true and stamp the run-start timestamp. Returns
407− * false (with no state change) when a run is already in flight, so callers can surface a
408− * "busy" message instead of silently dropping the action. Centralizes the transition so
409− * every run-start path captures [runStartMs] consistently.
410− */
411− private fun beginRun(): Boolean {
412− if (!_running.compareAndSet(false, true)) return false
413− _runStartMs.value = System.currentTimeMillis()
414− return true
415− }
416−
417− /** True once the current run ended via SessionIdle/SessionError, until the next run starts
418− * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
419− * returns true for ANY message.part.updated, so without this a trailing/replayed part
420− * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
421− * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
422− * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
423− @Volatile private var runEndedByIdle = false
424−
425− /** The in-flight REST title resolution for the current connection. Launched from — and
426− * cancelled by — the per-connection reset block so it can't observe or restore a previous
427− * server's stale title. */
428− private var sessionTitleJob: Job? = null
429−
430− /** True while an abort REST call is in flight, so the Stop button can show a
431− * spinner and prevent double-taps from firing a second abort. */
432− private val _aborting = MutableStateFlow(false)
433− val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
434−
435− /** A follow-up the user typed while a run was active. send() queues it here and
436− * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
437− * no way to send and no indication why the Send button is gone. */
438− private val _queuedFollowUp = MutableStateFlow<String?>(null)
439− val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
440−
441− /** The text of the most recently-sent user prompt (captured in [send]), so an undo-able
442− * Stop can re-send it. Only text-bearing prompts are tracked — an image-only prompt has
443− * nothing to re-send text-wise. Cleared when a new run starts with different text. */
444− private var lastSentPrompt: String? = null
445−
446− /** One-shot event signals for an undoable Stop. The UI collects this and shows an
447− * "Stopped — Undo" snackbar; tapping Undo calls [resendLastPrompt]. A Channel so an
448− * emit before the UI subscribes is still delivered. */
449− private val _stopUndoEvents = Channel<String?>(Channel.CONFLATED)
450− val stopUndoEvents: Flow<String?> = _stopUndoEvents.receiveAsFlow()
451−
452− /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
453− * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
454− * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
455− * composition). Each event is delivered exactly once and not replayed to a
456− * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
457− private val _errorEvents = Channel<ChatError>(
458− capacity = NetworkConfig.snackbarEventBufferCapacity,
459− onBufferOverflow = BufferOverflow.DROP_OLDEST,
460− )
461− val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
462−
463− /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
464− * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
465− * already viewing, instead of navigating away first and surfacing Undo on the session list.
466− * Carries the session id so a collector using `collectLatest` can withdraw a superseded
467− * delete (see [cancelSessionDelete]) — a `Unit` event would leave the prior session's
468− * deferred REST delete firing with no reachable Undo. */
469− private val _deleteUndoEvents = MutableSharedFlow<String>(
470− extraBufferCapacity = 1,
471− onBufferOverflow = BufferOverflow.DROP_OLDEST,
472− )
473− val deleteUndoEvents: SharedFlow<String> = _deleteUndoEvents.asSharedFlow()
474−
475− private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
476− val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
477−
478− private val _modelsLoading = MutableStateFlow(true)
479− val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
480−
481− private val _modelsError = MutableStateFlow(false)
482− val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
483−
484− private val _selectedModel = MutableStateFlow<ModelOption?>(null)
485− val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
486−
487− val connectionState: StateFlow<EventStreamClient.ConnectionState> =
488− container.activeConnection
489− .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
490− .stateIn(
491− scope = viewModelScope,
492− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
493− initialValue = EventStreamClient.ConnectionState.Disconnected,
494− )
495−
496− /** SSE reconnect-attempt count for the active connection, surfaced so the chat's
497− * connection banner can show "Reconnecting (attempt N)…" during a sustained outage. */
498− val reconnectAttempts: StateFlow<Int> =
499− container.activeConnection
500− .flatMapLatest { it?.events?.reconnectAttempts ?: flowOf(0) }
501− .stateIn(
502− scope = viewModelScope,
503− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
504− initialValue = 0,
505− )
506−
507− private val _pendingPermission = MutableStateFlow<Permission?>(null)
508− val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
509−
510− // Permission ids already responded to, guarding against a duplicate response after a
511− // config change: the dialog's `responded` flag uses plain `remember` so a re-queued
512− // permission is always answerable, but a rotation between tap and VM resolution resets
513− // it to false, allowing a second tap. Cleared on failure (so a re-queued permission is
514− // answerable) and on connection change.
515− private val respondedPermissionIds = mutableSetOf<String>()
516−
517− /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
518− private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
519− val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
520−
521− /** Max number of permissions simultaneously pending since the queue was last empty. Reset
522− * to 0 when the queue drains, so a fresh burst starts its count over. */
523− private var maxPendingSeen = 0
524−
525− // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
526− // recent; when one is answered/replied — or a response fails — we fall back to the next so a
527− // request that arrived while another was in flight isn't silently dropped (its tool run would
528− // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
529− // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
530− private val pendingPermissions = LinkedHashMap<String, Permission>()
531−
532− // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
533− // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
534− // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
535− // isn't re-prompted for every other file read in the same conversation. Cleared on connection
536− // change (see the activeConnection collector) so it never leaks across servers. The server only
537− // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
538− private val sessionAllowed = mutableSetOf<Pair<String, String>>()
539−
540− private fun enqueuePermission(permission: Permission) {
541− // Auto-respond to a permission whose (type, pattern) the user previously granted for this
542− // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
543− // the tool run isn't paused waiting for a dialog the user will never see.
544− val type = permission.type.orEmpty()
545− val pattern = permission.patternText.orEmpty()
546− if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
547− respondPermission(permission, PermissionResponse.ONCE)
548− return
549− }
550− pendingPermissions[permission.id] = permission
551− _pendingPermission.value = pendingPermissions.values.lastOrNull()
552− updatePermissionProgress()
553− }
554−
555− private fun resolvePermission(id: String) {
556− pendingPermissions.remove(id)
557− _pendingPermission.value = pendingPermissions.values.lastOrNull()
558− updatePermissionProgress()
559− }
560−
561− private fun clearPermissions() {
562− pendingPermissions.clear()
563− respondedPermissionIds.clear()
564− _pendingPermission.value = null
565− updatePermissionProgress()
566− }
567−
568− /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
569− * resets only when it drains, so position = total - remaining + 1 advances as requests are
570− * answered. */
571− private fun updatePermissionProgress() {
572− val remaining = pendingPermissions.size
573− if (remaining == 0) {
574− maxPendingSeen = 0
575− _permissionProgress.value = PermissionProgress(0, 0)
576− return
577− }
578− if (remaining > maxPendingSeen) maxPendingSeen = remaining
579− _permissionProgress.value = PermissionProgress(
580− position = maxPendingSeen - remaining + 1,
581− total = maxPendingSeen,
582− )
583− }
584−
585− private val _agents = MutableStateFlow<List<Agent>>(emptyList())
586− val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
587−
588− private val _agentsLoading = MutableStateFlow(true)
589− val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
590−
591− private val _agentsError = MutableStateFlow(false)
592− val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
593−
594− private val _selectedAgent = MutableStateFlow<String?>(null)
595− val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
596−
597− private val _commands = MutableStateFlow<List<Command>>(emptyList())
598− val commands: StateFlow<List<Command>> = _commands.asStateFlow()
599−
600− private val _commandsLoading = MutableStateFlow(true)
601− val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
602−
603− private val _commandsError = MutableStateFlow(false)
604− val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
605−
606− private val _sessionTitle = MutableStateFlow<String?>(null)
607− val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
608−
609− /** Set when the current session is deleted via SSE, so the UI can navigate away. */
610− private val _sessionDeleted = MutableStateFlow(false)
611− val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
612−
613− /** The text of the last send that failed, surfaced so the UI can offer a retry. */
614− private val _failedDraft = MutableStateFlow<String?>(null)
615− val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
616−
617− /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
618− * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
619− * deduplicated server-side instead of starting a duplicate agent run. */
620− @Volatile private var failedIdempotencyKey: String? = null
621−
622− /** Per-session draft, persisted so it survives navigation/process death. */
623− private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
624− val draft: StateFlow<String> = _draft.asStateFlow()
625−
626− /** A pending quote-reply preview (the raw text the user quoted), shown as a dismissible
627− * card above the composer so the user can see and cancel the quote before sending. Cleared
628− * on send or via [cancelQuoteReply]. The actual `>` prefixed lines are still folded into
629− * the draft for the server; this state is purely for the visual preview chip. */
630− private val _pendingQuote = MutableStateFlow<String?>(null)
631− val pendingQuote: StateFlow<String?> = _pendingQuote.asStateFlow()
632−
633− /** Messages composed for this session while offline/disconnected, queued in the outbox and
634− * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
635− * chip with a discard/send-now affordance. */
636− val outbox: StateFlow<List<OutboxMessage>> =
637− container.outboxStore.messages
638− .map { list -> list.filter { it.sessionId == sessionId } }
639− .stateIn(
640− scope = viewModelScope,
641− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
642− initialValue = emptyList(),
643− )
644−
645− /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
646− * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
647− * survives process death — the source content Uris wouldn't survive, but the data URLs
648− * do. Cleared on a successful send. */
649− private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
650− val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
651−
652− // Count of picks currently being read + base64-encoded off the main thread, so the
653− // composer can show a staging placeholder immediately (chips only materialize once done).
654− // Exposed as StateFlows (not Compose state) so they survive a config change: the staging
655− // work runs on viewModelScope (which survives rotation), so the counters must live on the
656− // VM too — remember { mutableStateOf(0) } in the composable would reset to 0 on rotation
657− // and lose the staging chip mid-encode.
658− private val _stagingCount = MutableStateFlow(0)
659− val stagingCount: StateFlow<Int> = _stagingCount.asStateFlow()
660− // Total number of files still being staged across all in-flight batches, so the
661− // staging chip can show "Staging N files…" rather than a bare indeterminate spinner.
662− private val _stagingFileTotal = MutableStateFlow(0)
663− val stagingFileTotal: StateFlow<Int> = _stagingFileTotal.asStateFlow()
664−
665− /** True while a revert checkpoint is active for this session (messages after it are hidden
666− * server-side). Drives the "reverted" banner with its Undo. */
667− private val _reverted = MutableStateFlow(false)
668− val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
669−
670− /** True when the revert was triggered by an Edit action (vs a bare Revert). Drives the
671− * banner's copy so it reads "Editing message" rather than "Reverted", making the composer's
672− * prefilled text unambiguous. Cleared on unrevert and on send. */
673− private val _editing = MutableStateFlow(false)
674− val editing: StateFlow<Boolean> = _editing.asStateFlow()
675−
676− /** The diff of the active revert checkpoint, when the server includes one. Shown in the
677− * revert banner so the user can see what changed before deciding to undo. Null when the
678− * session isn't reverted or the server didn't send a diff. */
679− private val _revertDiff = MutableStateFlow<String?>(null)
680− val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
681−
682− /** The active public share URL for this session, or null when not shared. */
683− private val _shareUrl = MutableStateFlow<String?>(null)
684− val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
685−
686− /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
687− private val _shareLinkEvents = Channel<String>(
688− capacity = NetworkConfig.snackbarEventBufferCapacity,
689− onBufferOverflow = BufferOverflow.DROP_OLDEST,
690− )
691− val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
692−
693− /** One-shot events carrying the id of a session freshly branched from a message, so the
694− * UI can navigate to it. */
695− private val _branchEvents = Channel<String>(
696− capacity = NetworkConfig.snackbarEventBufferCapacity,
697− onBufferOverflow = BufferOverflow.DROP_OLDEST,
698− )
699− val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
700−
701− init {
702− // DraftStore loads SharedPreferences asynchronously; on a cold start the
703− // synchronous get() above returns "" until the background load completes.
704− // Observe the ready signal and re-seed the draft so it appears in the UI.
705− viewModelScope.launch {
706− container.draftStore.ready.collect { ready ->
707− if (!ready) return@collect
708− if (_draft.value.isEmpty()) {
709− _draft.value = container.draftStore.get(sessionId)
710− }
711− // Recover a follow-up queued before process death (its in-memory StateFlow
712− // is gone). If the input is now free, drop it back there for review/send and
713− // consume the stored copy; otherwise re-queue it so it auto-sends when the
714− // current run next idles. Guarded on == null so it never clobbers a
715− // follow-up the user queued after opening this session.
716− val savedFollowUp = container.draftStore.getFollowUp(sessionId)
717− if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
718− if (_draft.value.isEmpty()) {
719− _draft.value = savedFollowUp
720− container.draftStore.flushFollowUp(sessionId, "")
721− } else {
722− _queuedFollowUp.value = savedFollowUp
723− }
724− }
725− }
726− }
727− // Observe the drafts store for external mutations to this session's draft
728− // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
729− // before navigation in single-pane mode). Without this, a share injected into
730− // an already-open session never appears in the input field — the init-time
731− // read and the ready-only re-seed miss it when the draft is non-empty.
732− // The VM's own debounced persist writes the same value _draft already holds,
733− // so those updates are no-ops here (storeValue == _draft.value). The
734− // suppressDraftPersist guard prevents a stale store value from clobbering
735− // the deliberate draft clear during a send (the store still holds the
736− // pre-send draft until the send resolves).
737− viewModelScope.launch {
738− container.draftStore.drafts.collect { drafts ->
739− // Ignore emissions until the initial disk load completes: the load itself
740− // emits the persisted snapshot, and applying it here would clobber text the
741− // user typed during the async load (the `ready` observer above seeds an
742− // empty draft). After ready, emissions are external writes.
743− if (!container.draftStore.ready.value) return@collect
744− val storeValue = drafts[sessionId].orEmpty()
745− // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
746− // genuine external write (e.g. a two-pane share injection) should overwrite
747− // the live draft.
748− if (storeValue == lastPersistedDraft) return@collect
749− if (storeValue != _draft.value && !suppressDraftPersist.get()) {
750− _draft.value = storeValue
751− }
752− }
753− }
754− }
755−
756− init {
757− // Restore attachments staged before process death. They were persisted as
758− // self-contained data URLs, so they re-send and (for images) re-preview intact.
759− // Guarded on empty so it never clobbers attachments the user staged after opening.
760− viewModelScope.launch {
761− val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
762− .getOrDefault(emptyList())
763− if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
764− _attachments.value = restored.map { it.toPending() }
765− }
766− }
767− }
768−
769− fun selectModel(option: ModelOption) { _selectedModel.value = option }
770−
771− fun selectAgent(name: String?) { _selectedAgent.value = name }
772−
773− fun reloadModels() {
774− val conn = connection ?: return
775− viewModelScope.launch {
776− conn.api.invalidateProvidersCache()
777− _modelsReload.value++
778− }
779− }
780−
781− fun reloadAgents() {
782− val conn = connection ?: return
783− viewModelScope.launch {
784− conn.api.invalidateAgentsCache()
785− _agentsReload.value++
786− }
787− }
788−
789− fun reloadCommands() {
790− val conn = connection ?: return
791− viewModelScope.launch {
792− conn.api.invalidateCommandsCache()
793− _commandsReload.value++
794− }
795− }
796−
797− /**
798− * Shared helper for init-block catalog observers: re-fetches whenever the active
799− * connection or the catalog's reload trigger changes, invoking [onNull] to clear
800− * state on null so stale data from the old server doesn't persist. The
801− * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
802− * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
803− * via collectLatest, preventing a stale observe result from overwriting a fresh
804− * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
805− */
806− private fun <T> observeCatalog(
807− tag: String,
808− loading: MutableStateFlow<Boolean>,
809− error: MutableStateFlow<Boolean>,
810− fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
811− onSuccess: suspend (T) -> Unit,
812− onNull: () -> Unit,
813− reloadTrigger: StateFlow<Int>,
814− ) {
815− viewModelScope.launch {
816− merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
817− val conn = container.activeConnection.value
818− if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
819− loading.value = true
820− error.value = false
821− runCatchingCancellable { fetch(conn.api) }
822− // fetch() does HTTP via withRetry, so the failure can be a
823− // ClientRequestException whose message embeds the full request URL
824− // (may contain auth/paths). Log only a scrubbed summary.
825− .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
826− .getOrNull()?.let { onSuccess(it) }
827− loading.value = false
828− }
829− }
830− }
831−
832− fun updateDraft(text: String) {
833− _draft.value = text
834− }
835−
836− init {
837− // Debounce draft persistence so we don't write to disk on every keystroke.
838− // Skip persisting empty drafts that were set by send() (suppressed via
839− // suppressDraftPersist) — send() clears the in-memory draft immediately for
840− // UI feedback but deliberately doesn't persist the clear until the send
841− // succeeds, so a failed send can restore the draft.
842− viewModelScope.launch {
843− _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
844− if (text.isBlank() && suppressDraftPersist.get()) return@collect
845− lastPersistedDraft = text
846− runCatchingCancellable { container.draftStore.set(sessionId, text) }
847− .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
848− }
849− }
850− // Reset per-connection state when the active server changes so stale spinners,
851− // permission dialogs, errors, and agent selections from the old server don't
852− // persist into the new one.
853− viewModelScope.launch {
854− container.activeConnection.collectLatest { conn ->
855− if (conn == null) {
856− // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
857− // arrive to deliver SessionIdle/SessionError, so reset the run state here —
858− // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
859− // the RunForegroundService alive, and the Stop button no-ops (abort() early-
860− // returns with no connection) until a later reconnect happens to clear it.
861− _running.value = false
862− _streamInterrupted.value = false
863− _aborting.value = false
864− clearPermissions()
865− sessionAllowed.clear()
866− sessionTitleJob?.cancel()
867− _revertDiff.value = null
868− return@collectLatest
869− }
870− _running.value = false
871− _streamInterrupted.value = false
872− // Allow the re-light below to recover a still-running run after this (re)connect;
873− // a run that actually finished during the outage emits no live parts, so this
874− // won't spuriously re-light.
875− runEndedByIdle = false
876− clearPermissions()
877− sessionAllowed.clear()
878− _failedDraft.value = null
879− _revertDiff.value = null
880− // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
881− // user intent that is now persisted; wiping it on every (re)connect — which
882− // this collector does, including transient SSE drops and the cold-start
883− // restore path — would drop a legitimately queued/recovered follow-up.
884− hasShownMessages = false
885− _selectedAgent.value = null
886− // Apply the user's persisted preferred-agent for this new session/load, so a
887− // user who always runs a specific agent doesn't have to pick it every session.
888− // No-op when the preference is empty (the server default applies). Read here
889− // (not in the agent-catalog onSuccess) because the catalog loads once per
890− // connection while this reset fires per-session-open; setting it once on
891− // connect is enough and avoids re-applying it on every catalog refresh.
892− runCatchingCancellable {
893− val pref = container.settingsStore.preferredAgentName.first()
894− if (pref.isNotEmpty()) _selectedAgent.value = pref
895− }
896− _sessionTitle.value = null
897− // Resolve the app-bar title via REST for THIS connection. Launched from the same
898− // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
899− // never observe or restore the previous server's stale title — the old separate
900− // collector could, racing that reset. The assignment is guarded on a still-null
901− // title so a fresher SSE SessionUpdated (from the event collector below) wins.
902− sessionTitleJob?.cancel()
903− sessionTitleJob = viewModelScope.launch {
904− runCatchingCancellable { conn.repository.listSessions() }
905− .getOrNull()
906− ?.firstOrNull { it.id == sessionId }
907− ?.let { session ->
908− if (_sessionTitle.value == null) {
909− _sessionTitle.value = session.displayTitle
910− _reverted.value = session.isReverted
911− _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
912− _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
913− }
914− }
915− }
916− try {
917− // Loop the inner collect so a transient non-cancellation exception (a
918− // malformed SSE event decoding error, an unexpected channel close)
919− // doesn't terminate the event collector for this connection. Without
920− // the loop, the catch returns the collectLatest lambda normally, so
921− // collectLatest considers this connection's block complete and never
922− // re-runs it — the chat would go permanently silent (no streaming,
923− // no permission prompts, no title updates) until a server switch.
924− while (currentCoroutineContext().isActive) {
925− try {
926− conn.events.events.collect { event ->
927− if (SessionRepository.isIdle(event, sessionId)) {
928− _running.value = false
929− runEndedByIdle = true
930− // Auto-send a queued follow-up once the previous run finishes,
931− // so the user's drafted-while-running message isn't lost.
932− // The follow-up is cleared *inside* send()'s launched
933− // coroutine (via queuedFollowUpToClear), so a scope
934− // cancellation between this point and the coroutine
935− // starting drops the launch without wiping the persisted
936− // follow-up — recovery on next session open instead of
937− // silent data loss.
938− val queued = _queuedFollowUp.value
939− if (queued != null) {
940− send(queued, includeAttachments = false, queuedFollowUpToClear = queued)
941− }
942− }
943− if (SessionRepository.isError(event, sessionId)) {
944− _running.value = false
945− runEndedByIdle = true
946− // The run errored, so the queued follow-up won't auto-send.
947− // Restore it to the input (if free) so the user's typed text
948− // isn't silently lost; otherwise just clear the queue.
949− val queued = _queuedFollowUp.value
950− if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
951− _draft.value = queued
952− }
953− setQueuedFollowUp(null)
954− _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
955− }
956− // Restore the run indicator if a live streaming event arrives while it's
957− // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
958− // agent is still working. Don't revive it once the user has hit Stop.
959− val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
960− !_aborting.value && !_running.value && !runEndedByIdle
961− if (relightRunIndicator) {
962− _running.value = true
963− }
964− when (event) {
965− is PermissionUpdated ->
966− if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
967− is PermissionReplied ->
968− if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
969− is SessionUpdated -> {
970− val info = event.properties.info
971− if (info != null && info.id == sessionId) {
972− _sessionTitle.value = info.displayTitle
973− _reverted.value = info.isReverted
974− _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
975− _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
976− }
977− }
978− is SessionDeleted ->
979− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
980− _sessionDeleted.value = true
981− }
982− else -> {}
983− }
984− }
985− } catch (e: CancellationException) {
986− throw e
987− } catch (e: Exception) {
988− Log.w("ChatViewModel", "SSE event collector error, restarting", e)
989− }
990− }
991− } catch (e: CancellationException) {
992− throw e
993− } catch (e: Exception) {
994− Log.w("ChatViewModel", "SSE event collector loop error", e)
995− }
996− }
997− }
998− // Load the model catalog; preselect the server default. Failure is non-fatal —
999− // sending with no model just uses the server's default agent/model.
1000− observeCatalog(
1001− tag = "model catalog",
1002− loading = _modelsLoading,
1003− error = _modelsError,
1004− fetch = { it.providers() },
1005− onSuccess = { resp ->
1006− val options = resp.toOptions()
1007− _models.value = options
1008− // Only (re)apply the default when there's no valid current selection. On
1009− // re-fetch/reconnect/Retry the user's chosen model must survive — keep it as
1010− // long as it's still present in the refreshed list. When there's no current
1011− // selection, prefer the user's persisted default-model preference (if it's in
1012− // the list) over the server default, so a user who always picks a specific
1013− // model doesn't have to pick it every new session.
1014− val current = _selectedModel.value
1015− if (current == null || options.none { it.ref == current.ref }) {
1016− val prefId = runCatchingCancellable {
1017− container.settingsStore.preferredModelId.first()
1018− }.getOrDefault("")
1019− val preferredOption = if (prefId.isNotEmpty()) {
1020− options.firstOrNull { it.modelID == prefId }
1021− } else null
1022− _selectedModel.value = preferredOption ?: resp.defaultOption(options)
1023− }
1024− },
1025− onNull = { _models.value = emptyList(); _selectedModel.value = null },
1026− reloadTrigger = _modelsReload,
1027− )
1028− // Load the agent catalog (non-fatal).
1029− observeCatalog(
1030− tag = "agent catalog",
1031− loading = _agentsLoading,
1032− error = _agentsError,
1033− fetch = { it.agents() },
1034− onSuccess = { _agents.value = it },
1035− onNull = { _agents.value = emptyList() },
1036− reloadTrigger = _agentsReload,
1037− )
1038− // Load the command catalog (non-fatal).
1039− observeCatalog(
1040− tag = "command catalog",
1041− loading = _commandsLoading,
1042− error = _commandsError,
1043− fetch = { it.commands() },
1044− onSuccess = { _commands.value = it },
1045− onNull = { _commands.value = emptyList() },
1046− reloadTrigger = _commandsReload,
1047− )
1048− // If the SSE stream drops mid-run, the run indicator would spin forever;
1049− // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
1050− // On a *transient* drop (Disconnected) we don't clear _running — the run is still
1051− // active on the server, and showing it as finished would mislead the user. Instead
1052− // we set _streamInterrupted so the working row switches to a "reconnecting" visual.
1053− // On a *hard* failure (Failed/AuthFailed) the run genuinely can't continue without
1054− // user action, so _running is cleared. _streamInterrupted is cleared on Connected.
1055− viewModelScope.launch {
1056− container.activeConnection.collectLatest { conn ->
1057− if (conn == null) return@collectLatest
1058− try {
1059− // Loop so a transient non-cancellation exception doesn't terminate the
1060− // state watcher for this connection. Without the loop, a single thrown
1061− // error returns the collectLatest lambda and the _running reset on
1062− // Disconnected/Failed never fires again for this connection — a dropped
1063− // stream leaves the working spinner (and the RunForegroundService) stuck.
1064− while (currentCoroutineContext().isActive) {
1065− try {
1066− conn.events.state.collect { state ->
1067− when (state) {
1068− EventStreamClient.ConnectionState.Connected -> {
1069− _streamInterrupted.value = false
1070− }
1071− EventStreamClient.ConnectionState.Disconnected -> {
1072− // Transient: the stream dropped but the system is
1073− // reconnecting. Flag interrupted so the working row
1074− // shows a "reconnecting" state instead of vanishing.
1075− if (_running.value) _streamInterrupted.value = true
1076− }
1077− EventStreamClient.ConnectionState.Failed,
1078− EventStreamClient.ConnectionState.AuthFailed -> {
1079− // Hard failure: the run can't continue without user
1080− // action (fix credentials / server). Clear running and
1081− // the interrupted flag so the UI doesn't look stuck.
1082− _streamInterrupted.value = false
1083− if (_running.value) _running.value = false
1084− }
1085− EventStreamClient.ConnectionState.Connecting -> {
1086− // Initial connect or reconnect attempt; leave running
1087− // as-is — interrupted (if set) stays until Connected.
1088− }
1089− }
1090− }
1091− } catch (e: CancellationException) {
1092− throw e
1093− } catch (e: Exception) {
1094− Log.w("ChatViewModel", "SSE state collector error, restarting", e)
1095− }
1096− }
1097− } catch (e: CancellationException) {
1098− throw e
1099− } catch (e: Exception) {
1100− Log.w("ChatViewModel", "SSE state collector loop error", e)
1101− }
1102− }
1103− }
1104− }
1105−
1106− /** Sends [text] together with any staged attachments; returns true on success so the
1107− * caller can clear the draft only then. */
1108− fun send(text: String): Boolean = send(text, includeAttachments = true)
1109−
1110− /**
1111− * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
1112− * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
1113− *
1114− * [queuedFollowUpToClear] is the text of a queued follow-up being auto-sent (from
1115− * SessionIdle). When non-null, the follow-up is cleared from persistence *inside* the
1116− * launched coroutine — so if [viewModelScope] is cancelled before the coroutine runs
1117− * (e.g. the user navigates away at the exact moment SessionIdle arrives), the clear
1118− * never executes and the follow-up remains persisted, recovered on the next open of
1119− * this session. Clearing it before the launch (the prior approach) could lose the
1120− * follow-up entirely: the launch would be dropped by cancellation but the persisted
1121− * copy was already wiped.
1122− */
1123− @Suppress("CyclomaticComplexMethod")
1124− private fun send(
1125− text: String,
1126− includeAttachments: Boolean,
1127− idempotencyKey: String? = null,
1128− queuedFollowUpToClear: String? = null,
1129− ): Boolean {
1130− val trimmed = text.trim()
1131− val attachments = if (includeAttachments) _attachments.value else emptyList()
1132− // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
1133− // attached is not.
1134− if (trimmed.isEmpty() && attachments.isEmpty()) return false
1135− // Offline, or no active connection: queue the prompt to the outbox (flushed on
1136− // reconnect) instead of failing the send. Device-offline is caught even when a stale
1137− // connection object lingers, so a phone that lost signal mid-conversation still
1138− // captures the message rather than bouncing it off a dead socket.
1139− val conn = connection
1140− if (conn == null || !container.isOnline.value) {
1141− return enqueueOffline(trimmed, attachments)
1142− }
1143− if (!beginRun()) return false
1144− // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
1145− // indicator lit (and a later SessionIdle re-closes it).
1146− runEndedByIdle = false
1147− // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
1148− // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
1149− // re-send stays deduplicated server-side instead of starting a duplicate agent run.
1150− // Capture the match BEFORE clearing _failedDraft just below.
1151− val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
1152− failedIdempotencyKey
1153− } else {
1154− null
1155− }
1156− _failedDraft.value = null
1157− // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
1158− // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
1159− // key it stashed on the original failure — so a retry after a lost response is deduplicated
1160− // server-side (no duplicate agent run).
1161− val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
1162− // Clear the staged attachments optimistically so the composer empties immediately;
1163− // restore them if the send fails (mirrors the draft handling below).
1164− if (attachments.isNotEmpty()) _attachments.value = emptyList()
1165− // Clear the in-memory draft for the UI immediately, but don't persist the clear
1166− // yet — if the send fails and the process dies before we restore, the draft
1167− // would be lost forever. The persisted draft is cleared only on success.
1168− // suppressDraftPersist prevents the debounced collector from persisting the
1169− // empty draft before the send resolves.
1170− //
1171− // Only clear when the text being sent IS the current draft. send() is also
1172− // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
1173− // the user may have typed a NEW draft since — wiping it would lose that text.
1174− if (_draft.value.trim() == trimmed) {
1175− suppressDraftPersist.set(true)
1176− _draft.value = ""
1177− _pendingQuote.value = null
1178− _editing.value = false
1179− }
1180− // Inject an optimistic user message so the outgoing prompt is visible immediately,
1181− // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
1182− // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
1183− // no text to show).
1184− val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
1185− if (optimisticId != null) {
1186− // Clear any prior failed optimistic entry with the same text (a retry re-sends).
1187− _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
1188− _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
1189− }
1190− // Remember the sent text so an undoable Stop can re-send it. Overwrite unconditionally:
1191− // an image-only prompt has nothing to re-send, and leaving a prior text prompt tracked
1192− // would make Stop-Undo silently re-send that unrelated earlier message.
1193− lastSentPrompt = trimmed.takeIf { it.isNotEmpty() }
1194− viewModelScope.launch {
1195− // Clear a queued follow-up being auto-sent *inside* the launched coroutine, so a
1196− // scope cancellation between send() being called and the coroutine starting drops
1197− // the launch without wiping the persisted follow-up — the follow-up is then
1198− // recovered on the next session open instead of being silently lost.
1199− if (queuedFollowUpToClear != null) setQueuedFollowUp(null)
1200− // Re-check the active connection: send() captured `conn` synchronously above, but a
1201− // server switch between send() returning and this coroutine starting would send the
1202− // prompt to the OLD (closed) server. Route to the outbox instead so the message is
1203− // delivered to the correct server on reconnect, rather than failing against a stale
1204− // connection. Pass the ORIGINAL connection's profile id so the outbox attributes the
1205− // message to the server the user was typing against — without this, enqueueOffline
1206− // would re-read container.activeConnection.value (now the NEW server) and queue the
1207− // message against a session id that doesn't exist there, 404ing on flush.
1208− // Reset _running (set by beginRun above) since no SSE streaming will
1209− // follow an outbox enqueue — without this the run indicator would be stuck on.
1210− if (container.activeConnection.value !== conn) {
1211− enqueueOffline(trimmed, attachments, profileId = conn.profile.id)
1212− _running.value = false
1213− return@launch
1214− }
1215− runCatchingCancellable {
1216− conn.repository.sendPrompt(
1217− sessionId,
1218− trimmed,
1219− attachments = attachments.map { it.part },
1220− model = _selectedModel.value?.ref,
1221− agent = _selectedAgent.value,
1222− idempotencyKey = key,
1223− )
1224− }.onFailure {
1225− // Mark the optimistic message as failed so the user sees a failure indicator
1226− // instead of a perpetual "sending" state.
1227− if (optimisticId != null) {
1228− _optimisticMessages.update { entries ->
1229− entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
1230− }
1231− }
1232− suppressDraftPersist.set(false)
1233− _failedDraft.value = trimmed
1234− failedIdempotencyKey = key
1235− // Only restore the draft if the user hasn't typed anything new since.
1236− if (_draft.value.isBlank()) updateDraft(trimmed)
1237− // Restore the staged attachments too, so a failed send doesn't lose them —
1238− // but only if the user hasn't staged new ones in the meantime.
1239− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1240− // Re-persist so the restored attachments survive process death after a failure.
1241− persistAttachments()
1242− // Retryable: this is the failed send whose prompt Retry re-submits.
1243− _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1244− _running.value = false
1245− }.onSuccess {
1246− suppressDraftPersist.set(false)
1247− // This attempt reached the server, so the stashed retry key is spent — drop it so a
1248− // later unrelated send can't reuse it (retryFailed clears it on success too).
1249− failedIdempotencyKey = null
1250− // Send succeeded — the attachments rode along, so clear their persisted copy
1251− // (or keep any the user staged after the optimistic clear).
1252− persistAttachments()
1253− // Send succeeded — persist the cleared draft, but only if the user hasn't
1254− // typed a new one while the send was in flight. Otherwise clearing the
1255− // store echoes back through the draft observer and wipes the in-progress
1256− // text (data loss). Mirrors the guard on the failure path above.
1257− if (_draft.value.isBlank()) {
1258− lastPersistedDraft = ""
1259− container.draftStore.set(sessionId, "")
1260− }
1261− // Don't reset _running here: the agent continues streaming via SSE.
1262− // _running is cleared on SessionIdle/SessionError (see event collector)
1263− // or when the SSE stream drops (see connection state watcher below).
1264− }
1265− }
1266− return true
1267− }
1268−
1269− /**
1270− * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1271− * optimistically like a real send. AppContainer flushes the outbox automatically on
1272− * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1273− * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1274− * are restored so nothing is lost.
1275− *
1276− * [profileId] pins the message to a specific server: when [send] re-routes here because a
1277− * server switch was detected mid-send, it passes the ORIGINAL connection's profile id so
1278− * the queued message flushes against the session on that server, not the new one (which
1279− * may not have a session with this id and would 404). When null (the user was genuinely
1280− * offline with no connection), the profile is resolved at enqueue time as before.
1281− */
1282− private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>, profileId: String? = null): Boolean {
1283− _failedDraft.value = null
1284− if (attachments.isNotEmpty()) _attachments.value = emptyList()
1285− val clearDraft = _draft.value.trim() == trimmed
1286− if (clearDraft) {
1287− suppressDraftPersist.set(true)
1288− _draft.value = ""
1289− }
1290− viewModelScope.launch {
1291− val resolvedProfileId = profileId
1292− ?: connection?.profile?.id
1293− ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1294− if (resolvedProfileId == null) {
1295− // No server to attribute the message to — restore the composer and report it
1296− // rather than silently dropping the prompt.
1297− suppressDraftPersist.set(false)
1298− if (_draft.value.isBlank()) updateDraft(trimmed)
1299− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1300− persistAttachments()
1301− _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1302− return@launch
1303− }
1304− runCatchingCancellable {
1305− container.outboxStore.enqueue(
1306− OutboxMessage(
1307− id = java.util.UUID.randomUUID().toString(),
1308− profileId = resolvedProfileId,
1309− sessionId = sessionId,
1310− text = trimmed,
1311− attachments = attachments.map { it.toPersisted() },
1312− model = _selectedModel.value?.ref,
1313− agent = _selectedAgent.value,
1314− createdAt = System.currentTimeMillis(),
1315− ),
1316− )
1317− }.onSuccess {
1318− suppressDraftPersist.set(false)
1319− // The staged attachments now live in the outbox item; clear their draft copy.
1320− persistAttachments()
1321− if (_draft.value.isBlank()) {
1322− lastPersistedDraft = ""
1323− container.draftStore.set(sessionId, "")
1324− }
1325− // Nudge a flush in case we're actually online with a live connection.
1326− container.flushOutbox()
1327− _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1328− }.onFailure {
1329− suppressDraftPersist.set(false)
1330− if (_draft.value.isBlank()) updateDraft(trimmed)
1331− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1332− persistAttachments()
1333− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1334− }
1335− }
1336− return true
1337− }
1338−
1339− /** Discard a single queued (offline) message. */
1340− fun discardQueued(id: String) {
1341− viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1342− }
1343−
1344− /** Discard every queued (offline) message for this session. */
1345− fun discardAllQueued() {
1346− viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1347− }
1348−
1349− /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1350− fun flushQueued() { container.flushOutbox() }
1351−
1352− /** Re-send the last draft whose send failed, if any. */
1353− fun retryFailed() {
1354− val draft = _failedDraft.value ?: return
1355− // Reuse the failed send's idempotency key so a retry after a lost response is
1356− // deduplicated server-side rather than starting a duplicate run.
1357− val key = failedIdempotencyKey
1358− // Don't clear _failedDraft until send() accepts the text — if _running is
1359− // already true, send() returns false and the draft would be lost forever.
1360− // Attachments aren't re-sent on retry (they were optimistically restored to the
1361− // composer on the original failure, so they'll ride the next manual send).
1362− // send() accepts the text synchronously, but its onSuccess/onFailure callbacks run
1363− // asynchronously. Clearing failedIdempotencyKey here would open a window between this
1364− // synchronous clear and the async onFailure (which re-stashes the key) during which a
1365− // manual re-send of the same text gets a fresh UUID — defeating server-side deduplication.
1366− // Let send()'s own callbacks own the key's lifecycle, as they do for non-retry paths.
1367− if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1368− _failedDraft.value = null
1369− }
1370− }
1371−
1372− /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1373− * list. Without this there's no recovery path for a failed message the user chose to abandon
1374− * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1375− fun dismissOptimistic(tempId: String) {
1376− _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1377− }
1378−
1379− /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1380− * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1381− * no-op) if the entry can't be found or a run is already active. */
1382− fun retryOptimisticMessage(tempId: String): Boolean {
1383− val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1384− return send(entry.text, includeAttachments = false)
1385− }
1386−
1387− /**
1388− * Queue [text] to be sent automatically when the current run finishes, or clear any
1389− * queued follow-up when [text] is blank. Used when the user taps Send while a run
1390− * is already active — the Send button is replaced by Stop during a run, but the
1391− * input field stays enabled, so a follow-up typed mid-run would otherwise have
1392− * nowhere to go.
1393− */
1394− fun queueFollowUp(text: String) {
1395− val trimmed = text.trim()
1396− setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1397− // The text now lives in the queued chip, so clear the input field just as a
1398− // normal send would. No-op for the cancel case (blank text), which must leave
1399− // whatever the user has since typed untouched.
1400− if (trimmed.isNotEmpty()) {
1401− _draft.value = ""
1402− // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1403− // persisted synchronously above, so if the process is killed before the debounced
1404− // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1405− // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1406− // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1407− // this store change as our own echo and doesn't write the cleared value back.
1408− lastPersistedDraft = ""
1409− container.draftStore.flushDraft(sessionId, "")
1410− }
1411− }
1412−
1413− /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1414− * queued mid-run survives process death (recovered on the next open of this session).
1415− * Passing null / blank clears both the in-memory value and the persisted copy. */
1416− private fun setQueuedFollowUp(text: String?) {
1417− _queuedFollowUp.value = text
1418− container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1419− }
1420−
1421− /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1422− * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1423− * refreshMessages may not visibly change [connectionState] (it's already
1424− * Connected), so without this the tap appears to do nothing. Clears after a
1425− * short delay or when the connection state next becomes Connected. */
1426− private val _refreshing = MutableStateFlow(false)
1427− val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1428−
1429− /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1430− * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1431− * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1432− * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1433− * existing generation-guarded merge. Also clears [loadError] optimistically and
1434− * sets [refreshing] for immediate tap feedback. */
1435− fun refreshMessages() {
1436− val conn = connection ?: return
1437− _loadError.value = false
1438− _loadErrorInline.value = false
1439− _refreshing.value = true
1440− conn.events.triggerReconnect()
1441− viewModelScope.launch {
1442− delay(NetworkConfig.refreshFeedbackMs)
1443− _refreshing.value = false
1444− }
1445− }
1446−
1447− /** Invoke a slash-command by name via the server's /command endpoint. */
1448− fun runCommand(command: Command) {
1449− val conn = connection ?: return
1450− // A run is already in flight — surface feedback instead of silently dropping the
1451− // command (the picker stays openable during a run and dismisses on select).
1452− if (!beginRun()) {
1453− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1454− return
1455− }
1456− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1457− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1458− runEndedByIdle = false
1459− lastSentPrompt = null
1460− viewModelScope.launch {
1461− runCatchingCancellable {
1462− conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1463− }.onFailure {
1464− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1465− _running.value = false
1466− }
1467− }
1468− }
1469−
1470− fun abort() {
1471− val conn = connection ?: return
1472− // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1473− // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1474− setQueuedFollowUp(null)
1475− if (!_aborting.compareAndSet(false, true)) return
1476− viewModelScope.launch {
1477− try {
1478− // Run the abort POST under NonCancellable so it survives this scope being
1479− // cancelled. The "Stop and exit" path calls abort() then navigates back
1480− // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1481− // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1482− // before the request even reaches the server) and the run keeps burning tokens
1483− // server-side despite the user explicitly stopping it.
1484− runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1485− .onSuccess {
1486− _running.value = false
1487− // Close the re-light gate so trailing SSE events in flight between the
1488− // abort POST's ack and the server's SessionIdle don't re-light the run
1489− // indicator (and re-arm the FGS / keepScreenOn) after the user hit Stop.
1490− // send()/runCommand()/continueRun() all open this gate on beginRun();
1491− // abort() is the one run-ending path that previously missed it.
1492− runEndedByIdle = true
1493− // Surface an undo opportunity so an accidental Stop is recoverable.
1494− // The UI shows a "Stopped — Undo" snackbar; tapping Undo re-sends the
1495− // last prompt. Only when there's a tracked prompt to undo back to.
1496− val prompt = lastSentPrompt
1497− if (prompt != null) _stopUndoEvents.trySend(prompt)
1498− }
1499− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1500− } finally {
1501− _aborting.value = false
1502− }
1503− }
1504− }
1505−
1506− /** Re-send the most recently-sent user prompt, used by the Stop-undo snackbar so an
1507− * accidental Stop can be recovered. No-op if no prompt is tracked (e.g. the run was
1508− * started by a command, not a text prompt). Clears the tracked prompt so a second Undo
1509− * tap doesn't double-send. */
1510− fun resendLastPrompt() {
1511− val prompt = lastSentPrompt ?: return
1512− lastSentPrompt = null
1513− if (!send(prompt, includeAttachments = false)) lastSentPrompt = prompt
1514− }
1515−
1516− fun addAttachment(attachment: PendingAttachment) {
1517− // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1518− // count caps are checked upstream). The base64 data URLs are held in memory and
1519− // re-serialized on every add/remove and again when the request body is built, so an
1520− // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1521− // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1522− // per-file check, and includes attachments already staged (incl. restored ones).
1523− val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1524− if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1525− _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1526− return
1527− }
1528− _attachments.update { it + attachment }
1529− persistAttachments()
1530− }
1531−
1532− fun removeAttachment(id: String) {
1533− _attachments.update { list -> list.filterNot { it.id == id } }
1534− persistAttachments()
1535− }
1536−
1537− fun clearAttachments() {
1538− _attachments.value = emptyList()
1539− persistAttachments()
1540− }
1541−
1542− /** Convert each picked Uri to a base64 attachment off the main thread, honoring the
1543− * per-prompt count cap and surfacing per-file errors via [errorEvents] without aborting
1544− * the batch. Runs on [viewModelScope] (which survives configuration changes) so an
1545− * in-flight encode isn't cancelled and its progress counters ([stagingCount] /
1546− * [stagingFileTotal]) aren't reset to 0 on rotation — the prior UI-side
1547− * `rememberCoroutineScope` implementation lost both the work and the counters on
1548− * every config change. */
1549− fun stageUris(uris: List<android.net.Uri>, context: android.content.Context) {
1550− if (uris.isEmpty()) return
1551− val appContext = context.applicationContext
1552− viewModelScope.launch {
1553− _stagingCount.value++
1554− _stagingFileTotal.value += uris.size
1555− try {
1556− uris.forEachIndexed { index, uri ->
1557− if (_attachments.value.size >= NetworkConfig.maxAttachments) {
1558− _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1559− // The cap dropped the rest of this batch; account for the files we
1560− // won't process so stagingFileTotal doesn't linger over-counted,
1561− // then abandon the batch. return@launch (not return@forEachIndexed)
1562− // so the remaining uris aren't re-checked against a never-relieved
1563− // cap (which would over-decrement the counter and re-fire the
1564− // snackbar once per leftover uri). The `finally` below still
1565− // decrements stagingCount.
1566− _stagingFileTotal.value -= uris.size - index
1567− return@launch
1568− }
1569− when (val result = uri.toAttachmentResult(appContext)) {
1570− is AttachmentResult.Ok -> addAttachment(result.attachment)
1571− AttachmentResult.TooLarge -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_too_large)))
1572− AttachmentResult.Failed -> _errorEvents.trySend(ChatError(container.string(R.string.attachment_failed)))
1573− }
1574− _stagingFileTotal.value--
1575− }
1576− } finally {
1577− _stagingCount.value--
1578− }
1579− }
1580− }
1581−
1582− /** Persist the current staged attachments so an interrupted compose survives process
1583− * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1584− private fun persistAttachments() {
1585− val snapshot = _attachments.value
1586− viewModelScope.launch {
1587− runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1588− .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1589− }
1590− }
1591−
1592− /** Revert the conversation to just before [messageId], hiding everything after it. The
1593− * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1594− * feedback. */
1595− fun revertTo(messageId: String) {
1596− val conn = connection ?: return
1597− viewModelScope.launch {
1598− runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1599− .onSuccess {
1600− _reverted.value = it.isReverted
1601− _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1602− _editing.value = false
1603− }
1604− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1605− }
1606− }
1607−
1608− /**
1609− * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1610− * it and everything after), then re-send the user prompt that immediately preceded it. A
1611− * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1612− *
1613− * Captures the preceding user text *before* the revert (the messages list updates
1614− * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1615− * or the assistant message isn't found.
1616− */
1617− fun regenerate(assistantMessageId: String) {
1618− val conn = connection ?: return
1619− val msgs = messages.value
1620− val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1621− if (idx < 0) return
1622− val precedingUserText = (idx - 1 downTo 0)
1623− .asSequence()
1624− .map { msgs[it] }
1625− .firstOrNull { it.info is UserMessage }
1626− ?.parts
1627− ?.filterIsInstance<TextPart>()
1628− ?.joinToString("\n\n") { it.text }
1629− ?.takeIf { it.isNotBlank() }
1630− ?: return
1631− viewModelScope.launch {
1632− runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1633− .onSuccess {
1634− // send() can return false if the beginRun() CAS lost to a concurrent run
1635− // or the message had to be enqueued offline and that failed. The revert has
1636− // already hidden the messages, so a silent false would leave the user with a
1637− // reverted conversation and no reply and no error — surface it instead.
1638− val sent = send(precedingUserText, includeAttachments = false)
1639− if (!sent) {
1640− _errorEvents.trySend(ChatError(container.string(R.string.error_generic)))
1641− }
1642− }
1643− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1644− }
1645− }
1646−
1647− /** Continue a partially-generated assistant reply by sending a "continue" prompt
1648− * without reverting (unlike [regenerate], which re-runs from the preceding user
1649− * prompt and loses the partial). The partial reply stays as context so the model
1650− * can pick up where it left off. This is the standard chat-app "continue" pattern
1651− * for a reply that was aborted mid-stream. */
1652− fun continueRun(@Suppress("UNUSED_PARAMETER") assistantMessageId: String) {
1653− val conn = connection ?: return
1654− if (running.value || aborting.value) return
1655− if (!beginRun()) return
1656− // Reopen the relight gate so this run's streamed parts keep the indicator lit (and a
1657− // later SessionIdle re-closes it). Without this, runEndedByIdle stays true from the
1658− // previous run's SessionIdle and the relight guard at the event collector would suppress
1659− // every streamed part of the continuation, leaving the working indicator / Stop button /
1660− // foreground service off for the whole run.
1661− runEndedByIdle = false
1662− // Track the synthetic "continue" prompt so an undoable Stop re-sends it (not the prior
1663− // real prompt), matching send()'s lastSentPrompt contract. Without this a Stop-Undo mid-
1664− // continuation would silently re-send the previous user prompt instead of "continue".
1665− lastSentPrompt = "continue"
1666− viewModelScope.launch {
1667− // Route through the repository (not conn.api directly) so the prompt inherits
1668− // withRetry (transient-failure backoff) and the user's selected model/agent — a
1669− // direct api.sendPrompt would neither retry nor carry the picker selection, and
1670− // would mint a fresh idempotency key on every retry attempt instead of sharing one.
1671− runCatchingCancellable {
1672− conn.repository.sendPrompt(
1673− sessionId,
1674− text = "continue",
1675− model = _selectedModel.value?.ref,
1676− agent = _selectedAgent.value,
1677− )
1678− }.onFailure {
1679− _running.value = false
1680− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1681− }
1682− }
1683− }
1684−
1685− /** Load a previously-sent user prompt back into the composer to edit and re-send.
1686− * Reverts the conversation to just before [messageId] (hiding it and everything after)
1687− * and prefills the draft with [text]. The next Send continues from that checkpoint,
1688− * effectively replacing the edited message; the reverted banner's Undo restores it.
1689− * Overwrites the current draft — this is an explicit edit action on that message. */
1690− fun editMessage(messageId: String, text: String) {
1691− val conn = connection ?: return
1692− _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1693− _pendingQuote.value = null
1694− // Do NOT delegate to revertTo(): revertTo's onSuccess sets _editing = false, which races
1695− // this synchronous _editing = true (the async revert completes after editMessage returns
1696− // and clobbers the banner). Inline the revert so editMessage owns the _editing lifecycle:
1697− // set it true on success, matching the documented contract ("Cleared on unrevert and on
1698− // send"). revertTo() (the manual revert button) still clears _editing itself.
1699− viewModelScope.launch {
1700− runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1701− .onSuccess {
1702− _reverted.value = it.isReverted
1703− _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1704− _editing.value = true
1705− }
1706− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1707− }
1708− }
1709−
1710− /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1711− * specific message inline. Appends to any existing draft (with a blank line) rather than
1712− * overwriting, so quoting doesn't discard text already being typed. Also stashes the raw
1713− * quoted text in [pendingQuote] so the composer can show a dismissible preview card. */
1714− fun quoteReply(text: String) {
1715− val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1716− if (quoted.isBlank()) return
1717− val current = _draft.value
1718− val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1719− // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1720− // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1721− _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1722− _pendingQuote.value = text.trim().takeIf { it.isNotBlank() }
1723− }
1724−
1725− /** Remove the pending quote-reply preview and strip the `>`-prefixed blockquote lines from
1726− * the draft so canceling the quote card actually removes the quoted text, not just the chip.
1727− * Strips by leading `> ` prefix (structural match) rather than by exact full-text replace,
1728− * so a user who edited any quoted line is still cleaned up — the exact-text replaces would
1729− * silently no-op on an edit, leaving orphan `>` lines in the draft after the chip vanished. */
1730− fun cancelQuoteReply() {
1731− val removed = _pendingQuote.value ?: return
1732− _pendingQuote.value = null
1733− val quoteLineCount = removed.trim().lineSequence().count()
1734− // Walk the draft and drop the first contiguous run of `> `-prefixed lines that matches
1735− // the stashed quote's line count (the block quoteReply inserted). Lines the user edited
1736− // still begin with `> ` (quoteReply prefixed every line), so a prefix match finds them
1737− // even when their trailing content diverges from the stashed original.
1738− val lines = _draft.value.split("\n").toMutableList()
1739− var runStart = -1
1740− var runLen = 0
1741− var i = 0
1742− while (i < lines.size) {
1743− if (lines[i].startsWith("> ") || lines[i] == ">") {
1744− if (runStart < 0) runStart = i
1745− runLen++
1746− if (runLen == quoteLineCount) {
1747− // Strip the run plus a single trailing blank line that separated the quote
1748− // from the reply (quoteReply appends "\n\n" after the block).
1749− val stripUntil = i + 1
1750− if (stripUntil < lines.size && lines[stripUntil].isEmpty()) {
1751− lines.subList(runStart, stripUntil + 1).clear()
1752− } else {
1753− lines.subList(runStart, stripUntil).clear()
1754− }
1755− break
1756− }
1757− i++
1758− } else {
1759− runStart = -1
1760− runLen = 0
1761− i++
1762− }
1763− }
1764− _draft.value = lines.joinToString("\n").trimStart()
1765− }
1766−
1767− /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1768− * prompt (using the currently-selected model/agent). Emits the new session id via
1769− * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1770− * so this starts a fresh session from the chosen prompt rather than copying history. */
1771− fun branchFrom(text: String) {
1772− val conn = connection ?: return
1773− val prompt = text.trim()
1774− if (prompt.isEmpty()) return
1775− viewModelScope.launch {
1776− var createdId: String? = null
1777− runCatchingCancellable {
1778− val session = conn.repository.createSession(title = null)
1779− createdId = session.id
1780− conn.repository.sendPrompt(
1781− session.id,
1782− prompt,
1783− model = _selectedModel.value?.ref,
1784− agent = _selectedAgent.value,
1785− )
1786− session.id
1787− }
1788− .onSuccess { newId -> _branchEvents.trySend(newId) }
1789− .onFailure {
1790− // If the session was created but the prompt failed, delete the orphaned
1791− // empty session so failed branches don't accumulate on the server.
1792− val id = createdId
1793− if (id != null) {
1794− runCatchingCancellable { conn.repository.deleteSession(id) }
1795− }
1796− _errorEvents.trySend(ChatError(container.string(R.string.branch_failed)))
1797− }
1798− }
1799− }
1800−
1801− /** Undo the active revert checkpoint, restoring the hidden messages. */
1802− fun unrevert() {
1803− val conn = connection ?: return
1804− viewModelScope.launch {
1805− runCatchingCancellable { conn.api.unrevert(sessionId) }
1806− .onSuccess {
1807− _reverted.value = it.isReverted
1808− _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1809− _editing.value = false
1810− }
1811− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1812− }
1813− }
1814−
1815− /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1816− * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1817− fun shareSession() {
1818− val conn = connection ?: return
1819− viewModelScope.launch {
1820− runCatchingCancellable { conn.api.shareSession(sessionId) }
1821− .onSuccess { session ->
1822− val url = session.share?.url?.takeIf { it.isNotBlank() }
1823− _shareUrl.value = url
1824− if (url != null) _shareLinkEvents.trySend(url)
1825− }
1826− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1827− }
1828− }
1829−
1830− /** Revoke the session's public share link. */
1831− fun unshareSession() {
1832− val conn = connection ?: return
1833− viewModelScope.launch {
1834− runCatchingCancellable { conn.api.unshareSession(sessionId) }
1835− .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1836− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1837− }
1838− }
1839−
1840− /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1841− * Uses the currently-selected model (required by the endpoint). */
1842− fun summarize() {
1843− val conn = connection ?: return
1844− val model = _selectedModel.value?.ref ?: run {
1845− _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1846− return
1847− }
1848− if (!beginRun()) {
1849− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1850− return
1851− }
1852− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1853− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1854− runEndedByIdle = false
1855− lastSentPrompt = null
1856− viewModelScope.launch {
1857− runCatchingCancellable { conn.api.summarize(sessionId, model) }
1858− .onFailure {
1859− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1860− _running.value = false
1861− }
1862− }
1863− }
1864−
1865− /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1866− fun initProject() {
1867− val conn = connection ?: return
1868− if (!beginRun()) {
1869− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1870− return
1871− }
1872− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1873− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1874− runEndedByIdle = false
1875− lastSentPrompt = null
1876− viewModelScope.launch {
1877− runCatchingCancellable { conn.api.initSession(sessionId) }
1878− .onFailure {
1879− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1880− _running.value = false
1881− }
1882− }
1883− }
1884−
1885− /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1886− * The server requires an agent to scope the run — use the selected one, else the primary. */
1887− fun runShell(command: String) {
1888− val conn = connection ?: return
1889− val cmd = command.trim()
1890− if (cmd.isEmpty()) return
1891− val agent = _selectedAgent.value
1892− ?: _agents.value.firstOrNull { it.isPrimary }?.name
1893− ?: _agents.value.firstOrNull()?.name
1894− ?: DEFAULT_SHELL_AGENT
1895− if (!beginRun()) {
1896− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1897− return
1898− }
1899− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1900− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1901− runEndedByIdle = false
1902− lastSentPrompt = null
1903− viewModelScope.launch {
1904− runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1905− .onFailure {
1906− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1907− _running.value = false
1908− }
1909− }
1910− }
1911−
1912− /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1913− * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1914− * the caller keeps the dialog open so the user can retry without retyping. */
1915− fun renameSession(newTitle: String) {
1916− val conn = connection ?: return
1917− val title = newTitle.trim()
1918− if (title.isEmpty() || title == _sessionTitle.value) return
1919− viewModelScope.launch {
1920− runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1921− .onSuccess { _sessionTitle.value = it.displayTitle }
1922− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1923− }
1924− }
1925−
1926− /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1927− * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1928− * *over the conversation the user is viewing* — they can cancel without first being
1929− * navigated back to the session list. The actual REST delete runs after
1930− * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1931− * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1932− * A failure surfaces as a snackbar. */
1933− fun deleteSession() {
1934− val conn = connection ?: return
1935− container.scheduleSessionDelete(
1936− id = sessionId,
1937− delayMs = NetworkConfig.undoDeleteDelayMs,
1938− // Navigate away only once the deferred delete actually commits — so an in-place Undo
1939− // before the window expires keeps the user on this conversation.
1940− onDeleted = { _sessionDeleted.value = true },
1941− onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1942− )
1943− _deleteUndoEvents.tryEmit(sessionId)
1944− }
1945−
1946− /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1947− * delete was still pending (Undo succeeded). */
1948− fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1949−
1950− /** Cancel a delete scheduled for [id] — the `collectLatest` undo path in ChatScreen calls
1951− * this with the tracked `pending` id when a newer delete supersedes an in-flight one, so
1952− * the prior session's deferred REST delete is withdrawn (no-op if it already fired)
1953− * instead of being left to fire with no reachable Undo button. Mirrors
1954− * [soy.iko.opencode.ui.session.SessionListViewModel.undoDelete]. Returns false when [id]
1955− * isn't this VM's session (defensive — a ChatScreen only ever deletes its own session). */
1956− fun cancelSessionDelete(id: String): Boolean {
1957− if (id != sessionId) return false
1958− return container.cancelSessionDelete(id)
1959− }
1960−
1961− /** Reconnect to the most recently used server profile (used when the connection is gone). */
1962− fun reconnect() {
1963− if (container.activeConnection.value != null) return
1964− // Guard against concurrent reconnect calls: the user can tap the button
1965− // multiple times while _reconnecting is true, which would launch parallel
1966− // connect() coroutines that race to set the active connection, leaking the
1967− // intermediate connections.
1968− if (!_reconnecting.compareAndSet(false, true)) return
1969− viewModelScope.launch {
1970− try {
1971− val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1972− .getOrDefault(emptyList())
1973− .firstOrNull()
1974− if (recent == null) {
1975− _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1976− return@launch
1977− }
1978− runCatchingCancellable {
1979− val conn = container.connect(recent)
1980− conn.api.ping()
1981− }.onFailure {
1982− container.disconnect()
1983− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1984− }
1985− } finally {
1986− _reconnecting.value = false
1987− }
1988− }
1989− }
1990−
1991− fun respondPermission(permission: Permission, response: PermissionResponse) {
1992− val conn = connection ?: return
1993− if (!respondedPermissionIds.add(permission.id)) return
1994− // Record a session-scoped grant so subsequent matching requests are auto-answered.
1995− // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1996− // client-side set is what gives it the "for this session" semantics.
1997− if (response == PermissionResponse.SESSION) {
1998− val type = permission.type.orEmpty()
1999− val pattern = permission.patternText.orEmpty()
2000− if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
2001− }
2002− // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
2003− // If the call fails we re-surface this request so the user can retry instead of being stuck
2004− // with a dismissed dialog and a tool run that's still paused server-side.
2005− resolvePermission(permission.id)
2006− viewModelScope.launch {
2007− runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
2008− .onFailure {
2009− _errorEvents.trySend(ChatError(container.friendlyError(it)))
2010− // Revoke the session-scoped grant on failure: enqueuePermission() auto-responds
2011− // when (type, pattern) is in sessionAllowed, so leaving the entry in would loop
2012− // respondPermission -> fail -> enqueuePermission -> respondPermission ... forever,
2013− // spamming the dead server and the error snackbar without ever re-prompting the
2014− // user. Removing it makes enqueuePermission fall through to re-queueing the
2015− // request for the dialog so the user can retry.
2016− //
2017− // This must cover BOTH the SESSION and ONCE response paths: enqueuePermission's
2018− // auto-respond sends ONCE, not SESSION, so guarding only the SESSION branch (as
2019− // the original code did) leaves the entry in and still loops when the auto-reply
2020− // fails. Revoke unconditionally on any failure.
2021− val type = permission.type.orEmpty()
2022− val pattern = permission.patternText.orEmpty()
2023− if (type.isNotEmpty()) sessionAllowed.remove(type to pattern)
2024− respondedPermissionIds.remove(permission.id)
2025− enqueuePermission(permission)
2026− }
2027− }
2028− }
2029−
2030− private companion object {
2031− /** Fallback agent for the shell endpoint when none is selected and no agent catalog
2032− * loaded. opencode's default primary agent is "build". */
2033− const val DEFAULT_SHELL_AGENT = "build"
2034− }
2035−
2036− override fun onCleared() {
2037− super.onCleared()
2038− // Flush any pending debounced draft so it survives navigation. Uses an
2039− // asynchronous apply() (not a synchronous commit) so the main thread isn't
2040− // blocked on disk I/O — Android's SharedPreferences framework guarantees
2041− // pending apply() writes are flushed before the process exits.
2042− // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
2043− // so if the user cleared the input and navigated back within the debounce window, the
2044− // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
2045− // previous non-empty draft. flushDraft() removes the key when the text is blank, so
2046− // flushing commits the clear instead of resurrecting the deleted text.
2047− //
2048− // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
2049− // persisting the clear so a failed send can restore the text. If the user taps Send then
2050− // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
2051− // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
2052− // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
2053− if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)
2054− }
2055−}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktdeleted−248
@@ -1,248 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.imePadding
9−import androidx.compose.foundation.layout.navigationBarsPadding
10−import androidx.compose.foundation.layout.padding
11−import androidx.compose.foundation.lazy.LazyColumn
12−import androidx.compose.foundation.lazy.items
13−import androidx.compose.foundation.text.KeyboardActions
14−import androidx.compose.foundation.text.KeyboardOptions
15−import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
17−import androidx.compose.material.icons.filled.Close
18−import androidx.compose.material.icons.filled.Search
19−import androidx.compose.material3.CircularProgressIndicator
20−import androidx.compose.material3.ExperimentalMaterial3Api
21−import androidx.compose.material3.Icon
22−import androidx.compose.material3.IconButton
23−import androidx.compose.material3.MaterialTheme
24−import androidx.compose.material3.ModalBottomSheet
25−import androidx.compose.material3.OutlinedTextField
26−import androidx.compose.material3.Text
27−import androidx.compose.material3.AlertDialog
28−import androidx.compose.material3.TextButton
29−import androidx.compose.material3.rememberModalBottomSheetState
30−import androidx.compose.runtime.Composable
31−import androidx.compose.runtime.LaunchedEffect
32−import androidx.compose.runtime.getValue
33−import androidx.compose.runtime.mutableStateOf
34−import androidx.compose.runtime.remember
35−import androidx.compose.runtime.rememberCoroutineScope
36−import androidx.compose.runtime.saveable.rememberSaveable
37−import androidx.compose.runtime.setValue
38−import androidx.compose.ui.Alignment
39−import androidx.compose.ui.Modifier
40−import androidx.compose.ui.focus.FocusRequester
41−import androidx.compose.ui.focus.focusRequester
42−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
43−import androidx.compose.ui.platform.LocalHapticFeedback
44−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
45−import androidx.compose.ui.res.stringResource
46−import androidx.compose.ui.semantics.Role
47−import androidx.compose.ui.semantics.contentDescription
48−import androidx.compose.ui.semantics.semantics
49−import androidx.compose.ui.semantics.stateDescription
50−import androidx.compose.ui.text.input.ImeAction
51−import androidx.compose.ui.text.style.TextOverflow
52−import androidx.compose.ui.unit.dp
53−import androidx.compose.foundation.layout.Box
54−import kotlinx.coroutines.launch
55−import soy.iko.opencode.data.model.Command
56−import soy.iko.opencode.data.network.NetworkConfig
57−import soy.iko.opencode.R
58−
59−/** Bottom sheet that lists slash-commands; selecting one invokes it. */
60−@OptIn(ExperimentalMaterial3Api::class)
61−@Composable
62−fun CommandPickerSheet(
63− commands: List<Command>,
64− loading: Boolean,
65− error: Boolean,
66− onSelect: (Command) -> Unit,
67− onRetry: () -> Unit,
68− onDismiss: () -> Unit,
69−) {
70− // Persist only the command *name* (a savable String), not the Command itself: a
71− // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
72− // routing it through rememberSaveable crashes on rotation while the confirm dialog
73− // is open. Re-resolve the full Command from the current list for rendering.
74− var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
75− val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
76− // Hoist SheetState + skipPartiallyExpanded (see AgentPickerSheet for the full rationale).
77− val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
78− val scope = rememberCoroutineScope()
79− ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
80− Text(
81− stringResource(R.string.commands),
82− style = MaterialTheme.typography.titleMedium,
83− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
84− )
85− if (loading) {
86− val loadingLabel = stringResource(R.string.loading_commands)
87− Column(
88− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).imePadding(),
89− horizontalAlignment = Alignment.CenterHorizontally,
90− ) {
91− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
92− Text(
93− stringResource(R.string.loading_commands),
94− style = MaterialTheme.typography.bodyMedium,
95− color = MaterialTheme.colorScheme.onSurfaceVariant,
96− modifier = Modifier.padding(top = 8.dp),
97− )
98− }
99− } else if (error && commands.isEmpty()) {
100− Column(
101− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
102− horizontalAlignment = Alignment.CenterHorizontally,
103− ) {
104− Text(
105− stringResource(R.string.load_failed),
106− style = MaterialTheme.typography.bodyMedium,
107− color = MaterialTheme.colorScheme.error,
108− )
109− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
110− }
111− } else if (commands.isEmpty()) {
112− Text(
113− stringResource(R.string.no_commands),
114− style = MaterialTheme.typography.bodyMedium,
115− color = MaterialTheme.colorScheme.onSurfaceVariant,
116− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
117− )
118− } else {
119− var query by rememberSaveable { mutableStateOf("") }
120− val keyboardController = LocalSoftwareKeyboardController.current
121− val haptics = LocalHapticFeedback.current
122− // Resolved once (not per-row): the semantics lambda below isn't a composable scope,
123− // so stringResource can't be called inside it.
124− val destructiveStateLabel = stringResource(R.string.command_confirmation_required)
125− // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
126− // it for a short list that's faster to scan by eye than to filter.
127− val searchFocus = remember { FocusRequester() }
128− LaunchedEffect(Unit) {
129− if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) {
130− runCatching { searchFocus.requestFocus() }
131− }
132− }
133− OutlinedTextField(
134− value = query,
135− onValueChange = { query = it },
136− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
137− placeholder = { Text(stringResource(R.string.search_commands)) },
138− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
139− trailingIcon = if (query.isNotEmpty()) {
140− {
141− IconButton(onClick = { query = "" }) {
142− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
143− }
144− }
145− } else null,
146− singleLine = true,
147− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
148− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
149− )
150− // Dedupe the source list once, then filter the distinct list.
151− val distinct = remember(commands) { commands.distinctBy { it.name } }
152− val filtered = remember(distinct, query) {
153− val q = query.trim()
154− if (q.isEmpty()) distinct
155− else distinct.filter {
156− it.name.contains(q, ignoreCase = true) ||
157− it.displayDescription.contains(q, ignoreCase = true)
158− }
159− }
160− if (filtered.isEmpty()) {
161− Text(
162− stringResource(R.string.no_commands_match, query.trim()),
163− style = MaterialTheme.typography.bodyMedium,
164− color = MaterialTheme.colorScheme.onSurfaceVariant,
165− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
166− )
167− } else {
168− LazyColumn(
169− modifier = Modifier
170− .heightIn(max = NetworkConfig.pickerSheetMaxHeightDp.dp)
171− .imePadding()
172− .navigationBarsPadding(),
173− ) {
174− items(filtered, key = { it.name }) { cmd ->
175− val destructive = isDestructiveCommand(cmd.name)
176− Column(
177− modifier = Modifier
178− .fillMaxWidth()
179− .clickable(role = Role.Button) {
180− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
181− if (destructive) {
182− pendingCommandName = cmd.name
183− } else {
184− scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
185− onSelect(cmd)
186− }
187− }
188− .semantics {
189− if (destructive) stateDescription = destructiveStateLabel
190− }
191− .padding(horizontal = 24.dp, vertical = 12.dp),
192− ) {
193− Row(
194− verticalAlignment = Alignment.CenterVertically,
195− ) {
196− Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
197− Text(
198− "/${cmd.name}",
199− style = MaterialTheme.typography.bodyLarge,
200− modifier = Modifier.padding(start = 8.dp),
201− )
202− }
203− Text(
204− cmd.displayDescription,
205− style = MaterialTheme.typography.bodySmall,
206− color = MaterialTheme.colorScheme.onSurfaceVariant,
207− maxLines = 2,
208− overflow = TextOverflow.Ellipsis,
209− modifier = Modifier.padding(start = 28.dp, top = 2.dp),
210− )
211− }
212− }
213− }
214− }
215− }
216− }
217−
218− // Confirmation for commands whose name suggests they may discard work or reset state,
219− // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
220− pendingCommand?.let { cmd ->
221− AlertDialog(
222− onDismissRequest = { pendingCommandName = null },
223− title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
224− text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
225− confirmButton = {
226− TextButton(onClick = {
227− pendingCommandName = null
228− scope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() }
229− onSelect(cmd)
230− }) { Text(stringResource(R.string.run)) }
231− },
232− dismissButton = {
233− TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
234− },
235− )
236− }
237−}
238−
239−/**
240− * Heuristic for commands that may be destructive (discard work / reset state) and so
241− * warrant a confirmation step before invocation. Matches common opencode slash-command
242− * names; non-matching commands invoke immediately.
243− */
244−private fun isDestructiveCommand(name: String): Boolean {
245− val n = name.lowercase()
246− return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
247− n.startsWith("clear-") || n.startsWith("compact-") || n.startsWith("reset-")
248−}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktdeleted−236
@@ -1,236 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.FilePart
5−import soy.iko.opencode.data.model.MessageWithParts
6−import soy.iko.opencode.data.model.ReasoningPart
7−import soy.iko.opencode.data.model.TextPart
8−import soy.iko.opencode.data.model.ToolCompleted
9−import soy.iko.opencode.data.model.ToolError
10−import soy.iko.opencode.data.model.ToolPart
11−import soy.iko.opencode.data.model.ToolRunning
12−import soy.iko.opencode.data.model.UserMessage
13−import soy.iko.opencode.data.model.sourcePath
14−import soy.iko.opencode.data.network.NetworkConfig
15−import soy.iko.opencode.data.network.OpencodeJson
16−import kotlinx.serialization.builtins.ListSerializer
17−import java.text.SimpleDateFormat
18−import java.util.Date
19−import java.util.Locale
20−
21−/**
22− * Render a conversation as a lossless JSON document for sharing/exporting. Unlike
23− * [buildConversationMarkdown], this preserves the full fidelity of the server's
24− * message stream: tool outputs are not truncated, file parts keep their source
25− * objects and data URLs, reasoning keeps its raw text, and the polymorphic
26− * `type`/`role` discriminators are retained. That makes the JSON export suitable
27− * for re-importing into another client, for auditing exactly what the agent did,
28− * and for bug reports where the Markdown summary would have dropped detail.
29− *
30− * Uses the shared [OpencodeJson] so the on-the-wire shape round-trips — the same
31− * serializer that decoded the messages re-encodes them, including the `Unknown*`
32− * fallback variants for parts/roles the client doesn't otherwise model.
33− */
34−fun buildConversationJson(messages: List<MessageWithParts>): String =
35− try {
36− // Explicit serializer (mirrors MessageCacheStore) rather than the reified
37− // encodeToString overload, so the polymorphic Part/MessageInfo discriminators
38− // registered in OpencodeJson are exercised on the way back out.
39− OpencodeJson.encodeToString(ListSerializer(MessageWithParts.serializer()), messages)
40− } catch (e: Throwable) {
41− // Fall back to an explicit error document rather than crashing the share
42− // flow; a serialization failure here is most likely a model invariant bug
43− // and a silent empty string would be harder to diagnose than this stub.
44− "{\"error\":\"conversation_json_export_failed\",\"message\":\"${e.message ?: e.javaClass.simpleName}\"}"
45− }
46−
47−/**
48− * Render a conversation as Markdown for sharing/exporting. Each message becomes a
49− * role-prefixed section containing its text parts; reasoning is exported as a blockquote
50− * and tool calls are summarized so the transcript shows what the agent *did*, not just
51− * what it said. Pure (no Android deps) so it can be unit-tested directly.
52− */
53−fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
54− val sb = StringBuilder(messages.size * 128)
55− if (!title.isNullOrBlank()) {
56− sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
57− }
58− appendMetadataHeader(sb, messages)
59− for (message in messages) {
60− val heading = when (message.info) {
61− is UserMessage -> "## You"
62− is AssistantMessage -> "## opencode"
63− else -> null
64− } ?: continue
65− // The assistant's reply text is already Markdown (code fences, bold, headings);
66− // escaping it would render the export as backslash noise. Only escape user-authored
67− // text, which isn't expected to be Markdown.
68− val escapeText = message.info is UserMessage
69− val body = message.parts
70− .mapNotNull { part ->
71− when (part) {
72− is TextPart -> part.text.takeIf { it.isNotBlank() }
73− ?.let { if (escapeText) escapeMarkdown(it) else it }
74− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
75− ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
76− is ToolPart -> formatToolCall(part)
77− is FilePart -> {
78− // Image/file attachments aren't portable in a text export; emit a
79− // labeled placeholder so the transcript records that one was present
80− // (useful for debugging) instead of silently dropping it.
81− val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
82− if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
83− else "_[file: ${escapeMarkdown(name)}]_"
84− }
85− else -> null
86− }
87− }
88− .joinToString("\n\n")
89− .trim()
90− if (body.isEmpty()) continue
91− sb.append(heading).append("\n\n")
92− // Per-message timestamp (ISO, local zone) so an exported transcript is useful for
93− // support/debugging — without it only the metadata header's global range has timing.
94− message.info.time?.created?.let { ts -> sb.append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
95− sb.append(body).append("\n\n")
96− }
97− return sb.toString().trimEnd()
98−}
99−
100−/**
101− * Render a single message as Markdown for per-message sharing. A trimmed-down version of
102− * [buildConversationMarkdown] that emits just this message's role heading + body (no metadata
103− * header), so a shared single reply/exchange is self-contained without dragging the whole
104− * transcript. Returns null when the message has no exportable body (e.g. an image-only prompt).
105− */
106−fun buildMessageMarkdown(message: MessageWithParts): String? {
107− val heading = when (message.info) {
108− is UserMessage -> "## You"
109− is AssistantMessage -> "## opencode"
110− else -> return null
111− }
112− val escapeText = message.info is UserMessage
113− val body = message.parts
114− .mapNotNull { part ->
115− when (part) {
116− is TextPart -> part.text.takeIf { it.isNotBlank() }
117− ?.let { if (escapeText) escapeMarkdown(it) else it }
118− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
119− ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
120− is ToolPart -> formatToolCall(part)
121− is FilePart -> {
122− val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
123− if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
124− else "_[file: ${escapeMarkdown(name)}]_"
125− }
126− else -> null
127− }
128− }
129− .joinToString("\n\n")
130− .trim()
131− if (body.isEmpty()) return null
132− return buildString {
133− append(heading).append("\n\n")
134− message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
135− append(body)
136− }
137−}
138−
139−/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
140− * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
141−private fun formatExportTimestamp(epochMillis: Long): String =
142− SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(epochMillis))
143−
144−/** Append a metadata header (message counts + timestamp range) so a shared transcript has
145− * context for support/debugging. Skipped when there are no messages. */
146−private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
147− if (messages.isEmpty()) return
148− val userCount = messages.count { it.info is UserMessage }
149− val assistantCount = messages.count { it.info is AssistantMessage }
150− val firstTime = messages.firstOrNull()?.info?.time?.created
151− val lastTime = messages.lastOrNull()?.info?.time?.created
152− sb.append("> ")
153− // Pluralize each noun independently (a prior bug keyed both on userCount, so "user" was
154− // never pluralized and "message" was pluralized based on the wrong count).
155− sb.append("$userCount user")
156− if (userCount != 1) sb.append("s")
157− sb.append(" · $assistantCount assistant message")
158− if (assistantCount != 1) sb.append("s")
159− if (firstTime != null && lastTime != null) {
160− sb.append(" · ${formatExportTimestamp(firstTime)} → ${formatExportTimestamp(lastTime)}")
161− }
162− sb.append("\n\n---\n\n")
163−}
164−
165−/** Render a tool call as a compact, readable blockquote summary: the tool name, its
166− * human-readable title (if any), and a truncated output/error. Keeps the exported
167− * transcript useful when the agent's work (edits, command runs) is the substance. */
168−private fun formatToolCall(part: ToolPart): String? {
169− val title = when (val s = part.state) {
170− is ToolRunning -> s.title
171− is ToolCompleted -> s.title
172− else -> null
173− }
174− val detail = when (val s = part.state) {
175− is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
176− is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
177− else -> null
178− }
179− val status = when (part.state) {
180− is ToolCompleted -> null
181− is ToolError -> " — error"
182− is ToolRunning -> " — running"
183− else -> null
184− }
185− val head = "**${part.tool}**${status ?: ""}"
186− val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
187− return buildList {
188− add("> $head")
189− if (titleLine != null) add("> $titleLine")
190− // Prefix every detail line with "> " so the fenced code block stays inside the
191− // blockquote — otherwise lines after the first fall out of the quote.
192− if (detail != null) {
193− val quoted = detail.lines().joinToString("\n") { "> $it" }
194− // Use a fence longer than the longest backtick run in the content, so tool output
195− // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
196− // prematurely close the block and make the rest of the transcript render broken.
197− val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
198− add(">\n> $fence\n$quoted\n> $fence")
199− }
200− }.joinToString("\n").takeIf { it.isNotBlank() }
201−}
202−
203−/** Length of the longest consecutive run of backticks in [text] (0 if none). */
204−private fun longestBacktickRun(text: String): Int {
205− var max = 0
206− var current = 0
207− for (c in text) {
208− if (c == '`') {
209− current++
210− if (current > max) max = current
211− } else {
212− current = 0
213− }
214− }
215− return max
216−}
217−
218−private fun truncateOutput(output: String): String {
219− if (output.length <= NetworkConfig.exportToolOutputLimitChars) return output
220− return output.take(NetworkConfig.exportToolOutputLimitChars) + "\n… (truncated)"
221−}
222−
223−/** Escape markdown special characters so user/model text doesn't produce malformed markdown.
224− * Extends the basic set with [ ] ( ) - + ! so user text containing links, list markers, or
225− * image syntax doesn't render as live Markdown in the exported transcript. */
226−private fun escapeMarkdown(text: String): String {
227− if (text.isEmpty()) return text
228− val sb = StringBuilder(text.length + 16)
229− for (c in text) {
230− when (c) {
231− '\\', '*', '_', '#', '`', '>', '[', ']', '(', ')', '!', '+', '-' -> sb.append('\\')
232− }
233− sb.append(c)
234− }
235− return sb.toString()
236−}
app/src/main/java/soy/iko/opencode/ui/chat/CostFormat.ktdeleted−60
@@ -1,60 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import soy.iko.opencode.data.model.Tokens
4−
5−// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
6−// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
7−// when the message list re-seeds after a reconnect and every visible assistant bubble
8−// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
9−// not thread-safe.
10−internal val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
11− java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
12−}
13−
14−internal fun formatTokens(tokens: Tokens, format: String): String {
15− val nf = tokenNumberFormat.get()!!
16− return format.format(nf.format(tokens.input), nf.format(tokens.output))
17−}
18−
19−internal fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
20− // Locale.US so the formatting is stable regardless of device locale (avoids
21− // non-ASCII digits or comma decimal separators in a dollar amount). The format
22− // string itself is localized via strings.xml so the currency symbol can be
23− // adapted by translators.
24− if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
25− else String.format(java.util.Locale.US, shortFormat, cost)
26−
27−/** Assemble the single-line cost/tokens summary for an assistant bubble. Returns null when the
28− * message isn't complete, when neither cost nor tokens were reported, or when every figure is
29− * zero (so the bubble doesn't show a meaningless "0 in · 0 out • $0.0000"). Extracted from
30− * [AssistantBlock] to keep that function's cyclomatic complexity under the detekt threshold.
31− * Reasoning and cache segments are added only when non-zero. */
32−internal fun buildCostSummary(
33− isComplete: Boolean,
34− tokens: Tokens?,
35− cost: Double?,
36− tokenFormat: String,
37− reasoningFormat: String,
38− cacheReadFormat: String,
39− cacheWriteFormat: String,
40− costShort: String,
41− costLong: String,
42−): String? {
43− if (!isComplete || (cost == null && tokens == null)) return null
44− val nf = tokenNumberFormat.get()!!
45− return buildList {
46− // Skip an all-zero token count (e.g. a completed message that reported no usage) so
47− // the bubble doesn't show a meaningless "0 in · 0 out".
48− tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
49− tokens?.takeIf { it.reasoning > 0 }?.let {
50− add(reasoningFormat.format(nf.format(it.reasoning)))
51− }
52− tokens?.cache?.takeIf { it.read > 0 }?.let {
53− add(cacheReadFormat.format(nf.format(it.read)))
54− }
55− tokens?.cache?.takeIf { it.write > 0 }?.let {
56− add(cacheWriteFormat.format(nf.format(it.write)))
57− }
58− cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
59− }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
60−}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktdeleted−1428

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktdeleted−330

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktdeleted−811

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktdeleted−331

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.ktdeleted−205

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/TtsController.ktdeleted−278

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/ViewSourceDialog.ktdeleted−76

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.ktdeleted−82

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/Autofill.ktdeleted−49

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.ktdeleted−54

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktdeleted−632

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.ktdeleted−171

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CommonStates.ktdeleted−170

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CompactNavBar.ktdeleted−106

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktdeleted−266

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktdeleted−667

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/EmptyState.ktdeleted−77

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/GatedHapticFeedback.ktdeleted−33

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.ktdeleted−109

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/LoadingSize.ktdeleted−12

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/LoadingSpinner.ktdeleted−40

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktdeleted−921

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.ktdeleted−11

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.ktdeleted−131

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/RelativeTimeUnit.ktdeleted−56

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktdeleted−617

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/SectionHeader.ktdeleted−25

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/SkeletonRow.ktdeleted−97

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktdeleted−194

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktdeleted−1046

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktdeleted−281

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktdeleted−91

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktdeleted−1323

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.ktdeleted−117

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.ktdeleted−521

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.ktdeleted−76

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.ktdeleted−173

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktnew file+212

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktnew file+109

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktnew file+101

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.ktdeleted−430

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.ktdeleted−369

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktdeleted−353

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktdeleted−355

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktdeleted−710

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktdeleted−270

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.ktdeleted−428

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsViewModel.ktdeleted−226

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.ktdeleted−315

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktdeleted−2075

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktdeleted−1124

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktdeleted−292

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktdeleted−690

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktdeleted−1660

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktdeleted−19

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+27−186

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktdeleted−136

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.ktdeleted−152

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.ktdeleted−435

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.ktdeleted−173

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Contexts.ktdeleted−15

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Coroutines.ktdeleted−56

This diff is too big to show here.

app/src/main/res/drawable/ic_action_block.xmldeleted−10

This diff is too big to show here.

app/src/main/res/drawable/ic_action_check.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_refresh.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_reply.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_stop.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_launcher_foreground.xmldeleted−13

This diff is too big to show here.

app/src/main/res/drawable/ic_stat_notify.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/widget_background.xmldeleted−8

This diff is too big to show here.

app/src/main/res/layout/widget_session_item.xmldeleted−21

This diff is too big to show here.

app/src/main/res/layout/widget_sessions.xmldeleted−64

This diff is too big to show here.

app/src/main/res/mipmap-anydpi/ic_launcher.xmldeleted−6

This diff is too big to show here.

app/src/main/res/values-es/strings.xmldeleted−902

This diff is too big to show here.

app/src/main/res/values-es/strings_cross_ux.xmldeleted−7

This diff is too big to show here.

app/src/main/res/values-es/strings_diag_ux.xmldeleted−4

This diff is too big to show here.

app/src/main/res/values-es/strings_notif_ux.xmldeleted−5

This diff is too big to show here.

app/src/main/res/values-es/strings_settings_ux.xmldeleted−12

This diff is too big to show here.

app/src/main/res/values-night/colors.xmldeleted−18

This diff is too big to show here.

app/src/main/res/values-night/themes.xml+1−5

This diff is too big to show here.

app/src/main/res/values/colors.xmldeleted−22

This diff is too big to show here.

app/src/main/res/values/strings.xml+1−927

This diff is too big to show here.

app/src/main/res/values/strings_cross_ux.xmldeleted−7

This diff is too big to show here.

app/src/main/res/values/strings_diag_ux.xmldeleted−4

This diff is too big to show here.

app/src/main/res/values/strings_notif_ux.xmldeleted−8

This diff is too big to show here.

app/src/main/res/values/strings_settings_ux.xmldeleted−12

This diff is too big to show here.

app/src/main/res/values/themes.xml+1−19

This diff is too big to show here.

app/src/main/res/xml/data_extraction_rules.xmldeleted−15

This diff is too big to show here.

app/src/main/res/xml/file_paths.xmldeleted−12

This diff is too big to show here.

app/src/main/res/xml/network_security_config.xmldeleted−15

This diff is too big to show here.

app/src/main/res/xml/sessions_widget_info.xmldeleted−10

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M5DecodingTest.ktdeleted−144

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M6Test.ktdeleted−102

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktdeleted−122

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/UxTest.ktdeleted−104

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.ktnew file+136

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.ktdeleted−90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktdeleted−37

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktdeleted−42

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktdeleted−115

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktdeleted−254

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.ktdeleted−46

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktdeleted−60

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktdeleted−385

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.ktdeleted−44

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktdeleted−73

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktdeleted−56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.ktdeleted−650

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktdeleted−120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ToolDurationFormatTest.ktdeleted−45

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/CodeHighlighterLanguageTest.ktdeleted−66

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktdeleted−55

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.ktdeleted−56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.ktdeleted−28

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.ktdeleted−231

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.ktdeleted−70

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.ktdeleted−52

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.ktdeleted−120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.ktdeleted−39

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.ktdeleted−425

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerSettingsViewModelTest.ktdeleted−287

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.ktdeleted−649

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.ktdeleted−486

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.ktdeleted−67

This diff is too big to show here.

build.gradle.kts+3−1

This diff is too big to show here.

config/detekt/detekt.ymldeleted−68

This diff is too big to show here.

generated/build.gradle.ktsnew file+21

This diff is too big to show here.

generated/com/novi/bincode/BincodeDeserializer.ktnew file+39

This diff is too big to show here.

generated/com/novi/bincode/BincodeSerializer.ktnew file+33

This diff is too big to show here.

generated/com/novi/serde/BinaryDeserializer.ktnew file+182

This diff is too big to show here.

generated/com/novi/serde/BinarySerializer.ktnew file+122

This diff is too big to show here.

generated/com/novi/serde/Bytes.ktnew file+39

This diff is too big to show here.

generated/com/novi/serde/DeserializationError.ktnew file+6

This diff is too big to show here.

generated/com/novi/serde/Deserializer.ktnew file+76

This diff is too big to show here.

generated/com/novi/serde/Int128.ktnew file+6

This diff is too big to show here.

generated/com/novi/serde/SerdeByteArrayOutput.ktnew file+48

This diff is too big to show here.

generated/com/novi/serde/SerializationError.ktnew file+6

This diff is too big to show here.

generated/com/novi/serde/Serializer.ktnew file+77

This diff is too big to show here.

generated/com/novi/serde/Slice.ktnew file+36

This diff is too big to show here.

generated/com/novi/serde/Tuple4.ktnew file+6

This diff is too big to show here.

generated/com/novi/serde/Tuple5.ktnew file+12

This diff is too big to show here.

generated/com/novi/serde/Tuple6.ktnew file+13

This diff is too big to show here.

generated/com/novi/serde/UInt128.ktnew file+6

This diff is too big to show here.

generated/soy/iko/opencode/core/Core.ktnew file+1110

This diff is too big to show here.

gradle.properties+3−8

This diff is too big to show here.

gradle/libs.versions.toml+28−51

This diff is too big to show here.

gradle/wrapper/gradle-wrapper.jardeleted

Binary file not shown.

gradle/wrapper/gradle-wrapper.properties+1−1

This diff is too big to show here.

renovate.jsondeleted−32

This diff is too big to show here.

scripts/run-device.shdeleted−106

This diff is too big to show here.

scripts/run.shdeleted−95

This diff is too big to show here.

settings.gradle.kts+1

This diff is too big to show here.

shared/Cargo.locknew file+1707

This diff is too big to show here.

shared/Cargo.tomlnew file+31

This diff is too big to show here.

shared/build.gradle.ktsnew file+39

This diff is too big to show here.

shared/src/app.rsnew file+494

This diff is too big to show here.

shared/src/bin/codegen.rsnew file+40

This diff is too big to show here.

shared/src/ffi.rsnew file+53

This diff is too big to show here.

shared/src/lib.rsnew file+6

This diff is too big to show here.