A native Android client for the spettro AI coding agent — a Kotlin port
of the iOS companion app (SpettroMobile). The app ships no AI itself: it is
a remote control and transcript view for an agent that runs elsewhere.
UI is Jetpack Compose with Material 3 Expressive, dressed in the desktop
app's design language; package root is to.eyed.spettro.mobile. minSdk 33,
targetSdk/compileSdk 37, Kotlin + coroutines throughout (no RxJava, no Hilt —
manual DI via AppContainer).
Spettro for Android talks to a spettro agent host and renders
everything it emits: chats, streamed tokens, tool calls, diffs, plans,
permission prompts, and ask-user questions. It pairs with the Spettro
desktop app on your computer — Linux, macOS, or Windows — over the local
network (Bonjour discovery, QR-code pairing, WebSocket + JSON-RPC 2.0), and
mirrors its chat list; the computer owns the agent. Every prompt, config change, and
permission reply is a request; every chat update is a notification. The app
owns the display transcript, the composer, and the sheets; the agent owns
the model, the tool loop, and the file edits.
| Spettro Remote (Protocol B, version 1) | |
|---|---|
| Connects to | Spettro desktop app acting as host (Settings › Remote › sharing on) |
| Transport | WebSocket, JSON-RPC 2.0 (symmetric peer): ws://<lan-ip>:<port>/ on the local network (or a USB port forward) |
| Discovery | Bonjour _spettro-remote._tcp via NsdManager, plus the QR's address hint and the last address that worked |
| Auth | QR pair-once, HMAC-SHA256 challenge/response, durable device key |
| Surface | Full chat list, projects, multi-session, config, permissions, questions, renames, @-mentions |
| Spec | ../Spettro-Desktop/ARCHITECTURE.md (Remote protocol); the wire is ../Spettro-Desktop/src/shared/remote.ts |
The companion-app pairing is the only supported connection: the durable, HMAC-authenticated pairing is safer than exposing a raw agent endpoint, so there is deliberately no direct/headless CLI mode.
RemoteClient exposes a StateFlow<RemoteState> state machine
(Unpaired → Searching → Connecting → Connected, plus Offline with a
typed reason), a diagnostics log, and RemoteApi typed wrappers for every
host request (chatsList, chatsOpen, prompt, cancel, config,
rename, projectFiles, permissionReply, questionReply, agentCall,
…). Raw ACP payloads cross module boundaries as JsonObject — the protocol
layers never parse ACP content; core.acp does.
The desktop shows a QR code, spettro-pair://pair?v=1&h=<hostID>&n=<name>&k=<kind>&s=<secret>&a=<addr>&p=<port>
(RemotePairing.parse; unknown keys, such as the r an older desktop
added for its internet tunnel, are ignored). Scan it from the pairing
screen, paste it, or open it as a link: the desktop's Copy link puts
the same URL on the clipboard, and tapping it on the phone opens the app on
spettro-pair://pair (a VIEW intent filter on MainActivity, which is
singleTask so the link reaches the running app). Unpaired, a link pairs as
if scanned. Already paired, PairingLinkPrompt asks first — "Pair with
“Studio iMac”?" — because pairing replaces the current computer; a link
from the computer already connected changes nothing and is dropped quietly.
The phone only reaches the computer on the local network; there is no
internet route. EndpointPlan (core/remote/RemoteConnection.kt) lists
where to try, best first: where Bonjour sees the computer now, then the last
LAN address that worked — only on Wi-Fi or Ethernet, except a loopback
address, which is a USB cable's adb reverse port forward and is tried on
any network. Pairing tries the code's address hint, then Bonjour.
raceStaggered races them "happy eyeballs" style — each next one after a
750 ms head start, or as soon as the one before fails — and the first to say
hello as the right host wins; the losers are closed before the winner
authenticates. Settings shows which way the connection goes ("Connected over
Wi-Fi", "over Ethernet", "over USB", "on your network"). When nothing
answers, offlineReason picks the story: Bonjour sees the computer (a
transport fault), the local-network permission is missing, or it isn't on
this network.
Android 17 (API 37) guards the LAN behind ACCESS_LOCAL_NETWORK. The app
declares it, asks with a rationale before pairing whenever it hasn't been
granted (rememberLocalNetworkPermission), and offers it again from the
disconnected screen. Without it the LAN routes are dropped from the plan —
the system would refuse the sockets anyway — so nothing is tried until it is
granted. Older Android ignores the permission.
The protocol version stays 1: a host refuses any other. What a host adds on
top it announces in features (in hello and the auth result; see
RemoteFeature), and the phone checks Connected.has(...) before relying
on any of it. An old host sends no list, which reads as "none of these":
| Feature | What the phone does with it |
|---|---|
configResult |
chats/config waits for {configOptions} (or an error after a rollback); without it, the two calls of one slider move go 300 ms apart |
notices |
desktop-side notices (chat/state with a notice, no stopReason) show in the transcript |
pendingReplay |
approvals and questions still waiting are re-asked after auth, so a reconnect never loses one |
mentions |
chats/prompt carries the @-mentioned files; without it they stay plain text |
rename |
Rename appears in the chat's menu and on a long press in the list |
projectFiles |
the @ menu lists the project's files |
snapshotV2 |
reopened chats keep steering, streaming, timing, mention and workflow fields |
unpair |
Forget in Settings first asks the computer to take this phone off its paired devices (devices/unpair, which only ever removes the signed-in device); without it, or when not connected, the phone forgets here only |
goodbyeReason |
a goodbye host/state says why — quit, restart (quitting to install an update, shown as "Spettro is restarting") or sharing-off — and there is one per shutdown; without it the phone reads its first goodbye: no message means the app quit |
The connection follows the process lifecycle (AppContainer, via
ProcessLifecycleOwner): it starts when the app comes to the foreground and
is let go a little after it leaves, so the system photo picker doesn't drop
it. Failed connections retry on their own: exponential backoff from 1s to
30s, reset the moment anything succeeds, the app comes to the foreground, or
Bonjour sees the host reappear. Android's NSD often misses a desktop quitting
and coming back, so while the host is away and the app is in sight the phone
browses Bonjour afresh (RemoteDiscovery.refresh): 2 s after it went, then
every 10 s, every 30 s after five minutes; never in the background or after
Disconnect. Some phones' NSD ignores the desktop's Bonjour goodbye outright
and answers from its cache, so after a goodbye the phone also tries the
computer's LAN address every 2 s for two minutes, then less and less often
(a refused connect costs nothing, and the computer doesn't count it): a
relaunched desktop is back within a couple of seconds. A drop while a chat
is open is ridden out in place for up to 20 s — the transcript and the draft
stay, a "Reconnecting…" pill shows, and Send waits — before the disconnected
screen takes over. That
screen says why in plain words (looking, can't reach, sharing off, not
open, restarting for an update, turned away, removed, update needed, local
network access) and always keeps a
"Forget this computer…" escape hatch so a stale pairing can be discarded.
There are no background notifications; another chat that needs an answer is
marked "Needs you" in the list.
app/src/main/java/to/eyed/spettro/mobile/
├── MainActivity.kt entry point; theme, and spettro-pair:// links
├── coordinator/ manual DI + app-wide coordinators
│ ├── AppContainer.kt service locator; starts/stops the client with the process
│ └── remote/ MobileModel (root coordinator: mirrors the host's
│ state), ChatOpenBuffer, DeleteScheduler (Undo window)
├── core/
│ ├── SpettroJson.kt, B64.kt shared Json instance + base64url/std helpers
│ ├── Humanize.kt errors in plain words, raw text behind "Show details"
│ ├── WorkflowActivation.kt the "ultracode" matcher; WorkflowBudget.kt the "+500k" directive
│ ├── rpc/ JsonRpcPeer — symmetric JSON-RPC 2.0 over OkHttp WS
│ ├── remote/ Protocol B: RemoteClient, RemoteApi, RemoteProtocol
│ │ (wire types, features), RemoteConnection (routes,
│ │ endpoint plan, the race), discovery (NSD), pairing
│ │ (QR + HMAC), credential store, notifications
│ └── acp/ ACP payload parsing: session updates, config options
│ (both wire shapes), permission/question requests,
│ StoredSession decode, extension types
├── model/ ChatSession transcript state holder, TranscriptItem,
│ │ ToolCallItem, image attachments, and the pure logic
│ │ the UI draws from:
│ ├── Orchestration.kt folds the flat wire back into workflow runs (see below)
│ ├── Thinking*.kt, SliderMoves.kt, Meteor.kt, Embers.kt
│ │ the thinking slider: one dial over thinking + Ultra,
│ │ the commit queue, the meteor and the smoulder
│ ├── ToolGroups.kt, ToolPresentation.kt, UnifiedDiff.kt
│ │ tool rows: folded reads, verbs and notes, real diffs
│ ├── Prompts.kt, QuestionForm.kt the prompt dock's approvals and questions
│ └── Todos.kt, Mentions.kt, CommandCopy.kt, RunPhase.kt, TranscriptAnchors.kt
└── ui/
├── theme/ the desktop's tokens (Lilac/Monochrome × light/dark), type,
│ motion, ThemeStore (appearance + accent, phone-local)
├── components/ shared composables (buttons, cards, chips, spinners,
│ local-network permission) and thinking/ (slider + chip)
├── screens/
│ ├── home/ PairingScreen, QR scanner, ChatListScreen,
│ │ DisconnectedScreen, project picker, rename/delete dialogs
│ ├── chat/ ChatScreen, composer, NewSessionScreen, transcript rows,
│ │ tool-call rows, PromptDock, TodoList, markdown,
│ │ config sheet, workflow cards + the live run strip
│ ├── settings/ SettingsScreen, ProvidersScreen
│ └── sheets/ PermissionSheet, QuestionSheet (prompts naming no chat)
└── AppRoot.kt routes on RemoteState; the split shell on tablets;
reconnecting pill, pairing-link prompt, global sheets
app/src/debug/.../screenshots/ScreenshotActivity.kt
the screenshot harness (see Building and running)
The architecture contract the modules were built against lives in
DESIGN.md — package ownership, module APIs, wire-format
gotchas, and the design tokens.
The CLI streams a workflow run as a flat sequence of tool calls — one long-lived card for the run, one call per sub-agent, and every tool each sub-agent runs, all interleaved in arrival order. Rendered literally that is a wall of near-identical rows that drowns the conversation and never says which agent did what.
model/Orchestration.kt reconstructs the real shape. It is pure and memo-free
on purpose: a session restored from disk must fold to exactly the same tree as
the live one that produced it, so the answer may depend only on the items
passed in. ChatScreen runs it once per transcript change and both the
transcript and the live strip read the result.
The run's state comes from _meta["spettro.app/workflow"] (version 1) on the
card when it is there — phases, members, pause, stop, size and budget, the log
tail — and from the text the CLI rendered into the card when it is not (an
older CLI, or a host that does not relay the meta in a snapshot). The meta
wins; the two paths are tested to draw the same tree. A few things about the
wire are load-bearing:
- Card ids say which run and which turn.
workflow-<run>is a run's first card andworkflow-<run>-<n>the one a later turn opened to continue it;wf-<n>is a run with no id (and every card of an older CLI). A run paused at a checkpoint and continued later has two cards: the first closes "continued in a later turn", the second lists every member so far, so members are pooled per run rather than per card. - A workflow arrives as two calls with the same name. The model's
invocation of the
workflowtool carries the whole script as arguments; the card is what the run is built from. The first is folded into the second — except when it failed before any run existed, where it is the only evidence a workflow was attempted at all. A reply to a checkpoint (continue_run_id) folds away too, unless it was refused. - Progress traces can escape.
workflow X ⏸ cp-1,▸ Phaseand· logtraces carryworkflowand noagent, exactly like a card, and must never open a phantom run.
Ultra swarms are gone from the CLI, and from the app with them.
OrchestrationTest is a case-for-case port of the desktop app's suite and
covers every one of those. Screenshots cannot catch any of it — a member
attached to the wrong run still renders beautifully.
Prerequisites: Android Studio (recent stable) with a JDK 11+ toolchain, an emulator or device on Android 13+ (API 33).
./gradlew assembleDebug # build
./gradlew installDebug # install on a connected device/emulator
./gradlew testDebugUnitTest # JVM unit testsThe debug build installs beside a Play install rather than replacing it.
It carries applicationIdSuffix = ".dev" and is labelled Spettro Dev, so
installDebug on a phone that already has Spettro from the Play Store adds a
second app instead of failing on the signature mismatch — and, more to the
point, without wiping the pairing credentials, which live in app-private
storage with allowBackup="false" and cannot be recovered.
That means the debug package is to.eyed.spettro.mobile.dev while the class
namespace stays to.eyed.spettro.mobile, so anything addressing a component
by name has to spell it out in full rather than using the .Foo shorthand.
tools/playstore-screenshots.sh already does. Release builds are untouched:
still to.eyed.spettro.mobile, still labelled Spettro.
./gradlew assembleRelease # R8-minified and resource-shrunk; signed with the debug keyA release build makes one APK per ABI plus a universal one, in
app/build/outputs/apk/release/. For a phone (the Seeker included) take
app-arm64-v8a-release.apk, about 12 MB against 27 MB for the universal
APK: most of the app is ML Kit's barcode model, once for every ABI.
app-armeabi-v7a-release.apk is for older 32-bit phones,
app-x86_64-release.apk for emulators, and app-universal-release.apk
carries every ABI, x86 included, as the single APK did before. Debug builds
stay one APK.
Each APK's versionCode is versionCode × 10 plus an ABI digit: universal 0,
armeabi-v7a 1, arm64-v8a 2, x86_64 3, so versionCode = 18 gives 180, 181,
182 and 183. Any APK of a newer release upgrades any APK of an older one,
and within a release a split may replace the universal APK. The splits (and
these versionCodes) only go on for assembleRelease/installRelease: the
Play bundle (bundleRelease, which AGP won't build with ABI splits on) and
any other build keep one APK and the plain versionCode. The sideloaded APKs
are signed with the debug key, so they are a separate track from Play anyway.
To actually use the app you need the Spettro desktop app running on a
computer with sharing on (Settings › Remote). Pair on the same network as the
computer — scan its QR code, paste or open its spettro-pair:// link — and
the phone reconnects whenever it's on that network again. Each application
id pairs separately, so the dev copy needs its own pairing.
The debug build carries ScreenshotActivity, which renders one screen from
the preview fixtures with no client and no network. It serves every surface:
the chat and its tool rows and diffs, the prompt dock, the todo list, the
workflow cards, the thinking slider (frozen meteor and smoulder frames
included), the new-session view, every disconnected reason, reconnecting,
Settings on either route, and the pairing-link prompt. The full scene list
is at the top of tools/playstore-screenshots.sh; drive one with
adb shell am start -n \
to.eyed.spettro.mobile.dev/to.eyed.spettro.mobile.screenshots.ScreenshotActivity \
--es scene thinking_meteor --es theme dark --es form phone [--es accent mono]./tools/playstore-screenshots.sh [phone|tablet] captures the Play listing
from it (boot the emulator with ./tools/start.sh).
JVM unit tests (app/src/test) cover the parts that must not drift:
- Pairing and connection — HMAC-SHA256 proof vector,
spettro-pair://parse (withrand hostname normalisation), the endpoint plan, the staggered race, offline reasons, credential prefs, notification parsing - JSON-RPC — framing and round-trips in
JsonRpcPeer(MockWebServer) - ACP parsing — both config-option wire shapes (ACP-native and the
Swift-synthesized
StoredSessionshape),StoredSessiondecode, session updates, permission/question requests, extension types - Model —
ChatSessiontranscript mutation and replay suppression,ToolCallItemtitle parsing, tool groups and diffs, orchestration folding, prompts and questions, todos, mentions, the thinking dial, its moves and commit queue, the meteor's plan - UI logic — disconnect reasons, transient drops, config summary,
permission copy, session groups, theme prefs, and
ContrastTest, which checks every text token for WCAG AA over (Lilac|Mono)×(light|dark)
End-to-end verification is manual: the Spettro desktop app hosting on a computer plus this app on a device.
- All JSON goes through
core.SpettroJson(ignoreUnknownKeys,explicitNulls = false). Key spellings are copied exactly from the wire:hostID/chatID/deviceIDare capital-ID;sessionId/toolCallIdare not. - base64url unpadded for pairing material; standard padded base64 for image attachments.
- Async state is coroutines +
StateFlow/SharedFloweverywhere. - Design tokens mirror the desktop design system: a Lilac (
#7A66C6light /#B9A3F5dark) or Monochrome accent, chosen on the phone, on true neutral greys (canvas#FAFAFA/#1C1C1E); flat hairline cards, with shadows only on floating layers (composer, menus, sheets, toasts); a neutral user bubble, assistant = full-width prose. SeeDESIGN.md.
../Spettro-Desktop— the desktop app this pairs with: the remote host and the design system (src/renderer/src/design/) this app follows; itsARCHITECTURE.mddocuments the protocol../spettro— the agent itself (the CLI)