Deleting a thread, channel, or message was a no-op/soft-delete: messages were only dropped from the plugin outbox (still in hermes' session store, hence searchable/recoverable) and channels/threads were merely archived. Now deletion is complete and non-recoverable, with no search trace: - purge.py (new): hard-delete from hermes' session store (state.db). delete_lane wipes a channel's/thread's sessions + messages; deleting a messages row also drops it from the FTS5 index via the delete triggers. delete_message removes one message, matched by (session, role, exact content, closest timestamp) since plugin m_<hex> ids aren't persisted. - channels.py: delete() hard-deletes the row (and a channel's child threads) instead of archiving. - outbox.py: add delete_lane() (wipe all frames for a lane) and message_info() (read a message's final role/text/ts for the match). - adapter.py: on_channel_delete wipes outbox + session store; on_message_delete purges the session-store row per message. - App: delete confirmations no longer claim history stays for search; ChannelStore removes a channel's threads on channel delete. - Docs updated to describe hard deletion.
13 KiB
13 KiB
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
WebSockettows(s)://host:port/ws. - Reconnect: exponential backoff + jitter; on reconnect send
hellothensync {cursor}(replays undelivered outbox only). - Initial channel open: on first open of a chat/thread, send
history(newest page) to populate the view.syncdoes NOT load history. - Heartbeat: send
pingevery 20s; reap on missedpong(3×) → reconnect. - Request/response: map of
id → CompletableDeferred; events → aSharedFlow<Frame>consumed by repositories. - Backpressure: collect frames on a bounded channel; coalesce
message.updatepermessage_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, authoritativesyncgets 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
BasicTextFieldinside aBoxwithModifier.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).
Slash commands
/drawer (implemented): typing/in the composer rolls a drawer up over the input listing the command catalog. The catalog is served by the gateway viacommands.catalogrequest → response with{name, description, args_hint, category, aliases}per command (derived from hermes'COMMAND_REGISTRY, gateway-available subset + plugin commands), so it always matches hermes (/new,/model,/reasoning,/status,/cron, …). The drawer is shown while the input is a bare/…(no space yet); typing fuzzy-filters it client-side (subsequence match over name + aliases — nocommands.completeround-trip). Tapping a row sendsmessage.send {text:"/cmd"}and closes the drawer; an unknown command empties the match list and closes it (the raw text can still be sent — hermes answers with its unknown-command reply). Escape (desktop) clears the/input.- Menu button (planned):
Menüopens a bottom sheet listing the same catalog for discovery without typing. - Interactive commands (
/model,/reasoning,/fast, approvals, clarifies) render as native pickers frompicker.*frames (a dialog / sheet with the options; answer viapicker.select) — planned.
Streaming (app-controlled)
- Settings → "Streaming" toggle (default on). When off, the app ignores
message.start/message.updateframes and shows each reply as a single final message onmessage.stop(the typing indicator covers the wait). Per-device display preference — the gateway keeps streaming for other devices (see05-streaming.md§5.1).
Tool output (app-controlled verbosity)
ToolCardrenderstool.start/progress/endframes.- The gateway always supplies the full tool data: it forces
display.platforms.android.tool_progress: verbose(so the progress line carries the full args JSON →tool.start.args) and captures each completed call via thepost_tool_callhook (→tool.endoutput_preview/duration/ok). The app decides how much to show. - Settings → "Tool detail": Everything / Truncated / Nothing.
- Everything: cards start expanded — name + full args + output.
- Truncated (default): compact one-liner; tap a card to reveal the full args + output (what was actually called).
- Nothing: suppress tool cards.
- Spinner while running; ✓/✗ + duration on
tool.end.
Reasoning before message
ReasoningBlock(collapsible, "💭 Reasoning" header, monospace body, copy button) rendered above the message body from thereasoningfield. Matches the reference screenshot.- Settings → "Reasoning" toggle (default on): auto-collapse — long reasoning blocks start collapsed, short ones expanded. Off: all reasoning blocks start expanded. Per-device display preference; the per-block tap-to-toggle always works.
Intermediate messages
commentaryframes → dimmed/smaller bubble, distinct from final answers.
Message selection + delete
- Long-press a message bubble (touch) or right-click it (desktop mouse) enters selection mode: the tapped message is selected (a circular check appears beside each bubble) and the composer is replaced by a selection toolbar (count + Delete + cancel ✕).
- While selecting, tap a bubble to toggle it; the ✕ (or deselecting the last message) exits selection mode. Only finalized messages are selectable — a streaming bubble has no final id yet and a pending echo isn't on the server.
- Delete → confirm dialog →
message.delete {message_ids:[…]}for the current lane. The server completely deletes the message(s): they are removed from the outbox (sohistory/syncno longer return them) and from the hermes session store (so no search trace survives and they are not recoverable), andmessage.deletedis broadcast to every device; each device drops them from its cache (the requesting device also drops them locally for snappy UX). Deleting is idempotent.
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 newmessage.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). - Auto-threading (Threads on, Telegram topic-mode workflow,
06-channels-cron-search.md§6.3.1): a message sent in a channel's flat lane goes out asmessage.send {auto_thread:true}; the gateway mints a fresh thread (instant derived name, AI-named a moment later viachannel.renamed) and the app jumps into it, relocating the optimistic bubble from the flat lane. - New channel (FAB / channel-list menu) →
channel.create→ appears in list; menu offers "Set as cron target". - Topic context menu — long-press a topic chip (touch) or right-click it
(desktop mouse) → "Rename" / "Delete". Rename →
channel.rename(prefilled dialog); Delete → confirm →channel.delete(hard delete; the thread leaves the switcher, its history is wiped from the outbox and session store, and if it was the open lane the app falls back to the channel's flat lane). The right-click handler is a skikoexpect/actual(iris/ui/ContextMenu.kt); on touch it is a no-op (long-press covers it).
Search
- Search bar (chat header or top) with a scope toggle: "Search everywhere" /
"Search in this chat/channel". →
searchframe → 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, thenmessage.sendwithmedia_refs.
Voice input (mic button)
- Mic button (right of composer, toggles to send when text is present).
- Tap → request
RECORD_AUDIOpermission → 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.sendwithmedia_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.