M0: toolchain, monorepo scaffold, gateway plugin skeleton, CMP app
- gateway-plugin/: android platform plugin (plugin.yaml + adapter.py register(ctx) + no-op AndroidAdapter) + stub modules for M1-M5 - app/: Compose Multiplatform project (shared KMP + androidApp + desktopApp) with Gradle wrapper; builds :androidApp:assembleDebug and :desktopApp:compileKotlin - scripts/guard_hermes_agent.sh + pre-commit hook: fail if hermes-agent/ is staged (read-only reference, never committed) - .gitignore excludes hermes-agent/; docs/ reference library
This commit is contained in:
commit
59acf66c89
49 files changed
+3950
No files matched your search
@@ -0,0 +1,197 @@
|
||||
# 10 — Android App (Kotlin + Jetpack Compose)
|
||||
|
||||
Native client. Lives in the Compose Multiplatform project at `app/`; the bulk of
|
||||
the code is in `app/shared` (commonMain) so the Desktop app reuses it.
|
||||
|
||||
## 10.1 Tech stack
|
||||
|
||||
| Concern | Choice |
|
||||
|---|---|
|
||||
| Language | Kotlin |
|
||||
| UI | Jetpack Compose (Material 3), Compose Navigation |
|
||||
| Async | Kotlinx Coroutines + Flow |
|
||||
| WS client | OkHttp (`WebSocketListener`) |
|
||||
| JSON | kotlinx-serialization |
|
||||
| Local DB | **SQLDelight** (KMP; channels, messages cache, media index, sync cursor, settings) |
|
||||
| Media playback | Media3 **ExoPlayer** (audio + video) |
|
||||
| Push | Firebase Messaging (FCM) [primary] / ntfy listener [fallback] |
|
||||
| DI | Hilt |
|
||||
| Images | Coil |
|
||||
| minSdk / target | **26** / 34 (test device: MIX 2S, API 29) |
|
||||
|
||||
## 10.2 Module layout
|
||||
|
||||
```
|
||||
app/shared/src/
|
||||
├── commonMain/kotlin/iris/
|
||||
│ ├── protocol/ # frame data classes (mirror of gateway protocol.py)
|
||||
│ ├── net/ # GatewayClient (OkHttp WS), reconnect, heartbeat, dispatch
|
||||
│ ├── data/ # ChannelRepository, MessageRepository, MediaRepository,
|
||||
│ │ # SearchRepository, SettingsRepository (SQLDelight + WS)
|
||||
│ ├── state/ # ViewModels: ChatListVM, ChatVM, ComposerVM, SettingsVM
|
||||
│ ├── ui/
|
||||
│ │ ├── theme/ # Material 3 theme (dark default), type, color
|
||||
│ │ ├── components/ # MessageBubble, ReasoningBlock, ToolCard, MediaPlayer,
|
||||
│ │ │ # ChannelRow, Composer, PickerSheet, SearchBar, Banner
|
||||
│ │ └── screens/ # ConnectScreen, ChatListScreen, ChatScreen,
|
||||
│ │ # SearchScreen, SettingsScreen, ChannelMenu
|
||||
│ └── util/ # time formatting, markdown, id gen
|
||||
├── androidMain/kotlin/iris/
|
||||
│ ├── platform/ # MediaPlayer actual (ExoPlayer), MediaPicker (SAF),
|
||||
│ │ # Notifications, SecureStore (EncryptedSharedPreferences)
|
||||
│ ├── fcm/ # FirebaseMessagingService, foreground sync service
|
||||
│ └── IrisApp.kt # Application (Hilt), notification channels
|
||||
└── androidApp/ # MainActivity, AndroidManifest, res, google-services
|
||||
```
|
||||
|
||||
## 10.3 `GatewayClient` (net)
|
||||
|
||||
- OkHttp `WebSocket` to `ws(s)://host:port/ws`.
|
||||
- **Reconnect:** exponential backoff + jitter; on reconnect send `hello` then
|
||||
`sync {cursor}` (replays undelivered outbox only).
|
||||
- **Initial channel open:** on first open of a chat/thread, send `history`
|
||||
(newest page) to populate the view. `sync` does NOT load history.
|
||||
- **Heartbeat:** send `ping` every 20s; reap on missed `pong` (3×) → reconnect.
|
||||
- **Request/response:** map of `id → CompletableDeferred`; events → a
|
||||
`SharedFlow<Frame>` consumed by repositories.
|
||||
- **Backpressure:** collect frames on a bounded channel; coalesce
|
||||
`message.update` per `message_id` (keep latest) to avoid flooding the UI.
|
||||
- **Thread:** all frame emission on a single dispatcher; UI observes via Flow.
|
||||
|
||||
## 10.4 State rules (from hermes desktop AGENTS.md — apply here)
|
||||
|
||||
- **Server is authoritative** for channels/messages/cursor; the app's Room copy
|
||||
is a **cache**. Reconcile (merge, don't clobber) on `channel.*`/`sync`.
|
||||
- **Optimistic then honest:** send a message → show it immediately (pending),
|
||||
roll back visibly on `error`, authoritative `sync` gets the last word.
|
||||
- **Guard against the past:** generation counters / request tokens so a stale
|
||||
response never overwrites newer intent.
|
||||
- **Isolate the foreground:** only the visible chat publishes into the shared
|
||||
view; background chats update their own cache quietly.
|
||||
- **Coalesce noise, flush signal:** batch cosmetic updates; let terminal
|
||||
transitions (turn done, needs input, failed) reach the user immediately.
|
||||
|
||||
## 10.5 Feature implementation (your checklist)
|
||||
|
||||
### Input box, auto-grow (max height)
|
||||
- Compose `BasicTextField` inside a `Box` with
|
||||
`Modifier.heightIn(min = 1.line, max = 160.dp)`. Grows with lines, caps at
|
||||
160dp, then scrolls internally.
|
||||
- Bottom bar (matches reference): `Menü` button (left), emoji button, the
|
||||
auto-grow field, attach (paperclip), mic/send (right). Send on Enter
|
||||
(configurable: Enter=send vs Enter=newline).
|
||||
|
||||
### Menu button → all slash commands
|
||||
- `Menü` opens a **bottom sheet** listing the command catalog. The catalog is
|
||||
served by the gateway via `commands.catalog` request → response with
|
||||
`{name, description, args_hint, category}` per command, so it always matches
|
||||
hermes (`/new`, `/model`, `/reasoning`, `/status`, `/cron`, `/tools`, …).
|
||||
- Typing `/` in the field shows **autocomplete** via `commands.complete`
|
||||
request (gateway matches the typed prefix). Selecting inserts `/cmd `.
|
||||
- Selecting a command sends `message.send {text:"/cmd args"}`.
|
||||
- **Interactive commands** (`/model`, `/reasoning`, `/fast`, approvals,
|
||||
clarifies) render as **native pickers** from `picker.*` frames (a dialog /
|
||||
sheet with the options; answer via `picker.select`).
|
||||
|
||||
### Tool output (app-controlled verbosity)
|
||||
- `ToolCard` renders `tool.start/progress/end` frames.
|
||||
- **Settings → "Tool detail": Everything / Truncated / Nothing.**
|
||||
- Everything: name + full args (collapsible) + output preview.
|
||||
- Truncated (default): `emoji name: "short preview"` one-liner, expandable.
|
||||
- Nothing: suppress tool frames.
|
||||
- Spinner while running; ✓/✗ + duration on `tool.end`.
|
||||
|
||||
### Reasoning before message
|
||||
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
|
||||
button) rendered **above** the message body from the `reasoning` field.
|
||||
Matches the reference screenshot.
|
||||
|
||||
### Intermediate messages
|
||||
- `commentary` frames → dimmed/smaller bubble, distinct from final answers.
|
||||
|
||||
### Agent busy / stop / steer
|
||||
- `agent.busy` → show "thinking…" indicator in chat header (animated dots).
|
||||
- `agent.idle` → clear indicator.
|
||||
- **Stop button** (appears in header while busy): sends `agent.stop`.
|
||||
- **Steer:** while busy, the composer accepts input; sending it calls
|
||||
`agent.steer` (injects mid-turn) rather than queuing a new `message.send`.
|
||||
|
||||
### Threading + channels
|
||||
- **Channel list** (drawer on single-pane; left rail on two-pane) = default chat
|
||||
+ user channels (avatar, name, last-message preview, unread badge, active
|
||||
highlight bar) — matches the reference left sidebar.
|
||||
- **Thread toggle** in the default chat header: "Threads on/off". On → topic
|
||||
switcher above the message list (each topic = a `thread_id`).
|
||||
- **New channel** (FAB / channel-list menu) → `channel.create` → appears in list;
|
||||
menu offers "Set as cron target".
|
||||
|
||||
### Search
|
||||
- Search bar (chat header or top) with a **scope toggle**: "Search everywhere" /
|
||||
"Search in this chat/channel". → `search` frame → results list → tap jumps to
|
||||
the message (navigate + highlight).
|
||||
|
||||
### Attach media
|
||||
- Paperclip → system pickers (Photos / Files / Audio / Video / Docs) via SAF.
|
||||
- Selected files show as **preview chips** in the composer (thumbnail + name +
|
||||
remove). On send: `media.upload` (chunked) for each, then `message.send` with
|
||||
`media_refs`.
|
||||
|
||||
### Voice input (mic button)
|
||||
- Mic button (right of composer, toggles to send when text is present).
|
||||
- Tap → request `RECORD_AUDIO` permission → start recording (MediaRecorder,
|
||||
OGG/Opus, 44.1 kHz mono).
|
||||
- While recording: timer + waveform; tap again to stop.
|
||||
- On stop: file becomes a **preview chip** (audio, kind=`voice`) in the
|
||||
composer, same as attached media. Send → `media.upload` (chunked) →
|
||||
`message.send` with `media_refs`.
|
||||
- Hermes receives it as an audio attachment; the agent's STT (if configured)
|
||||
transcribes it. No client-side STT.
|
||||
|
||||
### Push notifications
|
||||
- FCM service (see `08-push.md`): foreground banner + background foreground
|
||||
service → `sync`. Notification channel per chat. Tap → deep-link to chat.
|
||||
- ntfy fallback: foreground service maintains the subscription.
|
||||
|
||||
### Live playback
|
||||
- AI-sent audio/video → `media.pull` → cache file → **ExoPlayer** inline player
|
||||
(audio: mini-player; video: inline + fullscreen + PiP). Documents/images →
|
||||
viewer / open-with.
|
||||
|
||||
## 10.6 Layout (Telegram-style, per reference image)
|
||||
|
||||
**Two layout modes** (decision: user-toggleable, **single-pane default**):
|
||||
- **Single-pane (default on phones):** chat full-screen; channel list in a
|
||||
swipeable drawer (hamburger / edge swipe).
|
||||
- **Two-pane (Telegram-style, like the reference):** persistent left channel
|
||||
rail + chat. Auto-enabled on tablets / large screens; toggleable in Settings.
|
||||
|
||||
**Chat screen anatomy (matches reference):**
|
||||
- **Header:** back (single-pane), avatar, name + "Bot" subtitle, edit + overflow
|
||||
(⋮) menu (thread toggle, channel menu, set cron target, clear).
|
||||
- **Message list:** date separators ("7. August"); user bubbles **right**
|
||||
(accent color, ✓✓ read receipts); agent bubbles **left** (surface color) with
|
||||
optional ReasoningBlock + ToolCards + media + model/token footer
|
||||
("Qwen3-… · 11% · ~") + timestamp.
|
||||
- **In-app banner** above the composer (e.g. "renamed topic").
|
||||
- **Composer:** `Menü` / emoji / auto-grow input / attach / mic-send.
|
||||
|
||||
**Theme:** dark by default (reference is dark); Material 3; optional dynamic
|
||||
color. Accent = user's chosen brand color (default indigo, like the reference).
|
||||
|
||||
## 10.7 SQLDelight schema (cache)
|
||||
|
||||
- `channels(chat_id PK, name, kind, parent_chat_id, is_default, last_preview,
|
||||
last_ts, unread)`.
|
||||
- `messages(id PK, chat_id, thread_id, role, text, reasoning, model, tokens,
|
||||
ts, status[pending|sent|read], media_json)`.
|
||||
- `media(media_id PK, local_path, kind, mime, size, ts)`.
|
||||
- `meta(key PK, value)` — sync cursor, settings, device_id, server url, pinned
|
||||
cert fingerprint.
|
||||
|
||||
## 10.8 Onboarding / Connect screen
|
||||
|
||||
- First launch → **Connect**: server URL + token (or scan QR). "Test connection"
|
||||
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
|
||||
auth leg must be exercised). On success → save (secure storage) → main.
|
||||
- States: connecting / connected / reconnecting / degraded / auth-failed — each
|
||||
with honest copy and a way out.
|
||||
Reference in new issue
Block a user