git.iko.soy/opencode-android
on f2ff2eb help sign in
opencode-android/README.md
174 lines 11 kB history raw
1# opencode-android
3A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
5Application id: `soy.iko.opencode`
7## Status
9Milestones M0–M12 complete:
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
13- **M5:** per-session **model picker** (`GET /config/providers`); **permission prompts** — the
14 agent's `permission.updated` asks surface as an allow-once / always / reject dialog, answered via
15 `POST /session/:id/permissions/:permissionID`, so tool runs don't stall; **file browser + viewer**
16 (`/find/file` fuzzy search, `/file` directory listing, `/file/content` read-only view); **reconnect
17 banner** (live SSE connection state) and a real **Settings** screen (theme mode, persisted).
18- **M6:** **markdown rendering** of assistant text (headings, code blocks, lists, blockquotes, inline
19 bold/italic/code/links/strikethrough); **rich diff/patch rendering** — tool outputs and file-viewer
20 content that contain unified diffs are syntax-highlighted (added lines green, removed lines red,
21 hunk headers); **agents & commands UI** — `GET /agent` powers a per-message agent picker (sent via
22 the `agent` field on the prompt body); `GET /command` powers a slash-command runner that sends the
23 command template with its configured agent; **multi-server quick-switch** — the session list title
24 is a dropdown that lists all saved servers and switches instantly without navigating back.
25- **M7:** **UX polish pass.** Draft retention (failed sends restore your text with a Retry action);
26 destructive-action confirmations (delete session / remove server); the app bar now shows the real
27 session title; smart auto-scroll that won't fight a reader who scrolled up, with a jump-to-latest
28 FAB; long-press to copy assistant markdown, plus copy buttons on tool output and diffs; expandable
29 tool output (Show more / Show less) instead of a hard truncation; **session list** with relative
30 timestamps, last-message previews, and a search filter; tappable **file-browser breadcrumbs**;
31 a "connected" marker on the active server; permission dialog with clear action hierarchy;
32 dynamic "Thinking…" → "Thoughts" reasoning label; back-press guard while an agent is running;
33 polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
34 manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
35 The session list also supports **renaming** a session (`PATCH /session/:id`).
36- **M8:** **Enter-to-send** (hardware-keyboard Enter sends, Shift+Enter for newline);
37 **per-session draft persistence** — typed text survives back-navigation and process
38 death; **message timestamps** (relative time on every bubble); **VCS status in the file
39 browser** — `GET /file/status` powers added/modified/deleted badges with `+N/−N` line
40 counts on tracked files; **connectivity-aware SSE reconnect** — a `ConnectivityManager`
41 callback triggers an immediate reconnect (skipping backoff) when the device regains
42 network; cleaner **token/cost formatting** (`1,234 in · 5,678 out`, precision-aware cost);
43 and **bounded preview loading** (concurrency-capped, cancelled on refresh) so the session
44 list never floods the server with parallel requests.
46- **M9:** **Live session list** — `session.updated` / `session.deleted` SSE events now
47 drive incremental list updates (new sessions, renames, deletions appear without a
48 manual refresh); **pull-to-refresh** on the session list; **keep-screen-on** while an
49 agent is running so long tasks aren't interrupted by screen-off; **chat loading
50 state** — a spinner replaces the empty-conversation flash before the first message
51 load; **reconnect button** when the connection is lost mid-chat; **live session
52 title** in the chat header (updates when the agent renames the session);
53 **connection-aware run indicator** — the spinner resets if the SSE stream drops
54 mid-run instead of spinning forever; **retry-on-transient-failure** for all read
55 endpoints (sessions, messages, files, providers); and **per-code-block copy
56 buttons** in rendered markdown.
58- **M10:** **Reliability & UX hardening pass.** Fixed a latent crash where a diff in a
59 tool output nested a second `LazyColumn` inside the message list (now a bounded
60 column); **inline image rendering** for `file` parts — attachments are decoded
61 (data URIs) or fetched with Basic auth via the already-present Coil dependency;
62 **connection banner on the session list** so a dropped SSE stream is visible before
63 the list goes stale; **unread activity badges** — background sessions that receive a
64 reply get a dot until opened; **conflated message stream** so per-token snapshots no
65 longer back-pressure the event reducer on long replies; **typed error reporting** —
66 network/timeout/HTTP-status failures are classified by concrete exception type (with
67 a unit-tested classifier) instead of string-matching class names; **swipe-to-delete**
68 on session cards; **conversation export/share** (Markdown via the system share
69 sheet); and an **auto-incrementing `versionCode`** derived from the git commit count
70 so each release is strictly newer.
72- **M11:** **Reliability, a11y & tooling pass.**
73 - **Completion notifications** — when a background session finishes a run it posts a
74 system notification; tapping it opens the conversation. POST_NOTIFICATIONS is
75 requested on Android 13+ and respected if denied.
76 - **Foreground SSE service** — while an agent is running a low-priority foreground
77 service holds process priority so Doze can't choke the long-lived `/event` stream
78 mid-run when the app is backgrounded.
79 - **Crash reporting + Diagnostics** — an uncaught-exception handler writes crash
80 reports (stack trace + device/app metadata) to app-private storage, surfaced in a
81 new Settings → Diagnostics screen where they can be viewed, shared, or cleared. No
82 hosted backend required.
83 - **Adaptive large-screen layout** — on wide screens (tablets / unfolded foldables,
84 ≥ 840dp) the session list and the open conversation show side by side; compact
85 widths keep the single-pane back stack.
86 - **Deep linking** — `opencode://session/{id}` opens a conversation from any app, and
87 notification taps / share-in reuse the same open-session path.
88 - **Accessibility** — every clickable row is announced as a button to TalkBack
89 (`role` semantics); all icon buttons carry content descriptions.
90 - **Crash bug fix** — `StateFlow.value` was read inside composition (server switch
91 wouldn't recompose the image context); now collected as state.
92 - **App icon** — an adaptive launcher icon was added (lint flagged its absence).
93 - **Tooling/CI:** Android **Lint** and **detekt** now run in CI (detekt with a
94 baseline so only new findings block); a **Renovate** config keeps dependencies
95 current (grouped, weekly, AndroidX majors held for review); the deprecated
96 `kotlinOptions` DSL was replaced; and the release-artifact upload matches the
97 signed-output path. Instrumented tests (androidTest) cover the crash logger and
98 app bootstrap.
100- **M12:** **Reliability, a11y & test-coverage pass.**
101 - **Diagnostics disk I/O off the main thread** — crash report reads in the
102 Diagnostics screen were moved from synchronous `remember` blocks (composition
103 / main thread) into coroutines with `Dispatchers.IO`, eliminating potential
104 jank on large reports.
105 - **Orphaned coroutine scope fix** — `DraftStore` no longer creates an
106 unscoped `CoroutineScope` that leaks; it now uses the app-scoped
107 `CoroutineScope` from `AppContainer`.
108 - **Predictive back** — `android:enableOnBackInvokedCallback="true"` added to
109 the manifest so the Android 14+ predictive back gesture integrates properly
110 with the app's `BackHandler` usage.
111 - **Crash report filename uniqueness** — crash report timestamps now include
112 milliseconds so two crashes within the same second don't overwrite each other.
113 - **Permission response retry** — `POST /session/:id/permissions/:permissionID`
114 now uses the same retry-with-backoff as other REST calls, so a transient
115 network failure no longer leaves a tool run stuck without automatic retry.
116 - **Accessibility: space-prefix text patterns** — six `" " + text` patterns
117 that screen readers would announce with extra spaces were replaced with
118 `Modifier.padding(start = ...)` on the Text composables.
119 - **Accessibility: unread badge** — the unread activity dot on session cards
120 now carries a `contentDescription` so TalkBack announces it.
121 - **Test coverage** — added `OpencodeApiClientTest` (3 tests covering the
122 permission-response retry path) and `DraftStoreTest` (instrumented, covering
123 set/get/remove/blank-clears round-trip).
125## Architecture
127- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
128- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
129 (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
130 discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
131 can't break decoding.
132- **Manual DI** (`AppContainer` on the `Application`) — no Hilt/KSP.
133- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
134 `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
135 Compose collects with `collectAsStateWithLifecycle`.
136- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
138Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
139(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
141## Build
143```bash
144nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
145./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
146./gradlew assembleRelease # R8-minified + resource-shrunk release APK
147./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
148./gradlew detekt # static analysis (config: config/detekt/detekt.yml)
149./gradlew lintDebug # Android Lint
150./gradlew connectedAndroidTest # device/emulator tests (needs an AVD)
151```
153## Install & run
155```bash
156adb install -r app/build/outputs/apk/debug/app-debug.apk
157adb shell am start -n soy.iko.opencode/.MainActivity
158adb logcat --pid=$(adb shell pidof soy.iko.opencode)
159```
161## Connect to a server
163On the dev host:
165```bash
166opencode serve --hostname 0.0.0.0 --port 4096
167```
169In the app, add a server with base URL `http://<host-lan-ip>:4096`. If the host is reachable only
170over USB, use `adb reverse tcp:4096 tcp:4096` and point the app at `http://127.0.0.1:4096`.
172**Auth** is optional: opencode has no auth by default. If the server runs with
173`OPENCODE_SERVER_PASSWORD=...`, enter username `opencode` (the default) and that password — the app
174sends HTTP Basic auth. The app permits cleartext HTTP (opencode serves plain `http://`).