git.iko.soy/opencode-android
on 2026.07.07.1815 help sign in
opencode-android/AGENTS.md
197 lines 11 kB history raw
1# AGENTS.md
3Guidance for AI coding agents (and humans) working in this repository.
5## Project
7opencode-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
9remote opencode server over REST plus a long-lived Server-Sent Events stream,
10and renders conversations (markdown, diffs, tool calls, reasoning, images) with
11an adaptive single-pane / two-pane Material 3 UI.
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.
18## Build & environment
20A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 37, and a
21Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
23```bash
24nix 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```
29Outside Nix, you need JDK 17 and the Android SDK (platform 37, build-tools
3037.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
32Install on a connected device/emulator:
34```bash
35adb install -r app/build/outputs/apk/debug/app-debug.apk
36adb shell am start -n soy.iko.opencode/.MainActivity
37```
39## Test, lint, and static analysis
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```
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.
59## Running a server to develop against
61```bash
62opencode serve --hostname 0.0.0.0 --port 4096
63```
65Connect 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.
70## Architecture
72Unidirectional data flow with manual DI, no annotation processing (no Hilt/KSP).
74```
75OpencodeApp (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)
85ViewModels (ui/<feature>/<Feature>ViewModel.kt)
86 └─ expose StateFlow; Compose collects with collectAsStateWithLifecycle
87 └─ built via ui/VmFactory.kt (viewModelFactory { initializer { ... } }) from the AppContainer
88```
90Key invariants:
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.
118## Source map
120```
121data/model Wire models (kotlinx.serialization, sealed polymorphism, @Immutable)
122data/network Ktor: HttpClientFactory, OpencodeApiClient (REST), EventStreamClient (SSE),
123 OpencodeJson (shared Json), NetworkConfig (all tuning constants)
124data/repo SessionRepository + MessageStore (reducer), ProfileStore (DataStore +
125 EncryptedSharedPreferences), SettingsStore (theme/dynamic color),
126 DraftStore (per-session draft persistence), CrashLogger, ErrorKind
127di AppContainer (service locator) + OpencodeConnection (per-server bundle)
128notification NotificationChannels, RunForegroundService (holds process priority
129 during a run), SessionNotifications (completion notifications)
130ui OpencodeApp (NavHost), Routes, VmFactory, theme/
131ui/chat ChatScreen + ChatViewModel, message bubbles, part rendering,
132 agent/model/command picker sheets, permission dialog, export
133ui/session SessionListScreen + ViewModel, TwoPaneSessionChat (≥840dp)
134ui/server ServerListScreen + ServerEditScreen (+ ViewModels)
135ui/file FileBrowserScreen + FileViewScreen (+ ViewModels)
136ui/settings SettingsScreen, DiagnosticsScreen (crash reports)
137util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
138```
140## Conventions
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`).
180## Things to avoid
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.
193## Renovate
195`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196Ktor is grouped; AndroidX majors are held for manual review (pinned to
197compileSdk 37). Dependency upgrades land via Renovate PRs, not manual bumps.