The chat screen asked the claw for everything the cache did not hold by counting events, and a session that ran for weeks cost the whole record the first time it opened here. The stream now names each frame's seq and the cache stores it, so a reopening screen sends since=<seq> and receives only the gap. The cache migrates in place: the old count of events is exactly the last seq plus one, because the record numbers densely. A screen with no cache opens on the newest turn and walks back through the new history route when the reader reaches the top, one whole turn per page, so the phone does not hold a weeks-long transcript to show the last few messages. Pages are folded through the same reducer the stream uses and prepended, since the list runs oldest first: appended, they would land at the newest end. A reset comment (the record no longer holds the cursor) drops the mirror, and the tail that follows rebuilds it. The outcome tag colours a steered turn as an accent rather than a failure, matching the web UI.
375 lines
22 KiB
Markdown
375 lines
22 KiB
Markdown
# Rhema Android
|
|
|
|
The phone client of the claw (the resident agent daemon): Kotlin + Jetpack
|
|
Compose, single module, deliberately small. It chats with the agent, reports
|
|
phone status as events, shows the agent's notifications as system
|
|
notifications, executes the agent's phone commands (DND, alerts), and
|
|
manages the device fleet the claw keeps.
|
|
|
|
## What it does
|
|
|
|
- **Settings**: a nav of submenus mirroring the web UI's settings
|
|
sidebar — the "Phone" group (Status reporting, Devices, Diagnostics)
|
|
and the "Claw" group (Model providers, Connection), one page per entry
|
|
behind a back arrow. First launch is the exception: it lands straight
|
|
on the connection page until a base URL is saved and validated with
|
|
`GET /healthz`. Stores the claw base URL and bearer token
|
|
(plus a generated, persistent device ID) in app-private DataStore. The
|
|
**Devices** page is the **Phones** card, **Status reporting** and
|
|
**Model providers** their own pages (both described below): the
|
|
backend instances (`GET /v1/providers`), each one's live model
|
|
listing, and the active pair staged as a draft behind an explicit save
|
|
(`PUT /v1/providers/active`; it applies from the claw's next turn).
|
|
Model options carry the listing's vision verdict the way the web and
|
|
the TUI spell it — `◉ vision` when the adapter knows the model takes
|
|
images, a bare `no vision` when it knows it does not, nothing when the
|
|
key is absent (unknown is no verdict, never a `false`).
|
|
The **Diagnostics** page answers "why is the fleet row stale" from the
|
|
phone's side: the three WorkManager jobs' live state (an empty info
|
|
list — a cancelled schedule — is the loud answer), the two clocks
|
|
(when this phone last landed a full report vs. when the claw last
|
|
stamped the device; the claw-behind-phone callout is "reporting but
|
|
not landing", the network-error one is "claw unreachable"),
|
|
auto-detected system checks (battery optimization, background
|
|
restriction, notifications) each with a jump into the exact settings
|
|
page when the setting is wrong, a "report now" kick of the status
|
|
worker (its one-time job has a row of its own, and every worker
|
|
crash-lands in the log instead of dying silently), and the workers'
|
|
in-memory flight recorder (a 200-line ring, newest first, copyable)
|
|
where every run logs its outcome.
|
|
Trade-off: the token is stored in plain DataStore rather than
|
|
EncryptedSharedPreferences; `androidx.security:security-crypto` is an
|
|
alpha-quality, unmaintained artifact, and the OS app sandbox already covers
|
|
the realistic threat (other apps). Backups that could exfiltrate the file
|
|
are disabled in the manifest (`allowBackup=false`,
|
|
`dataExtractionRules`, `fullBackupContent`).
|
|
- **Chat**: session list (`GET /v1/sessions`, interactive sessions first,
|
|
the phone's own `phone:*` status keys hidden)
|
|
and one chat screen per session. The applied events persist in a
|
|
per-session cache (DataStore, the newest 500 events, the seq of the
|
|
last one as its cursor), so a screen opens on its cached transcript at
|
|
once and the stream connects with `?since=<seq>` to receive only the
|
|
gap instead of replaying the session again; the cache flushes at every
|
|
`turn_done` and when the screen closes. A screen with no cache opens on
|
|
the claw's newest turn and walks back through `GET
|
|
/v1/sessions/{key}/history` as the reader scrolls up, one whole turn
|
|
per page. While a chat screen is open the app holds one SSE connection
|
|
(`GET /v1/sessions/{key}/events`, OkHttp streaming GET with the auth
|
|
header, hand-parsed frames: blank-line delimited `data:` lines with the
|
|
event's seq on the `id:` line, `:` comment keep-alives skipped, the
|
|
claw's `reset` comment dropping the mirror, unknown event types
|
|
skipped). The reader's own messages render as right-anchored
|
|
bubbles (elevated surface, accent bar on the trailing edge); the
|
|
agent's turns keep the avatar on the left. The
|
|
composer's (+) door stages images for the next send — a photo-picker
|
|
pick or a camera shot written straight into a FileProvider-mapped
|
|
cache uri (no CAMERA permission) — each a removable `▣N name size`
|
|
chip with a downscaled thumbnail above the input row. The claw's own
|
|
budget is enforced client-side with visible inline errors, never
|
|
silent drops: at most eight images a message, 5 MiB decoded each,
|
|
png/jpeg/gif/webp only (a heic pick refuses rather than being sent),
|
|
and the text stays required — the claw 422s an empty text even with
|
|
images aboard. Send reads the staged files back and POSTs bare base64
|
|
in the body's `images` (omitted when there are none, so the old
|
|
`{"text"}` shape stays byte-stable); a failed send restores the draft
|
|
and the chips. The composer POSTs `{"text"}` plus those images; the
|
|
claw's documented dance is preserved:
|
|
**subscribe first, then POST**, the reply arrives on the stream. Thinking
|
|
deltas render dim and collapsible, tool rows show name/summary with error
|
|
coloring (bash-like tools render as shell lines instead: dim `$` prefix,
|
|
the command line, and an exit-code badge), pasted payloads embedded in a
|
|
message as paired `U+FFFC` marks render as compact `▣N 5L` chips, and
|
|
`attachment` events render as cards: file name, size, caption, an inline
|
|
preview for small (≤ 2 MB) images, and a download-and-open row (bytes
|
|
from `GET /v1/attachments/{id}` land in the app cache and open through a
|
|
FileProvider `ACTION_VIEW`, the only way another app can read a private
|
|
cache file). Sent messages with images render their thumbnails in the
|
|
right-anchored bubble: the optimistic row previews the staged files at
|
|
once, and a replayed `user_message` (the stream carries attachment
|
|
ids, never bytes) fetches through the same bearer-guarded
|
|
`GET /v1/attachments/{id}` as an attachment card, decoded downscaled
|
|
and capped at the card's 240 dp. The stream's echo of a message this
|
|
surface just sent dedupes against the optimistic row (same text, same
|
|
image count), so the local previews stand. The agent's
|
|
`consent_request` questions (a secret, or an
|
|
approval of a phone control action) render as cards answered in place —
|
|
`POST`/`DELETE /v1/consents/{id}` — and `consent_resolved` settles them
|
|
(a reconnect's replay rebuilds the settled states). The streaming reply
|
|
is finalized by `turn_done`. The chat renders the claw's presentation identity
|
|
(`GET /v1/agent`, cached in DataStore, refreshed per app open and on the session
|
|
list's refresh): the avatar image when one is set (`GET /v1/agent/avatar`), else
|
|
the initials, the name's first letter, or R; the agent's name labels every turn,
|
|
falling back to "rhema". Replies
|
|
render
|
|
as markdown (`com.mikepenz:multiplatform-markdown-renderer`, GFM flavour:
|
|
raw HTML stays literal, so model output never mounts as markup; the
|
|
streaming cursor rides the source text). The transcript is a reversed
|
|
LazyColumn: the newest turn anchors to the bottom edge, so streamed
|
|
blocks stay on screen and a reader scrolled into history is not yanked
|
|
back.
|
|
- **Phone status source**: a WorkManager periodic worker (15 min, the OS
|
|
floor, `NetworkType.CONNECTED`) reads every sensor into one snapshot and
|
|
POSTs **only transitions** vs the last successfully-posted snapshot
|
|
(persisted in DataStore) to `POST /v1/ingest`. Each sensor has its own
|
|
transition rule, tuned to what would otherwise spam:
|
|
|
|
| Topic | Posts when | Needs |
|
|
|---|---|---|
|
|
| `battery` | charging flip, or decile bucket change (`level/10`) | nothing |
|
|
| `network` | `wifi`/`metered`/`offline` changes | nothing |
|
|
| `dnd` | on/off changes | nothing |
|
|
| `location` | moved 300 m or more since the last posted fix | location permission |
|
|
| `app` | the foreground package changes | usage access grant |
|
|
| `device` | the identity fingerprint changes (basically once, and on app or OS updates) | nothing |
|
|
| `wifi` | the connected SSID changes | location permission |
|
|
| `alarm` | the next alarm moves | nothing |
|
|
| `heartbeat` | always, when a run produced no other post | nothing |
|
|
|
|
A sensor that is ungranted or unreadable produces no posts for its
|
|
topic, never an error. Rich readings carry their own honesty: a
|
|
location payload includes accuracy and fix age, the app payload the
|
|
package, its label and how long it has been current. The location read
|
|
requests a fresh fix (fused on API 31+, else gps then network) with a
|
|
20 s ceiling and falls back to the newest last-known fix under 30 min;
|
|
a plain last-known read would answer with whatever another app left
|
|
cached, which on a stationary phone is where the phone was, not where
|
|
it is. The heartbeat exists for the fleet's last-seen time: the claw
|
|
stamps a device "seen" on whatever post lands, so a run with no
|
|
transitions posts one anyway and the row stays fresh at the worker's
|
|
cadence instead of the last flip. A failed post ends the run anyway —
|
|
the snapshot is only saved when every post landed, so the next
|
|
scheduled run re-derives the whole transition set; `retry()` would
|
|
hand the cadence to WorkManager's exponential backoff (hours) and the
|
|
phone would read as gone. And because idle devices defer periodic work
|
|
(Doze stretches the fifteen-minute floor to hours), the app kicks a
|
|
status run on every foreground: a phone in hand reports then and
|
|
there, and the fleet card reloads on each landed run (`lastReportAt`)
|
|
so its own row reads from the new clock instead of the one it was
|
|
opened at.
|
|
|
|
Every report is an observation on the claw: it lands in the phone
|
|
tables (the ingest answers `{"ingested": "observed"}`) and a `phone`
|
|
tool status question reads it. No report dispatches a turn, so a
|
|
commute or a session of app-hopping is never a model call per move,
|
|
and a battery flip never becomes a push notification.
|
|
|
|
Charging flips additionally kick a one-shot worker from a process-lifetime
|
|
broadcast receiver (`ACTION_POWER_CONNECTED|DISCONNECTED`,
|
|
`ACTION_BATTERY_CHANGED`). Manifest registration for those actions is
|
|
refused by the OS (implicit broadcasts, API 26+), so the receiver is
|
|
registered dynamically in the Application and stays dumb: it only
|
|
enqueues work.
|
|
- **Phone fleet**: the Settings screen's **Phones** card is the
|
|
management surface over the claw's registry (`GET /v1/phones`), not a
|
|
private view: every device the claw has heard from, its self-reported
|
|
label (the `device` topic's manufacturer and model), its last-seen
|
|
time, and a one-line state. This install's row is marked; every other
|
|
row carries **Forget**, which `DELETE`s the device's records on the
|
|
claw after a confirm dialog. A device id lives for one install, so a
|
|
reinstall or a cleared app data leaves the old identity behind as a
|
|
stale row with an old last-seen time; that row is what Forget is for.
|
|
A forgotten phone that still reports re-registers on its next run.
|
|
The **Status reporting** card names what this phone cannot send until
|
|
a grant is flipped (location permission, usage access, DND access)
|
|
and walks to the system toggles; the rows re-read on every ON_RESUME.
|
|
- **Phone commands**: a third periodic worker polls
|
|
`GET /v1/phone/commands?device=<id>` — the agent's consented control
|
|
queue — every 15 minutes like the other workers (that interval is the
|
|
control latency; opening the app kicks a one-shot KEEP-coalesced run, so
|
|
the queue is fresh while the phone is in hand). Commands execute in
|
|
order, each answered with `POST /v1/phone/commands/{id}/result`, which
|
|
doubles as the ack: a failed post leaves the command queued and the next
|
|
run repeats it. `dnd_on`/`dnd_off` set the interruption filter; they
|
|
need the special **Do Not Disturb access** grant (Settings → Special app
|
|
access — a settings toggle, not a permission), and without it the worker
|
|
reports that back and toggles nothing. `alert` posts a heads-up,
|
|
sound-on notification on the high-importance `rhema-alert` channel with
|
|
the args' `title`/`body` (falling back to "claw" / the action name).
|
|
Unknown actions are reported, never guessed.
|
|
- **Notifications**: a second periodic worker polls
|
|
`GET /v1/notifications?device=<id>&since=<cursor>` (cursor in DataStore),
|
|
posts each as a system notification on channel `rhema` (tap opens the app;
|
|
the API carries no session key, so it lands on the session list), then
|
|
`POST`s an ack per id. Acks are best-effort: the cursor means "seen", not
|
|
"acked", so a failed ack does not re-notify.
|
|
- **Permissions**: `INTERNET`, `POST_NOTIFICATIONS` (runtime request on
|
|
API 33+, re-asked per cold start; the OS throttles denials),
|
|
`RECEIVE_BOOT_COMPLETED` (boot receiver re-runs the idempotent KEEP
|
|
schedule; WorkManager also re-enqueues its own work),
|
|
`ACCESS_COARSE_LOCATION`/`ACCESS_FINE_LOCATION` (runtime request per
|
|
cold start like notifications; a refusal only silences the `location`
|
|
and `wifi` topics). Two grants are settings toggles, not permissions:
|
|
usage access (`PACKAGE_USAGE_STATS` is declared only so the toggle can
|
|
grant it) for the `app` topic, and Do Not Disturb access
|
|
(`ACCESS_NOTIFICATION_POLICY`, declared only so the app appears on the
|
|
"Do Not Disturb access" screen's list — an undeclared app opens that
|
|
screen and finds nothing to flip) for the dnd commands. The Status
|
|
reporting page in Settings shows all three.
|
|
|
|
## API contract (the claw's `/v1`)
|
|
|
|
All under one base URL; `Authorization: Bearer <token>` on `/v1`, never on
|
|
`/healthz`.
|
|
|
|
| Endpoint | Used for |
|
|
|---|---|
|
|
| `GET /healthz` | settings gate (no auth) |
|
|
| `GET /v1/providers` | backend instances + active pair |
|
|
| `GET /v1/providers/{id}/models` | an instance's live model listing |
|
|
| `PUT /v1/providers/active` | switch the backend instance, persisted |
|
|
| `GET /v1/sessions` | session list |
|
|
| `POST /v1/sessions/{key}/messages` | `{"text"}`, optional `images` (≤ 8, ≤ 5 MiB decoded each, bare base64) → 202; reply via stream |
|
|
| `GET /v1/sessions/{key}/events` | SSE, replay-then-live: `user_message`, `thinking_delta`, `text_delta`, `tool_start`, `tool_result`, `turn_done`, `attachment`, `consent_request`, `consent_resolved`. Every frame carries the record's seq on its `id:` line; `?since=<seq>` replays only the events past it, and no cursor replays the newest turn only — the transcript cache's cursor |
|
|
| `GET /v1/sessions/{key}/history?before=&limit=` | one page of whole turns older than `before`, ending exactly there, with `more` saying whether anything precedes it — the chat screen's walk back |
|
|
| `GET /v1/attachments/{id}` | an attachment event's bytes (bearer) |
|
|
| `GET /v1/consents` | pending consent questions, oldest first |
|
|
| `POST /v1/consents/{id}` | answer one: `{"secret"}` or `{"grant"}` |
|
|
| `DELETE /v1/consents/{id}` | deny / cancel a question |
|
|
| `GET /v1/phones` | the device fleet, labels and topics included |
|
|
| `DELETE /v1/phones/{device}` | retire a device's records |
|
|
| `GET /v1/phone/commands?device=` | the control queue for this device |
|
|
| `POST /v1/phone/commands/{id}/result` | execution result; doubles as the ack |
|
|
| `POST /v1/ingest` | `{"source","key","payload"}` |
|
|
| `GET /v1/notifications?device=&since=` | ascending notification list |
|
|
| `POST /v1/notifications/{id}/ack` | best-effort ack |
|
|
|
|
### Ingest payloads emitted
|
|
|
|
`source` is always `"phone"`, `key` is `phone:<deviceId>:<topic>`.
|
|
Every report is an observation on the claw: it feeds the status the
|
|
`phone` tool reads, and no turn is run (the ingest answers
|
|
`{"ingested": "observed"}`):
|
|
|
|
```json
|
|
{"topic": "battery", "value": "83", "charging": true}
|
|
{"topic": "battery", "value": "17", "charging": false}
|
|
{"topic": "network", "value": "wifi"} // or "metered" | "offline"
|
|
{"topic": "dnd", "value": "on"} // or "off"
|
|
{"topic": "location", "lat": 48.85, "lon": 2.35, "accuracy_m": 12.0, "provider": "fused", "age_s": 45}
|
|
{"topic": "app", "value": "com.google.android.maps", "label": "Maps", "age_s": 900}
|
|
{"topic": "device", "value": "Google Pixel 9", "manufacturer": "Google", "model": "Pixel 9", "android": "16", "sdk": 36, "app_version": "0.1.0"}
|
|
{"topic": "wifi", "value": "home"}
|
|
{"topic": "alarm", "value": 1760000000000, "in_min": 542}
|
|
{"topic": "heartbeat", "ts": 1760000000000}
|
|
```
|
|
|
|
`battery.value` is the raw level; the transition rule is charging-flip or
|
|
decile bucket change. `location` posts on a 300 m move from the last posted
|
|
fix. `app` excludes this app itself. `wifi` needs the location grant
|
|
(Android treats an SSID read as location) and is skipped when the system
|
|
answers with a placeholder. Unreadable or ungranted sensors produce no
|
|
posts for that topic.
|
|
|
|
## Trade-offs
|
|
|
|
- **Notification latency**: no push infrastructure; background
|
|
notifications are as fresh as the WorkManager interval (15 min). The chat
|
|
screen is always live via SSE.
|
|
- **Transcript cache**: the applied event log persists per session (the
|
|
newest 500 events plus their seqs), so a screen opens on its cached
|
|
transcript and the stream resumes past it. The cache is a cursor over
|
|
the claw's record, not a copy of it: a claw database wiped behind the
|
|
phone's back leaves the cache pointing past a history that no longer
|
|
exists, which the claw answers with a `reset` comment and the newest
|
|
turn — the screen drops its stale rows and rebuilds from those.
|
|
- **Older turns are fetched, not cached**: the screen holds the cached
|
|
window plus whatever the reader scrolled back to; reopening a session
|
|
starts from the cache again, and the walk back re-reads the record.
|
|
Nothing older is persisted, so the cache stays a fixed size.
|
|
- **First launch**: nothing works until Settings validates `/healthz`.
|
|
|
|
## One-consumer principle
|
|
|
|
The claw's `/v1` is the contract. The app has no private endpoints and no
|
|
special privileges; every need goes through the same public API any other
|
|
client sees. If a feature seems to need a claw change, that change belongs
|
|
in the claw's public API first.
|
|
|
|
## Build
|
|
|
|
Requirements: JDK 17+ (Gradle 9.3.1 runs up to the current JDK; the system
|
|
default is fine), Android SDK with platform android-36 (set `ANDROID_HOME`
|
|
or `local.properties` `sdk.dir`).
|
|
|
|
```sh
|
|
ANDROID_HOME=~/Android/Sdk ./gradlew :app:assembleDebug # debug APK
|
|
ANDROID_HOME=~/Android/Sdk ./gradlew :app:lintDebug # lint
|
|
ANDROID_HOME=~/Android/Sdk ./gradlew :app:assembleRelease # release APK
|
|
```
|
|
|
|
APK lands at `app/build/outputs/apk/debug/app-debug.apk`. Install and run:
|
|
|
|
```sh
|
|
adb install app/build/outputs/apk/debug/app-debug.apk
|
|
```
|
|
|
|
Debug builds are signed with the committed `debug.keystore` (the standard
|
|
Android Debug identity: store/key password `android`, alias
|
|
`androiddebugkey`), so any build installs over any other: a CI artifact
|
|
updates a locally-built install and vice versa. The key is deliberately
|
|
not a secret — debug keys guard nothing — a stable signature across
|
|
machines is the whole point. The build never touches
|
|
`~/.android/debug.keystore`, so other projects keep their own key.
|
|
|
|
Release builds are the opposite custody model: the key anchors the
|
|
future edge channel — whatever it signs can silently update an install
|
|
that trusts the channel — so it never travels with the source. CI holds
|
|
it as two repository secrets (`ANDROID_RELEASE_KEYSTORE`, the base64
|
|
keystore, and `ANDROID_RELEASE_KEYSTORE_PASSWORD`; alias
|
|
`rhema-release`), and the android workflow pins both certificates by
|
|
SHA-256 fingerprint before uploading an artifact. Locally, blank
|
|
environment leaves `assembleRelease` unsigned: a build check, never a
|
|
deliverable.
|
|
|
|
Pinned toolchain: Gradle 9.3.1 (wrapper), AGP 8.13.2, Kotlin 2.2.21
|
|
(`org.jetbrains.kotlin.plugin.compose` + `plugin.serialization`), Compose
|
|
BOM 2025.12.01, minSdk 26 / target & compileSdk 36.
|
|
|
|
## Layout
|
|
|
|
```
|
|
app/src/main/java/rs/rhema/phone/
|
|
MainActivity.kt nav state + POST_NOTIFICATIONS runtime request
|
|
RhemaApp.kt Application: container, channel, schedule, receiver
|
|
di/AppContainer.kt manual DI (OkHttp, Json, repos, api, notifier)
|
|
data/ DataStore-backed settings + status repositories
|
|
sensors/ the status readers (location, foreground app, wifi, alarm, identity)
|
|
net/ ClawApi (one object per endpoint, sealed result type)
|
|
chat/ChatController transcript state + SSE loop for the open screen
|
|
ui/ Theme, shared design components, SettingsScreen,
|
|
SessionsScreen, ChatScreen
|
|
work/ PhoneStatusWorker, NotificationsWorker,
|
|
PhoneCommandWorker, receivers, scheduling
|
|
notif/Notifier.kt channel + system notifications
|
|
```
|
|
|
|
## Cleartext and certificates
|
|
|
|
The claw is self-hosted, so its address is whatever the operator points
|
|
at: a LAN name (`kosmos.lan`), a bare IP, a Tailscale name, where a
|
|
publicly-trusted certificate is rare. The app therefore permits
|
|
cleartext HTTP app-wide and trusts user-installed CAs (mkcert installs
|
|
into the user store, so a mkcert-secured claw works over HTTPS too).
|
|
Only the operator's configured claw is ever contacted, and the bearer
|
|
token is the auth; prefer HTTPS wherever the deployment has a real
|
|
certificate.
|
|
|
|
## Road notes
|
|
|
|
- Theming is Jadeite dark, always, the same tokens as the TUI and the
|
|
web UI (`crates/rhema-code/src/palette.rs` DARK). No light scheme, no
|
|
dynamic color: the palette is the brand. The screens mirror the web
|
|
claw's redesign: cards with hairline-divided rows, state dots, badges,
|
|
toned callouts, avatar-row transcripts with accent-bar tool cards, and
|
|
the round accent send button; the shared pieces live in
|
|
`ui/Components.kt`, the Compose counterparts of the web's components.
|
|
- Future onboarding: scanning a QR code from the claw (base URL +
|
|
token, rendered by the web UI or printed in the terminal) instead of
|
|
typing both by hand. Deliberately deferred; the Settings screen is
|
|
the manual path today.
|
|
- Distribution: the CI release artifact (`rhema-android-release`) is
|
|
the future edge channel's payload — publishing it somewhere public
|
|
(a Gitea generic package, an always-latest link) is deferred; the
|
|
secret release key is already the channel's trust anchor, so the
|
|
artifact is worth building and pinning now.
|