64 Commits
Author SHA1 Message Date
ARIA fb980d12b4 Release version management: single VERSION file as source of truth
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m0s
- VERSION at repo root (0.1.2); bump it to cut a release
- App: generated AppVersion.kt (config-cache-safe Gradle task with
  VERSION as declared input) shown in Settings; sent to the gateway
  via X-Iris-App-Version header on the SSE open
- Gateway: reports its own version in hello.ack server_caps.app_version
  (read from the repo-root VERSION via the plugin symlink); stores the
  app's version in the device registry caps (merge, not overwrite, so
  an old app reconnecting without the header doesn't wipe it)
- Settings: app + gateway version rows, mismatch hint, and a best-effort
  Gitea latest-release check (ReleaseCheck) with an 'update available' hint
- Release workflow: reads VERSION from the repo (no manual input), with
  a guard against an empty file
- Docs: frames.schema.json + 04-wire-protocol.md updated for app_version
2026-08-25 14:42:39 +02:00
ARIA f3f1b37221 Fix iris setup: embed generated token in the pairing URL/QR
CI / Gateway plugin tests (push) Successful in 5m23s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m53s
interactive_setup() generated a fresh IRIS_TOKEN and saved it to .env,
but the local `token` variable was never updated, so the pairing URL and
QR payload were built with an empty token (token=). The gateway accepted
the saved token, but the app never received it, so pairing was impossible.

Assign the generated value back to `token` so the pairing URL/QR carry it.
Add a regression test (test_interactive_setup_generates_token_in_pairing_url).
2026-08-25 13:42:46 +02:00
ARIA 6f339330c5 Move gateway-plugin tests out of the installable tree; clean plugin scan
CI / Gateway plugin tests (push) Successful in 5m13s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
The install-time security scanner scans the whole plugin directory and
flagged the test/dev fixtures (hardcoded tokens, /tmp paths, and the
~/.hermes/.env literal in setup.py) as DANGEROUS, blocking installs with
"19 findings".

- Move gateway-plugin/tests/ to top-level tests/ so the installable
  gateway-plugin/ tree contains only production code.
- Update _plugin_dir() in the tests and REPO in e2e.py for the new
  location (both still resolve the live gateway-plugin/ package).
- Update all references: docs, CI-SETUP.md, Gitea workflows, .pi-lens.json.
- Build the hermes .env path at runtime in setup.py via get_hermes_home()
  so the scanner no longer matches the literal ~/.hermes/.env.

Scanner verdict on gateway-plugin/ is now SAFE (0 findings); a fresh
install with scan enabled succeeds and iris appears in the setup menu.
2026-08-25 13:26:12 +02:00
ARIA 573291fc1e threads: order topic chips newest-first beneath General
CI / Gateway plugin tests (push) Successful in 5m17s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
The gateway already stored a created timestamp per channel/thread but
never sent it over the wire. Now:

- protocol.py: _channel_payload() includes created (unix seconds)
- Protocol.kt: ChannelInfo.created (default 0.0 for legacy gateways)
- ChatScreen: topic switcher sorts threads created-desc (newest right
  after General, swipe new -> old), name as tie-break
- frames.schema.json: document the created field
- ChannelCreatedWireTest: wire deserialization + ordering tests
2026-08-25 11:22:00 +02:00
ARIA 83a67f6fb1 app: smooth streaming markdown via incremental parser + typewriter reveal
CI / Gateway plugin tests (push) Successful in 6m4s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m36s
- MarkdownText: streaming branch on the library's StreamingMarkdownState
  (rememberStreamingMarkdownPipeline) — incremental append, no full
  re-parse per snapshot; static path keeps retainState=true
- client-side reveal: buffer snapshots, reveal at a steady rate
  (streamCharsPerSecond = 60/smoothness, 300..75 c/s) with rate-relative
  catch-up (jump when backlog > 2s of reveal time)
- keep the streaming renderer alive after message.stop until the reveal
  catches up (useStreaming gate); artifact card waits for the same
- cursor drawn via annotator, never part of the parse input
- new 'Streaming speed' setting (0.2-0.8s) in Settings, persisted per
  device via SecureStore, live-updatable
- docs/05-streaming: rate mapping, catch-up, wait-for-reveal semantics
2026-08-25 11:00:22 +02:00
ARIA c7a16d51e3 gateway setup: offer self-signed TLS cert generation (no openssl needed)
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
hermes gateway setup now asks 'Set up TLS now?' when IRIS_HTTP_CERT is
not in .env (default No, Yes for an all-interfaces bind). Accepting
generates a 10-year RSA-2048 self-signed cert with SANs (advertised LAN
IP, hostname, loopback) under ~/.hermes/iris/ via hermes' existing
cryptography dependency, saves IRIS_HTTP_CERT/IRIS_HTTP_KEY, and prints
the SHA-256 fingerprint in openssl format for the app's confirm-and-pin
dialog. The pairing URL/QR printed afterwards already advertise https.

- key created 0600 from the start (no umask window)
- save_env_value inside the best-effort guard (unwritable .env warns)
- leftover cert without env var -> overwrite confirmation (protects the
  app's pinned fingerprint)
- bind wildcards (0.0.0.0 / ::) never become SANs; :: gets the same
  default-Yes as 0.0.0.0 (pairing._unroutable parity)

Tests: 5 new (cert generation incl. openssl fingerprint cross-check,
accept/decline, no re-prompt, default-follows-bind, overwrite prompt).
Docs: install.md Part 2 table + Part 4 Option B.
2026-08-24 22:46:27 +02:00
ARIA b1c9bac7d8 docs+plugin: HTTP-only transport cleanup, install guide, review fixes
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
- docs/install.md: new end-to-end guide for non-technical users
  (gateway install, app install, LAN/TLS/remote connection, push,
  options, troubleshooting); docs/setup.md now points to it
- README: new 'Install the gateway' section; pairing section updated
  for HTTP transport (8791, QR scan on Android)
- rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat
  fallback); drop dead DEFAULT_PORT=8790
- setup.py: advertise https:// in the printed/QR server URL when
  IRIS_HTTP_CERT is set
- ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env
  IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme
- plugin.yaml: IRIS_HTTP_* env names, description no longer says
  'WebSocket server'
- docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites,
  8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the
  only transport)
- AGENTS.md: symlink name android -> iris (matches actual install)
- test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy
  IRIS_WS_* names are not consulted (95/95 pass)
2026-08-24 22:22:02 +02:00
ARIA a61b47a947 docs: replace stale 'android' name mentions with 'iris'
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m59s
The plugin is named 'iris' (IrisAdapter, IRIS_HOME_CHANNEL, label Iris),
but several docs still referred to it as the android platform/plugin and
to the product as 'the Android app'. Rename name-mentions to iris/IRIS
and product-mentions to 'Iris app'; keep legitimate OS references
(androidApp, Android SDK, Android 10, androidx, test_android.py, ...).

Also includes pi-lens markdown-lint autofixes (table spacing, trailing
newlines) in the touched files.
2026-08-24 21:44:02 +02:00
ARIA 597a28050f TLS: fingerprint-confirm flow for self-signed gateway certs (issue #14)
CI / Gateway plugin tests (push) Successful in 5m29s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m22s
docs/09 §9.4 promised a SSH-host-key-style fingerprint confirm on first
pair, but the app built a default OkHttpClient with no certificate
handling — self-signed gateway certs were simply rejected.

Build the flow:
- TlsPinning.kt: PinningTrustManager wraps the platform default trust
  manager; a rejected cert is accepted only when its SHA-256 fingerprint
  matches the user-confirmed pin, anything else fails with
  TlsFingerprintRequired (hostname verification still applies).
- SecureStore.pinnedCertFingerprint (Android EncryptedSharedPreferences +
  desktop settings.json), cleared on forget().
- GatewayClient: pinning socket factory on the shared client (all legs
  inherit it), new terminal State.TlsConfirmRequired, unwrap the nested
  TlsFingerprintRequired in connect loop / watchdog / SSE / poll /
  testHello.
- ConnectScreen: confirm dialog showing the fingerprint ("Confirm &
  pin" re-runs the connect); IrisApp routes TlsConfirmRequired there;
  ChatScreen + desktop tray handle the new state.
- Docs: §9.4 now describes the real flow (incl. SAN requirement), gap
  table item 6 → implemented, setup.md limitation note updated.
- Tests: TlsPinningTest (fingerprint vs openssl, pin accept/reject,
  live pin read, unwrap) + TlsPinningIntegrationTest (real TLS
  handshake: unpinned → confirm data, pinned → 200).

Live E2E verified on the phone: first pair against a self-signed
IRIS_HTTP_CERT gateway shows the dialog, confirm pins, chat works,
auto-reconnect after gateway restart uses the pin.
2026-08-24 21:30:42 +02:00
ARIA f90e40a3fc Fix README limit claims to match reality (issue #13)
CI / Gateway plugin tests (push) Failing after 1m4s
CI / Kotlin tests (android host + desktop) (push) Successful in 8m3s
- 'No file limit' -> 100 MB default, configurable via max_upload_bytes
- 'No character limit' -> 'No 4,096-character limit like Telegram'
  (hard frame-body cap: 1 MiB)
- Note that limits are set on the gateway side (hermes), not in the app
- Sync docs/playstore-listing.md (same overclaim)
2026-08-24 21:05:13 +02:00
Pakobbix 330e63e941 Merge pull request 'Split adapter.py monolith into focused modules; restore Ruff complexity defaults' (#15) from refactor/adapter-split into master
CI / Kotlin tests (android host + desktop) (push) Successful in 7m41s
CI / Gateway plugin tests (push) Successful in 8m46s
Reviewed-on: #15
2026-08-24 19:03:15 +00:00
ARIA b8e756c3dd Split adapter.py monolith into focused modules; restore Ruff complexity defaults (issue #12)
CI / Gateway plugin tests (pull_request) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 7m5s
adapter.py was a 3,493-line monolith. Split it into focused modules with
clear separation of responsibilities, bringing it down to ~857 lines:

- Module-level helpers: hooks, classify, pickers, commands, setup,
  defaults, secrets
- Frame-handler mixins: inbound, tool_frames, push_frames, media_frames,
  picker_frames, channel_frames, query_frames
- mixin_base: IrisAdapterBase (declaration-only base for shared attrs)
- adapter.py now holds only IrisAdapter (the composition of the 7 mixins
  + BasePlatformAdapter), register(), and test-facing re-exports

The mixins come before BasePlatformAdapter in the MRO so their methods
override the base; super() calls (e.g. send_image) still resolve to
BasePlatformAdapter. No circular imports; dispatch.py and http_server.py
(instance-method callers) are unaffected.

Ruff complexity ceilings (PLR0911/0912/0913/0915) restored to Ruff's
built-in defaults (12/50/6/5) instead of "just above the current maxima",
which ratchets the bar down as code grows. The existing genuinely-complex
functions (frame builders mirroring the wire schema, the QR matrix builder,
the dispatch table) carry an explicit `# noqa: PLR09xx` marking them as
reviewed, frozen exceptions; new code is held to the default ceilings.

All 125 tests green (94 test_android + 31 test_android_http); no new ruff
errors introduced.
2026-08-24 20:55:00 +02:00
ARIA 7faaf2aa1c Per-device tokens with revocation (issue #11)
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s
Auth previously used the shared IRIS_TOKEN as the security principal:
a leaked token meant access to all devices, and a compromised device
could not be isolated.

Gateway:
- pairing.py: devices.token column (in-place migration) + revoked
  denylist table; issue_token (idempotent, 64 hex), token_for,
  reissue_token, revoke/unrevoke/is_revoked/list_revoked. The token
  never leaks into device dicts (push fan-out / listings).
- http_server.py: auth accepts the shared token (bootstrap/legacy) OR
  the device's own token (both constant-time); a revoked device_id is
  rejected with 401 before either comparison. On SSE open (pairing)
  the per-device token is minted and returned in hello.ack.
- protocol.py: hello_ack(..., device_token).
- adapter.py: setup flow (hermes gateway setup -> Iris) now offers
  'Remove a paired device?' on an existing setup: numbered select
  menu (last option = exit the removal loop), confirmation, back to
  the menu for further removals.
- tools/iris_devices.py: operator CLI (list / revoke / unrevoke /
  reissue), stdlib only.

App:
- SecureStore.deviceToken (Android: EncryptedSharedPreferences;
  Desktop: second keyring slot iris-device-token / device_token.enc).
- HelloAckPayload.deviceToken; GatewayClient stores it on hello and
  presents it instead of the shared token from then on (live provider
  in HttpGateway); savePairing/clear wipe it for re-pairing.

Docs: 09 §9.3 stretch -> implemented (revocation semantics, both
control surfaces), 04 hello.ack example, frames.schema.json, M7 row 13.

Tests: 8 new Python tests (issuance, acceptance, revocation,
isolation, unrevoke, registry unit x2, setup-flow menu) - 94/94 pass;
2 new Kotlin wire tests - green. Live-verified against a running
gateway (hello.ack token matches devices.db; revoke -> 401 even with
shared token; unrevoke -> 200; setup TUI both paths).
2026-08-24 19:37:44 +02:00
ARIA 746d809d48 Default push backend to ntfy; FCM opt-in with privacy warning (issue #10)
CI / Gateway plugin tests (push) Successful in 5m3s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m6s
- IRIS_PUSH_BACKEND now defaults to ntfy (keeps push metadata on your own
  infrastructure); FCM is opt-in via IRIS_PUSH_BACKEND=fcm
- build_push_backend(): ntfy for empty/unknown names, FCM only on explicit 'fcm'
- gateway setup: warn when FCM is chosen (metadata routed via Google's servers)
- README: privacy note + dedicated push section; new docs/playstore-listing.md
  with the FCM/ntfy privacy note for the Play Store listing
- docs: 00/02/03/08/12/16 + setup.md updated to ntfy-default wording
- tests: default-backend assertion updated (86/86 pass)
2026-08-24 19:04:40 +02:00
ARIA 70282dfb65 fix(release): use Forgejo-style /assets and /tags API routes
CI / Gateway plugin tests (push) Successful in 4m55s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m48s
The server (gitea.zephyre.one) exposes a Forgejo-compatible API:
- release attachments live at POST /releases/{id}/assets, not /attachments
- tag deletion is DELETE /tags/{tag}, not DELETE /git/refs/tags/{tag}
(verified against the live API: /attachments 404s, /assets and /tags exist)
2026-08-23 19:53:17 +02:00
ARIA 44e8c7322e fix(release): delete leftover tag on re-run; support \\n in changelog input
CI / Gateway plugin tests (push) Successful in 5m0s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
- Gitea's DELETE /releases/:id does not remove the tag, so re-running the
  workflow for the same version failed with 409 (curl exit 22). The
  re-run safety block now also deletes the tag via git/refs/tags.
- Replace curl -sf with an api() wrapper that prints Gitea's error body
  on HTTP >= 400 instead of failing silently.
- The workflow_dispatch changelog input is single-line (Gitea has no
  multiline input type); convert literal \\n to real newlines and
  document it in the input description.
2026-08-23 19:03:59 +02:00
ARIA 29d0c1a73f Fix two gateway test failures: outbox lane scoping + SSE teardown race
CI / Gateway plugin tests (push) Successful in 5m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
- outbox: delete_message/message_info now match the exact lane first
  (a flat-lane delete/lookup with thread_id=None sees only frames with
  no thread_id) and fall back to the message_id across all lanes only
  when the exact lane matches nothing. Previously lane=None meant
  'any lane' in the first pass, so a flat-lane delete also removed
  same-id frames from threads (test expected 3 removed, got 4).

- http_server: the SSE live loop skipped queued frames when stop() set
  sub.closed before the handler thread reached the loop (descheduled
  under load between the initial hello/status writes and the loop).
  The loop now drains frames queued before the close, so the
  status{restarting} teardown broadcast always reaches the client
  before EOF (test_disconnect_broadcasts_status_restarting was flaky
  ~70% under CPU load).
2026-08-23 14:45:09 +02:00
ARIA d801a18db5 fix FCM push notifications
CI / Gateway plugin tests (push) Failing after 6m27s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m2s
2026-08-23 14:32:40 +02:00
Pakobbix 560d9c19b3 Merge pull request 'feat(app): copy messages via bubble context menu + selection toolbar' (#9) from feat/message-copy-context-menu into master
CI / Kotlin tests (android host + desktop) (push) Successful in 9m0s
CI / Gateway plugin tests (push) Failing after 9m18s
Reviewed-on: #9
2026-08-23 11:10:30 +00:00
ARIA 32db7fc4e8 feat(app): copy messages via bubble context menu + selection toolbar
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 9m55s
Long-press (touch) / right-click (desktop) on a finalized message now
opens a context menu anchored to the bubble: Copy / Select messages /
Delete. The multi-select toolbar gains a Copy button that joins the
selected messages in display order. Copies raw markdown so formatting
survives pasting into other markdown apps; blank text is a no-op.
Cancelling a menu-opened delete confirm clears the staged selection so
the bubble doesn't stay highlighted.
2026-08-23 13:08:44 +02:00
ARIA 863ab34915 fix(app): apply whole-review fixes (HIGH/MEDIUM/LOW) + dead code & stale comments
CI / Gateway plugin tests (push) Failing after 6m35s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
HIGH:
- ntfy listener: replace blocking exhausted() loop with SSE read + capped
  exponential-backoff reconnect; 60s read timeout
- GatewayClient.stop(): reset HTTP leg (http, httpCursor, sseFailures,
  usingLongPoll, lastAck)
- non-atomic shared state -> synchronized/@Volatile/AtomicLong/
  CopyOnWriteArrayList

MEDIUM:
- mediaId path-traversal guard (isValidMediaId) at network/app/fs boundaries
- loadFromCache: move ts=0 pending bubbles to end, keep stored order
- attachment placeholder tracked by identity, not filename
- optimistic ChannelStore updates on favorite/icon/automation/default
- secure-store caching (desktop map, android store)
- secret passed to keyring via stdin (macOS + Linux)
- SecureStore.clear() clears deviceId/syncCursor/fcmToken/ntfy*
- random ids for system messages; SSE EOF reconnect delay
- PowerShell $ escaping; dispose() cancels job before saving flows
- wire up "Forget pairing" in Settings
- move machine-specific org.gradle.java.home to user-level gradle.properties

LOW + dead code + stale comments:
- .aac->audio/aac; locale-fixed cost/size; 3-digit hex; hour+ latency
- Backdrop.DEFAULT defined once; notification id 24-bit; channel id cap
- remove dead FileSource, unused protocol/theme/media constants, empty
  onDispose, SDK_INT<O guard, hostFromUrl
- fix stale WS/SSE, M1/M5, and milestone KDoc comments

Verified: Kotlin desktop+android host tests, 100/100 Python gateway tests,
LSP clean, installed & running on device.
2026-08-23 12:57:35 +02:00
ARIA a4e4a4ea63 Fix flaky message deletion: exact lane match in outbox history + message_id fallback on delete
CI / Kotlin tests (android host + desktop) (push) Successful in 7m4s
CI / Gateway plugin tests (push) Failing after 8m46s
A flat-lane history (thread_id=None) returned frames from ALL threads, so
auto-threaded messages leaked into the flat lane on restart. Deleting them
from the flat lane then sent thread_id=None, which matched nothing in
outbox.delete_message/message_info (exact lane match) -> removed=0, no
session-store purge, and the messages resurrected from the outbox on the
next app restart.

- history: exact lane match (flat lane shows only flat-lane frames, per
  docs/06 §6.3)
- delete_message / message_info: fall back to the unique message_id (uuid4)
  when the exact lane matches nothing, so deletes with a stale/missing
  thread_id still remove the frames and the purge finds its row
2026-08-23 11:13:51 +02:00
ARIA ca622d3a39 added a watchdog to force the SSE stream open
CI / Gateway plugin tests (push) Successful in 6m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m18s
2026-08-23 10:56:37 +02:00
ARIA abbed438ec fix(app): don't re-show notification banners on app reopen
CI / Gateway plugin tests (push) Successful in 6m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m44s
The persisted sync cursor only advanced on sync.done, so on every
restart the event stream re-delivered all frames consumed since the
last sync.done - and replayed notification frames re-showed their
banner (e.g. 'thread deleted' again after close/reopen).

Track the consumed outbox high-water cursor in the controller,
persist it (debounced + on backgrounding), and skip notification
frames whose cursor is at/below the mark (also covers the duplicate
delivery of SSE catch-up + explicit sync replay on reconnect).
2026-08-23 01:42:55 +02:00
ARIA 6458c3183c feat(app): unread message indicator (channel view, header, hamburger)
CI / Gateway plugin tests (push) Successful in 6m18s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
Tracks per-lane unread counts for finalized assistant messages. A message
counts as unread unless the user is actively reading that lane (current
lane, app focused, newest content at the bottom of the viewport).

- ChatStore: ephemeral per-lane unread map (markUnread/markLaneRead/unreadFor).
- IrisController: 'being read' decision on incoming messages; clears the
  current lane when the app returns to the foreground at the bottom.
- Bridges: push foreground changes to the controller.
- ChatScreen: per-channel badges in the drawer/rail, an 'N new' pill in the
  header (tap glides to the latest message), and a red dot on the hamburger
  (single-pane/mobile only) when another channel has unread.

Closes #3.
2026-08-23 01:34:42 +02:00
ARIA 9c50f2dbc1 feat(approvals): render exec approvals as interactive picker buttons
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
Issue #4: approvals were only sent as a banner + text /approve prompt,
while the app already had the interactive choice-picker card (used by
clarify and slash commands).

Add send_exec_approval() to the iris adapter. Hermes auto-detects this
method and calls it when the agent wants to run a dangerous command. It
now emits a high-priority approval notification (wakes a backgrounded
device) plus a picker.choice card showing the command + reason with
Allow Once / Session / Always / Deny buttons (gated by the same
allow_session/allow_permanent/smart_denied flags as the native adapters).
A tap resolves via resolve_gateway_approval (same primitive as the text
/approve and /deny handlers), unblocking the agent, and posts a short
confirmation. No live device -> report failure so hermes falls back to
the text prompt.

No app changes needed: picker.choice cards are rendered generically.
2026-08-23 01:16:13 +02:00
ARIA 487fd1c83c One-line tool cards in truncated mode
CI / Gateway plugin tests (push) Successful in 5m0s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m27s
When tool verbosity is 'truncated' (the default) and the card is
collapsed, fold the preview into the title row so a tool call takes a
single line of vertical space: 'Terminal | ls -lah' with the preview
in monospace. Tapping still expands to the full args + output.
2026-08-23 01:01:04 +02:00
ARIA 4e9f1d028a docs(07): document media size limits, storage locations, outbound asymmetry
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
2026-08-23 00:48:25 +02:00
ARIA 742916903b Live agent todo list: compact scrollable strip above the composer
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m47s
Add a todo.update frame (server->app) carrying the agent's full current
todo list. The gateway emits it whenever the hermes todo tool completes
(the tool result is authoritative even for merge writes) and re-sends a
snapshot right after hello so a reconnecting device re-learns the plan.
Ephemeral: never outboxed.

The app renders it as a compact strip above the composer (max 3 lines,
the rest scrollable) mirroring the hermes desktop composer status stack:
pending = hollow ring, in_progress = spinner, completed = green check,
cancelled = struck through. It auto-scrolls to the current task whenever
the active task changes, and hides itself once the list is empty or fully
resolved.
2026-08-23 00:23:02 +02:00
ARIAandClaude Opus 4.8 1ff2ef380c Render single-select clarify prompts as interactive pickers
CI / Gateway plugin tests (push) Successful in 5m2s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m34s
Clarify questions with a finite option set now render as the same
tappable picker card used by /reasoning and /fast, instead of a numbered
text list. The change is gateway-only: it reuses the existing
picker.choice frame and the app's PickerCard UI, so no app change or
reinstall is needed.

- send_clarify: single-select + live device emits a picker.choice frame
  (one button per option + an "Other (type your answer)" button) and
  registers a pending picker; the selection resolves via
  resolve_gateway_clarify (the agent then continues and replies).
- "Other" flips the entry to text-capture (mark_awaiting_text) and
  prompts the user to type; an unmappable value also flips to text so a
  clarify never dead-ends.
- Multi-select, open-ended, and no-live-device clarifies keep the
  numbered-text fallback (unchanged behavior).
- New helpers _clarify_is_multi / _clarify_picker_callback; positional
  option values (c0..cN, other) mirror the relay adapter.

Tests: updated test_clarify_emits_banner_and_message to multi-select
(text fallback) + 3 new tests (single-select emits picker & resolves,
Other flips to text, no-device falls back to text). Full suite 90/90.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-22 23:51:14 +02:00
ARIAandClaude Opus 4.8 82c5a20848 Add interactive choice-picker menus for finite-choice slash commands
CI / Gateway plugin tests (push) Successful in 4m48s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
Slash commands with a finite set of options (/reasoning, /fast, ...) now
render a tappable card with buttons (2 per row, ✓ on the current value)
instead of a plain text status card. The mechanism is generic: any command
that calls the adapter's send_choice_picker() gets a picker automatically.

Wire protocol (docs/04, frames.schema.json):
- picker.choice (server→app): {picker_id, title, choices[]}
- picker.select (app→server): {picker_id, value}
- pickers capability flag now True in server_caps

gateway-plugin:
- protocol.py: picker.choice/picker.select frame types + picker_choice()
- dispatch.py: route picker.select → adapter.on_picker_select
- adapter.py: send_choice_picker() (fails cleanly with no live device so
  hermes falls back to text), on_picker_select(), in-memory pending pickers
  (gateway restart expires them; stale select is a no-op), pickers=True

app (KMP):
- Protocol.kt: PickerChoice/PickerChoicePayload + pickerSelectFrame()
- ChatStore.kt: PickerItem + onPickerChoice (idempotent) + resolvePicker
  (optimistic, one-shot)
- ChatDb.kt: persist PickerItem in the messages table (polymorphic decode)
- IrisController.kt: picker.choice routing + selectPicker() action
- ChatScreen.kt: PickerCard composable (locks after selection)

Tests:
- python: 3 picker tests (roundtrip, no-device fallback, stale-select noop)
- kotlin: ChatStorePickerTest (add/idempotent/resolve/one-shot/noop/serialize)
- fixture fix: clear leaked IRIS_HTTP_PORT/IRIS_WS_HOST env so the adapter
  binds the ephemeral port (a prior test's interactive_setup() polluted the
  process env, colliding with a live gateway on 8791)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-22 23:33:53 +02:00
ARIA 34a64d6e53 AGENTS.md: clarify commit/push scope (all changes, except hermes-agent/)
CI / Gateway plugin tests (push) Successful in 6m7s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m50s
2026-08-22 22:46:41 +02:00
ARIA 7a6d922d12 Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s
2026-08-22 22:43:13 +02:00
ARIA 27dc7917f2 Added log handling for failed phone connection to the gateway
CI / Gateway plugin tests (push) Successful in 4m50s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m45s
2026-08-22 21:08:06 +02:00
ARIA 4866f14231 chat: natural reading scroll for user and agent messages
CI / Gateway plugin tests (push) Successful in 4m46s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m14s
- User send: always scroll so the bottom of the own message is visible
  above the composer (scroll = new content: message + spacers), even
  while reading history.
- Agent message: while at the bottom, land on the natural reading
  position — top-aligned with the viewport top when the bubble is
  taller than the viewport, bottom-aligned otherwise.
- Streaming follow: keep the live bubble's bottom visible while it fits
  the viewport; once it outgrows the viewport, top-align it once and
  hand over to the user (no yank-back on later deltas).
2026-08-22 20:55:35 +02:00
ARIA 8fba00ba7f android: shrink composer pill; float it over the message list
CI / Gateway plugin tests (push) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s
- reduce composer size (padding, corner radius, send button 40->36dp)
- move composer plane (back-to-bottom button, attachments, banners,
  slash drawer, input pill) into a bottom overlay so messages scroll
  behind it; overlay height drives the list's bottom content padding
2026-08-22 20:36:33 +02:00
ARIA 7f0bdcbbc1 Per-tool emoji on tool cards (gateway-resolved via hermes get_tool_emoji)
CI / Gateway plugin tests (push) Successful in 5m6s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m45s
tool.start gains an optional cosmetic 'emoji' field, resolved server-side
through hermes' own display layer (active-skin overrides, then the tool
registry's per-tool emoji) so icons track the user's hermes theme and
new/plugin tools get their glyph for free. Omitted for unknown tools so
the app falls back to its default wrench.

- protocol.py: tool_start(emoji=...) kwarg, payload field when set
- adapter.py: _tool_emoji() helper (lazy import, None on unknown/failure)
- frames.schema.json + docs/04: field documented
- app: ToolStartPayload.emoji -> ToolItem.emoji -> ToolCard header
- tests: frame shape, resolution/fallback, end-to-end tool.start emoji
2026-08-22 20:26:38 +02:00
ARIA e6015033b6 HTTP transport: drop WS server, offline send queue + dead-stream watchdog
CI / Gateway plugin tests (push) Successful in 5m9s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m55s
Gateway (docs/19):
- Remove ws_server.py; frame dispatch factored into dispatch.py
- http_server: media upload/pull, pairing over HTTP
- protocol: media frames mirrored; tests + ws_probe updated for HTTP

App:
- HttpGateway: postFrame/uploadMedia/pullMedia no longer throw on
  network failure (PostResult ok=false / Result.failure) — uncaught
  SocketTimeoutException on Dispatchers.Default crashed the app
- GatewayClient: dead-stream watchdog (health probe every 10s, 2
  failures -> redial in ~20s instead of the 45s SSE read timeout);
  state flips to Reconnecting when the stream dies, restored from the
  last hello.ack on long-poll success; poke() + backoff reset on app
  resume (MainActivity.onResume)
- Offline sends: composer enabled while disconnected; a send with no
  response (status 0) stays queued (Pending) and is auto-resent on the
  next (re)connect after a 2s outbox-replay grace; gateway 4xx
  rejections fail the bubble (tap to retry, no auto-loop)
- ChatStore: echo-replace and thread-relocate also match Failed
  bubbles (POST response lost in a network drop); loadHistory dedupes
  local failed bubbles the server already has; failMessage()
- MainActivity: poke() on resume so a backgrounded app reconnects
  promptly instead of waiting out the backoff
2026-08-22 20:10:05 +02:00
ARIA 2349a95dd4 HTTP fallback leg (docs/19): e2e scenario 13 + docs
- e2e.py: scenario 13 (http fallback) — drives a full turn over the
  HTTP leg (health + POST /v1/frame + SSE /v1/events, no WS) and
  asserts the user echo lands on the SSE stream in < 1.5 s.
- ws_probe.py --http: prints '== user echo in X.XXs' (the docs/19
  sendable-in-fallback timing assertion) alongside the existing
  health/POST/SSE output; same assertion flags as the WS leg.
- docs: 19 status flipped to implemented; 09-pairing-security §9.4
  cross-reference (second door, same lock: token + device allowlist,
  64 KiB cap, rate limit, optional TLS, unauthenticated /v1/health);
  13-testing manual scenario 15 + automated pointers.
2026-08-22 14:34:14 +02:00
ARIA 7f936fa596 HTTP fallback leg (docs/19, app): State.HttpFallback + SSE/long-poll receive
When the WS is down but the gateway is reachable over HTTP, the app
stays sendable instead of waiting out the WS redial backoff:

- HttpGateway.kt: OkHttp client for the HTTP leg — GET /v1/health
  (2 s probe), POST /v1/frame (accept-and-ack; 4xx error frames parsed),
  SSE GET /v1/events (hand-rolled line parser: event/id/data, comments,
  multi-line data, Last-Event-ID bookkeeping), long-poll GET /v1/poll.
  Per-purpose call timeouts (SSE heartbeat 15 s / poll hold 25 s exceed
  OkHttp's 10 s default read timeout). deriveHttpUrl: ws(s)://host:port
  -> http(s)://host:8791 (pure, unit-tested).
- GatewayClient.kt: new State.HttpFallback (sibling of Connected, both
  implement State.HelloInfo). connectLoop races the WS dial against the
  health probe (sendable in < 1 s on a dead WS port); on WS loss it
  enters fallback immediately (no backoff gate on the send path); on WS
  reconnect it stops the HTTP leg (media available again). sendMessage/
  sendFrame route to POST in fallback (mediaRefs dropped — media is
  WS-only in v1); SSE frames feed the same _events flow, so request-id
  correlation is unchanged. The SSE hello is treated like hello.ack
  (caps/channels/lastPushedCursor + onHelloAck fast path). After two
  consecutive SSE open failures the receive loop switches to long-poll
  until the next full (re)connect.
- IrisController.kt: onHelloAck/onConnectedLane take State.HelloInfo;
  state collector + deep-link/commands-catalog gates accept fallback.
- ChatScreen.kt: send gate accepts fallback; attach button disabled in
  fallback (media needs the live connection); status pill shows
  'connected · http' (green).
- Tests: HttpGatewayTest (URL derivation + SSE parser, 8 tests); full
  :shared desktop + android-host suites green.
2026-08-22 14:31:46 +02:00
ARIA e5c7d690b8 HTTP fallback leg (docs/19): POST /v1/frame + SSE /v1/events + long-poll
Second, short-lived-connection transport next to the WS: same frames,
same outbox/cursor, same token, served over plain HTTP (stdlib
ThreadingHTTPServer bridged into the asyncio loop; zero new deps).

- http_server.py: /v1/health (unauthenticated), POST /v1/frame
  (accept-and-ack; validation rejections as 4xx error frames), SSE
  /v1/events (outbox catch-up with id=cursor, event: hello, 15s
  heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold).
  Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body
  cap, per-device rate limit, optional TLS, non-fatal bind failure.
- ws_server.py: inbound dispatch chain extracted to shared
  dispatch_frame() used by both transports.
- adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the
  WS; delivery counting in _broadcast_or_log (an SSE subscriber is a
  live subscriber -> no push, docs/19 19.8); _reply() routes
  point-to-point replies into the in-flight HTTP response (reply sink)
  or broadcasts when the device has no live WS (19.7); status/typing/
  channel events fan out to both transports.
- ws_probe.py: --http mode (health + POST + SSE turn drive, same
  assertion flags); tests/README updated.
- Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests,
  incl. the 19.8 delivery-counting regression); test_android.py (74)
  still green.
2026-08-22 14:10:14 +02:00
ARIA 524ed8ce53 Fixed tool calling history
CI / Gateway plugin tests (push) Successful in 5m30s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
2026-08-22 13:22:07 +02:00
ARIA dd43033888 Chat: scroll to bottom of newest message on open and on new messages
CI / Gateway plugin tests (push) Successful in 4m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m37s
scrollToItem(last) top-aligns the last row, so a final message taller
than the viewport stayed cut off at the bottom. New scrollToBottom()
brings the last row into view, then aligns its bottom edge with the
viewport bottom. The isAtBottom check now also treats 'can't scroll
forward' as at-bottom, so auto-scroll and the back-to-bottom button
behave correctly when the newest message is taller than the viewport.
2026-08-22 11:37:30 +02:00
ARIA 6591d7cec0 Gateway restart notices: explicit status{restarting} signal, correct timing
CI / Gateway plugin tests (push) Successful in 4m22s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m41s
The app previously showed 'Gateway restarting' on every connection loss.
Now the gateway broadcasts status{state=restarting} on its shutdown path
(before closing the sockets), and the app:

- posts 'Gateway restarting' immediately on that frame (not on the
  socket-drop transition, which lags by the ~20s WS ping timeout)
- posts 'Gateway online' on the next reconnect only when the restart
  notice was posted (latch) - a plain network drop shows neither, just
  the reconnect banner
- drops the 'Gateway is restarting...' banner (replaced by the chat notice)

Docs (04-wire-protocol, frames.schema.json) updated: restarting is no
longer reserved. Test for the disconnect broadcast added to the local
hermes-agent test mirror (git-ignored, not committed).
2026-08-22 11:31:10 +02:00
ARIA 3a33f6be15 formatting changes
CI / Gateway plugin tests (push) Successful in 4m43s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
2026-08-22 11:07:24 +02:00
ARIA c71636ad55 Release: single build+release job (Gitea has no artifacts API); fakeroot for deb
CI / Gateway plugin tests (push) Successful in 4m38s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m35s
- upload-artifact@v4+ fails on Gitea/act_runner (GHESNotSupportedError —
  the GitHub artifacts API is not implemented), so merge the android,
  desktop and release jobs into one: build APK+AAB + desktop zip/deb,
  then create the release and upload attachments from the workspace
- jpackage --type deb needs fakeroot, which the runner image lacks —
  install it via apt
- sync CI-SETUP.md §3 with the restructured workflow
2026-08-22 04:12:15 +02:00
ARIA 5a69e3927b Fix CI: uv sync --extra dev; sdkmanager licenses without SIGPIPE
CI / Gateway plugin tests (push) Successful in 4m35s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m37s
- pytest lives in hermes-agent's dev extra; plain uv sync left the CI venv
  without it and run_tests.sh refused to run (gateway job)
- 'yes | sdkmanager --licenses' dies with SIGPIPE (exit 141) under Gitea's
  bash -e -o pipefail; feed a finite number of y's from a file instead
  (kotlin + android jobs)
2026-08-22 03:42:15 +02:00
ARIA 1f182a7e7e Release: also build AAB; fix keystore key-password guidance
- release.yml android job: build APK + AAB (bundleRelease/bundleDebug)
- androidApp: versionCode overridable via -PappVersionCode (Play requires
  an incrementing versionCode per upload)
- make_release_keystore.sh: PKCS12 has no separate key password (keytool
  ignores -keypass) — print the store password for ANDROID_KEY_PASSWORD
2026-08-22 03:33:58 +02:00
ARIA 7309158e12 Add Gitea CI + release workflows (CI-SETUP.md)
CI / Gateway plugin tests (push) Failing after 2m6s
CI / Kotlin tests (android host + desktop) (push) Failing after 4m51s
- .gitea/workflows/ci.yml: gateway plugin tests + Kotlin host tests on
  push/PR (hermes-agent cloned at pinned SHA, vendored test mirror)
- .gitea/workflows/release.yml: manual dispatch; runs tests, builds signed
  Android APK (debug fallback) + Linux desktop app-image/deb via jpackage,
  creates Gitea release v<version> with artifacts (re-run safe)
- androidApp: versionName from -PappVersion, CI release signing from
  ANDROID_KEYSTORE_* env vars
- desktopApp: jpackage --app-version from -PappVersion
- scripts/make_release_keystore.sh: one-time keystore + Gitea secret setup
- vendor gateway-plugin/tests/test_android.py (byte-identical mirror) and
  ignore it in .pi-lens.json
2026-08-22 03:32:07 +02:00
ARIA 8c09aecf4a docs: add trailing newline to AGENTS.md 2026-08-21 21:00:52 +02:00
ARIA 060420c615 docs: fix Kotlin test task name in AGENTS.md
testDebugUnitTest does not exist; the real host-side tasks are
testAndroidHostTest / desktopTest (jvmTest is the shared source set).
2026-08-21 20:56:24 +02:00
ARIA 81f42ab761 Local message cache: instant start + offline reading (SQLDelight)
- Cache.sq / ChatDb: lanes, channels and last_lane persisted as JSON
  snapshots; sanitize on restore (Pending->Failed, streaming->false);
  ephemeral items (tool, system) skipped
- Platform drivers via expect/actual: AndroidSqliteDriver (app db dir)
  / JdbcSqliteDriver (~/.iris/iris_cache.db)
- IrisController: restore before connect, debounced (750ms) snapshot
  persistence, synchronous flush on dispose, clearAll on forget
- History robustness: historyLoaded marked only when the response is
  processed (lost request/response retried on reconnect); events
  collector wrapped in try/catch; onHelloAck fast path so the history
  request fires on the WS thread instead of the starved state
  collector; frame-decode and history-load logging
- Protocol: HistoryMessage.media nullable (defensive vs older
  gateways that sent "media": null)
- Tests: ChatDbTest (jvmTest, JDBC in-memory), ChatStoreCacheTest,
  HistoryPayloadTest, HistoryWireTest (real captured 92KB response)
- docs/10: §10.7 implemented schema, new §10.9 cache behavior
2026-08-21 20:56:20 +02:00
ARIA 9a519e3c5a Omit null media in history frames (schema-conformant)
history() wrote "media": null for streaming finals, but the frame
schema says array. The app's HistoryMessage.media was non-nullable, so
deserialization threw and every history page was silently dropped.
Omit the key when media is absent instead.
2026-08-21 20:56:16 +02:00
ARIA 17bf41a0b9 Make thread/channel/message deletion complete (hard delete)
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.
2026-08-21 19:47:55 +02:00
ARIA 9286937e2d Runtime footer: app-controlled model/context/cwd/latency/cost under replies
Hermes can append a text "runtime footer" (model, context %, workdir,
latency, cost) to final replies, but only when display.runtime_footer is
enabled in the hermes config. We want the same info but controlled by the
APP, not the gateway config. So the gateway now ALWAYS sends the data as a
structured `runtime` object on final assistant messages, and the app decides
whether/what to show.

Gateway (gateway-plugin/):
- protocol.py: new runtime_footer() helper + `runtime` field on the
  message / message.stop frames. Keys (all optional, absent when the data
  is unavailable — e.g. no cost for local models): model (vendor prefix
  dropped), context_pct (0-100), cwd (home-relative), latency (seconds),
  cost (USD).
- adapter.py: a post_api_request plugin hook captures the turn's model +
  prompt tokens + start time (platform-filtered to android so other
  platforms don't pollute the buffer). _build_runtime_footer() resolves the
  model's context window (cached, best-effort, off the event loop via
  asyncio.to_thread with a timeout) and computes context_pct. The runtime
  object is attached on every final send (streaming message.stop and
  non-streaming message, plus the fallback paths).
- outbox.py: `runtime` preserved in history reconstruction so the footer
  survives a restart / first open.

App (app/shared/):
- Protocol.kt: RuntimeMeta data class + `runtime` on MessagePayload /
  MessageStopPayload / HistoryMessage.
- ChatStore.kt: `runtime` on MessageItem, wired through live + history
  reconciliation.
- SecureStore.kt (+ Android/Desktop actuals): runtimeFooterEnabled +
  runtimeFooterFields (persisted per device).
- IrisController.kt: StateFlows + toggleRuntimeFooter() /
  toggleRuntimeField(); RUNTIME_FIELD_KEYS / default set / parser.
- SettingsScreen.kt: "Runtime footer" switch; when on, an expandable chip
  menu (Model · Context % · Workdir · Latency · Cost) to pick fields.
- ChatScreen.kt: footer rendered on the SAME line as the timestamp (footer
  left, time right, Telegram-style), only for final non-streaming assistant
  answers; Inspector pane now shows the runtime fields too.

Docs: 04-wire-protocol.md + frames.schema.json document the `runtime`
object.

Verified end-to-end on device: final replies carry
`qwen3.8-27B-exl3-4.5bpw · 53% · ~ · 38s` with the time right-aligned on
the same line; 69/69 gateway tests pass, Kotlin builds + tests pass.
2026-08-21 19:32:05 +02:00
ARIA 678c0344c8 Clean up lint/LSP across gateway, Android, and desktop (alpha -> stable)
Gateway (gateway-plugin/):
- Fix interactive_setup broken imports: print helpers were imported from the
  wrong hermes module (hermes_cli.config instead of hermes_cli.cli_output) plus
  a non-existent print_code; the try/except swallowed the ImportError so
  `hermes gateway setup` for android always bailed out early.
- Fix release_scoped_lock type error (str | None passed where str required).
- Rewrite empty `except: pass` blocks as contextlib.suppress with rationale.
- Restructure two ambiguous ws_server try blocks (hello-auth, frame loop).
- Ruff cleanup: type annotations, import sorting, line wrapping, magic values
  -> named constants, `raise ... from e`, complexity. Add gateway-plugin/ruff.toml.
- Add pyrightconfig.json so the Python LSP resolves hermes-runtime imports.
- Suppress verified false positives inline (parameterized SQL, column-name
  "secrets", hermes-generated media path).

Android (app/androidApp + app/shared):
- Consolidate launcher icons into a single mipmap-anydpi (minSdk 29 >= 26) with
  the monochrome layer; clears ObsoleteSdkInt + MonochromeLauncherIcon.
- Bump core-splashscreen 1.0.1 -> 1.2.0; pin targetSdk 34 (deliberate).
- Suppress verified findings inline (LAN ws:// default, correct GCM IV usage).

Desktop (app/desktopApp):
- Move the desktop to a Java 21 runtime (org.gradle.java.home) and set the
  desktop jvmTarget to 21 (Android stays JVM 17 / minSdk 29). Fixes the startup
  UnsupportedClassVersionError and restores Markdown renderer 0.44.0.

Tooling/config:
- .pi-lens.json: disable verified-noisy heuristics (documented in docs).
- .gitleaks.toml: allowlist git-ignored false-positive paths.
- docs/18-code-review.md: full findings + verification.

Verified: ruff clean, pyright 0 errors, 64/64 gateway tests, all Kotlin tests,
Android lint 0 issues, Android installed+launched on device, desktop launches
on JDK 21.
2026-08-21 18:47:03 +02:00
ARIA 9f3f9842c8 Push notification dedupe: one message = one notification — an offline message was notified twice (FCM push, then again when the app synced the outbox and mirrored the replayed frames). Fix: the gateway records the highest outbox cursor delivered per device via push (devices.last_pushed_cursor, advanced only on successful send) and returns it in hello.ack; sync-replayed frames carry their outbox cursor in the envelope; the app skips system notifications for replayed frames at/below the watermark (live frames never suppressed — that is the case where no push fired). Also: 5s per-chat push coalescing so a cron delivery (notification frame + message frame) pushes once, and the FCM handler no longer posts a redundant notification (skips when WS is connected or FCM already displayed the notification payload; data-only messages are the exception). Docs: frames.schema.json, 04-wire-protocol.md, 08-push.md §8.8 2026-08-21 17:21:54 +02:00
ARIA acd5fb4ad0 Ntfy listener: only run when the paired gateway pushes via ntfy — the foreground service (and its permanent 'Listening for messages' notification) is now started/stopped based on the gateway's push backend from hello.ack server_caps (persisted as pushBackend); FCM gateways no longer keep a persistent listener alive, and switching gateways toggles the service on the next connect 2026-08-21 17:21:47 +02:00
ARIA 75d491fd30 Added default backdrops to the chat. 2026-08-21 15:48:30 +02:00
ARIA 96b60daf08 Update dependencies to latest + migrate to AGP 9.x
Kotlin 2.1.0->2.4.10, Compose Multiplatform 1.7.3->1.11.1, AGP 8.7.3->9.3.1, Gradle 8.10.2->9.7.1. The two libs that pinned us to Compose 1.7.x -- compose-webview-multiplatform (1.9.40->2.0.3) and multiplatform-markdown-renderer (0.33.0->0.44.0) -- now have Compose 1.8+ releases, so the whole stack moves. Also coroutines/serialization 1.11.0, okhttp 5.5.0, media3 1.11.0, activity-compose 1.13.0, firebase-messaging 25.1.2, compose-bom 2026.08.00, google-services 4.5.0, KCEF 2025.03.23 (JBR pin ->17.0.14); material3 1.9.0 / material-icons-extended 1.7.3 (each one's latest stable -- they lag the core).

AGP 9 migration: :shared swaps com.android.library->com.android.kotlin.multiplatform.library (android config moves into kotlin{android{}}, withHostTest{} keeps host tests); :androidApp drops kotlin(android) for AGP 9 built-in Kotlin. compileSdk 34->37 (minSdk stays 29 / Android 10).

Code fixes for API breaks: markdown link->textLink + highlights->highlightsBuilder; kotlinOptions{jvmTarget}->compilerOptions{jvmTarget}.
2026-08-21 12:14:09 +02:00
ARIA ec53769325 Chat bubbles: preserve single newlines in agent messages too — the hard-break rule from user bubbles now applies to agent replies as well, so status lines and model-wrapped text render one line per field instead of collapsing into a wall of text; unit tests for preserveNewlinesAsHardBreaks 2026-08-21 10:26:35 +02:00
ARIA 1bcadcf950 Added slash command handling 2026-08-21 10:18:48 +02:00
ARIA 10e9565e2e Channels: default-only threading + automation (read-only) channels — threading (topic switcher, Ctrl+T, auto-threading) is now only active on the default channel, and the gateway ignores auto_thread for other channels; new channel.set_automation frame marks a channel read-only for cron/webhook output (app hides the composer behind a notice, gateway rejects message.send, default channel cannot be marked, flag syncs to all devices via the full-entry channel.renamed response, gear badge in the list); header pill drops the 'Bot' subtitle and shows the chat_id on automation channels (tap to copy for cron delivery targeting); also fixes a pre-existing stray brace that made frames.schema.json invalid JSON 2026-08-21 09:39:32 +02:00
ARIA d7cd59b685 AGENTS.md: document the uiautomator workflow for ADB UI taps — never guess coordinates from a screenshot; dump the hierarchy, find the node by text/content-desc/resource-id, tap the center of its bounds, re-dump after navigation 2026-08-21 09:02:52 +02:00
172 changed files with 23566 additions and 5790 deletions

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
gateway:
name: Gateway plugin tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
# The gateway tests run inside the hermes-agent test harness, which is
# git-ignored in this repo (read-only research reference). CI clones the
# upstream repo at a pinned commit and drops in the vendored test copy.
# Bump the pinned SHA when you update the local hermes-agent checkout.
- name: Clone hermes-agent (pinned)
run: |
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
- name: Sync venv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
cd hermes-agent
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
# venv without it and run_tests.sh refuses to run.
uv sync --extra dev
- name: Run android gateway tests
run: |
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py
kotlin:
name: Kotlin tests (android host + desktop)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# gradle.properties pins org.gradle.java.home to a local JDK path;
# strip it so CI uses the JDK installed by setup-java.
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
# Host-side tests only (no device needed). AGP auto-downloads the
# missing SDK platforms (licenses accepted above).
- name: Run host tests
working-directory: app
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
+226
View File
@@ -0,0 +1,226 @@
name: Release
on:
workflow_dispatch:
inputs:
# The release version comes from the repo-root VERSION file (the single
# source of truth) — bump it in a commit, then dispatch this workflow.
changelog:
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false
type: string
jobs:
gateway:
name: Gateway plugin tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Clone hermes-agent (pinned)
run: |
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
- name: Sync venv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
cd hermes-agent
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
# venv without it and run_tests.sh refuses to run.
uv sync --extra dev
- name: Run android gateway tests
run: |
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py
kotlin:
name: Kotlin tests (android host + desktop)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
- name: Run host tests
working-directory: app
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
# Gitea/act_runner does not implement the GitHub artifacts API
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
# the release creation happen in ONE job — no artifact handoff between jobs.
release:
name: Build + create Gitea release
runs-on: ubuntu-latest
needs: [gateway, kotlin]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
# jpackage --type deb shells out to fakeroot, which the runner image
# does not ship.
- name: Install fakeroot (for jpackage --type deb)
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
- name: Restore release keystore (from Gitea secrets)
env:
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$KS_B64" ]; then
echo "$KS_B64" | base64 -d > app/release.keystore
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
echo "Building SIGNED release APK"
else
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
fi
- name: Build APK + AAB
run: |
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads.
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
else
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
fi
# jpackage cannot cross-compile: this only produces Linux packages.
# When a Windows / macOS runner exists later, add a second build job
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb
run: |
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
# Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
(cd desktopApp/build/jpackage && zip -qr \
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
cp desktopApp/build/jpackage/*.deb \
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
- name: Create release + upload artifacts
env:
# Optional: create a personal access token (scope: Releases: write)
# and store it as secret GITEA_TOKEN. Without it the workflow uses
# the automatic GITHUB_TOKEN that Gitea Actions provides.
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
# The dispatch input is a single-line field; turn literal \n into real newlines.
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
# message) before failing — plain `curl -f` hides it (exit 22).
api() {
local code body
body=$(mktemp)
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
if [ "${code:0:1}" != "2" ]; then
echo "API error $code: $(cat "$body")" >&2
rm -f "$body"
return 1
fi
cat "$body"
rm -f "$body"
}
# Re-run safety: drop a previous release AND its tag for this version.
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
# makes the POST below fail with 409.)
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
if [ -n "$OLD_ID" ]; then
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
fi
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
# Gitea creates the tag at the default branch HEAD automatically.
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
"$API/releases" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{tag_name:$tag, title:$title, body:$body}')" \
| jq -r .id)
echo "Created release $TAG (id $RELEASE_ID)"
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
[ -f "$f" ] || continue
echo "Uploading $(basename "$f")"
# Forgejo-style API: release assets live under /assets, not /attachments.
api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
+19
View File
@@ -0,0 +1,19 @@
# Gitleaks allowlist for iris_x_hermes.
#
# pi-lens runs `gitleaks detect --no-git` over the working tree, which includes
# git-ignored paths. The hits below are all false positives:
# - hermes-agent/ read-only research reference (never committed)
# - **/build/** Gradle / jpackage build artifacts (regenerated)
# - google-services.json standard Firebase config; its "API key" is a web
# client key restricted by package name + SHA-1, and
# the file is git-ignored (optional; FCM is inert
# without it).
title = "iris_x_hermes gitleaks allowlist"
[allowlist]
description = "Git-ignored reference tree, build artifacts, and Firebase config"
paths = [
'''^hermes-agent/''',
'''.*/build/''',
'''^app/androidApp/google-services\.json$''',
]
+28
View File
@@ -0,0 +1,28 @@
{
"ignore": [
"tests/test_android.py"
],
"rules": {
"unchecked-throwing-call-python": {
"disable": [
"unchecked-throwing-call-python",
"ast-grep:unchecked-throwing-call-python"
]
},
"python-logger-credential-disclosure": {
"disable": [
"opengrep:python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure"
]
},
"sqlalchemy-execute-raw-query": {
"disable": [
"opengrep:python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query"
]
},
"exported-activity": {
"disable": [
"opengrep:java.android.security.exported_activity.exported_activity"
]
}
}
}
+12 -10
View File
@@ -3,7 +3,8 @@
## Hard rules ## Hard rules
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home. - `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine). - **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout ## Layout
@@ -14,26 +15,27 @@
## Commands ## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`). - `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`. - Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below). - Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb). - Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite). - Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testDebugUnitTest` / `:shared:desktopTest` (no test sources exist yet). - Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`. - WS probe (gateway must be running): `hermes-agent/.venv/bin/python tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `tests/README.md`.
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`. - E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python tests/e2e.py`.
## Environment / pairing quirks ## Environment / pairing quirks
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry. - Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP. - HTTP default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_HTTP_HOST` to the gateway's LAN IP.
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds. - `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy. - `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`). - **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works. - Desktop jpackage on Linux/JDK 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## Testing quirks ## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`. - `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design. - e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`. - ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`. - Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
+518
View File
@@ -0,0 +1,518 @@
# CI / Release setup — manual edit list
Everything needed for the Gitea workflows (CI + manual release). Items marked
**DONE** were already applied; the rest are copy-paste instructions.
---
## 1. DONE — no action needed
- `tests/test_android.py` — vendored byte-identical mirror of
`hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout
is the canonical copy; **keep the two in sync** when you change that test).
- `.pi-lens.json` — added `"ignore": ["tests/test_android.py"]`
so the scanner doesn't flag the vendored mirror.
---
## 2. NEW FILE: `.gitea/workflows/ci.yml`
Runs on every push to `master` and on PRs: gateway plugin tests + Kotlin host
tests (android + desktop).
```yaml
name: CI
on:
push:
branches: [master]
pull_request:
jobs:
gateway:
name: Gateway plugin tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
# The gateway tests run inside the hermes-agent test harness, which is
# git-ignored in this repo (read-only research reference). CI clones the
# upstream repo at a pinned commit and drops in the vendored test copy.
# Bump the pinned SHA when you update the local hermes-agent checkout.
- name: Clone hermes-agent (pinned)
run: |
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
- name: Sync venv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
cd hermes-agent
uv sync
- name: Run android gateway tests
run: |
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py
kotlin:
name: Kotlin tests (android host + desktop)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# gradle.properties pins org.gradle.java.home to a local JDK path;
# strip it so CI uses the JDK installed by setup-java.
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
# Host-side tests only (no device needed). AGP auto-downloads the
# missing SDK platforms (licenses accepted above).
- name: Run host tests
working-directory: app
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
```
---
## 3. NEW FILE: `.gitea/workflows/release.yml`
The release version is the repo-root **`VERSION` file** (single source of
truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
commit). Manual trigger: **repo → Actions → Release → Run workflow**,
optionally with a `changelog`. It runs the same tests as CI, builds a signed
Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled),
then creates the Gitea release `v<VERSION>` with all artifacts as download
attachments.
Note: builds + release creation happen in ONE job because Gitea/act_runner
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
with `GHESNotSupportedError`).
```yaml
name: Release
on:
workflow_dispatch:
inputs:
# The release version comes from the repo-root VERSION file (the single
# source of truth) — bump it in a commit, then dispatch this workflow.
changelog:
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false
type: string
jobs:
gateway:
name: Gateway plugin tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Clone hermes-agent (pinned)
run: |
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
- name: Sync venv
run: |
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
cd hermes-agent
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
# venv without it and run_tests.sh refuses to run.
uv sync --extra dev
- name: Run android gateway tests
run: |
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py
kotlin:
name: Kotlin tests (android host + desktop)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
- name: Run host tests
working-directory: app
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
# Gitea/act_runner does not implement the GitHub artifacts API
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
# the release creation happen in ONE job — no artifact handoff between jobs.
release:
name: Build + create Gitea release
runs-on: ubuntu-latest
needs: [gateway, kotlin]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Strip local JDK pin
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
mkdir -p "$ANDROID_HOME/cmdline-tools"
curl -fsSL -o /tmp/ct.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
# exits before `yes` is done writing.
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
# jpackage --type deb shells out to fakeroot, which the runner image
# does not ship.
- name: Install fakeroot (for jpackage --type deb)
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
- name: Restore release keystore (from Gitea secrets)
env:
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$KS_B64" ]; then
echo "$KS_B64" | base64 -d > app/release.keystore
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
echo "Building SIGNED release APK"
else
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
fi
- name: Build APK + AAB
run: |
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads.
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
else
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
fi
# jpackage cannot cross-compile: this only produces Linux packages.
# When a Windows / macOS runner exists later, add a second build job
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb
run: |
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
# Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
(cd desktopApp/build/jpackage && zip -qr \
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
cp desktopApp/build/jpackage/*.deb \
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
- name: Create release + upload artifacts
env:
# Optional: create a personal access token (scope: Releases: write)
# and store it as secret GITEA_TOKEN. Without it the workflow uses
# the automatic GITHUB_TOKEN that Gitea Actions provides.
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
# The dispatch input is a single-line field; turn literal \n into real newlines.
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
# message) before failing — plain `curl -f` hides it (exit 22).
api() {
local code body
body=$(mktemp)
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
if [ "${code:0:1}" != "2" ]; then
echo "API error $code: $(cat "$body")" >&2
rm -f "$body"
return 1
fi
cat "$body"
rm -f "$body"
}
# Re-run safety: drop a previous release AND its tag for this version.
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
# makes the POST below fail with 409.)
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
if [ -n "$OLD_ID" ]; then
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
fi
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
# Gitea creates the tag at the default branch HEAD automatically.
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
"$API/releases" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{tag_name:$tag, title:$title, body:$body}')" \
| jq -r .id)
echo "Created release $TAG (id $RELEASE_ID)"
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
[ -f "$f" ] || continue
echo "Uploading $(basename "$f")"
# Forgejo-style API: release assets live under /assets, not /attachments.
api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
```
---
## 4. EDITS to existing Gradle files
> **Note (versioning):** the `versionName` / `appVersion` lines shown below
> have since been changed to read the repo-root **`VERSION` file** (single
> source of truth; `-PappVersion` still overrides in CI). See
> `.gitea/workflows/release.yml` and `gateway-plugin/version.py`.
### 4a. `app/androidApp/build.gradle.kts`
**Change 1** — in `defaultConfig`, replace:
```kotlin
versionName = "0.1.0"
```
with:
```kotlin
// CI passes -PappVersion=<version> (release workflow); local builds
// keep the default.
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
```
**Change 2** — inside the `android { }` block, right after the `buildTypes { }`
block, add:
```kotlin
// CI release signing: .gitea/workflows/release.yml restores a keystore
// from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see
// scripts/make_release_keystore.sh). Without them the release build stays
// unsigned and the workflow falls back to a debug APK.
val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE")
if (ciKeystore != null) {
signingConfigs {
create("release") {
storeFile = file(ciKeystore)
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
```
### 4b. `app/desktopApp/build.gradle.kts`
**Change 1** — near the top (after `val arch = ...`), add:
```kotlin
// CI passes -PappVersion=<version> (release workflow); local builds keep the
// default. jpackage requires a plain semver (no leading "v").
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
```
**Change 2** — in the `jpackage` Exec task's `commandLine(...)`, replace:
```kotlin
"--app-version", "0.1.0",
```
with:
```kotlin
"--app-version", appVersion,
```
---
## 5. NEW FILE: `scripts/make_release_keystore.sh` (Android signing, one-time)
This is the noob-friendly signing setup. Run it **once** on your machine:
```bash
scripts/make_release_keystore.sh
```
It generates a keystore with random passwords and prints the four values to
paste into Gitea. Then create the secrets in
**Gitea → repo → Settings → Actions → Secrets**:
| Secret | Value |
| --- | --- |
| `ANDROID_KEYSTORE_BASE64` | the long base64 blob the script prints |
| `ANDROID_KEYSTORE_PASSWORD` | printed by the script |
| `ANDROID_KEY_ALIAS` | `iris` |
| `ANDROID_KEY_PASSWORD` | printed by the script |
Until the secrets exist, the release workflow still works but ships a
**debug** APK (installable, but not suitable for updates).
```bash
#!/usr/bin/env bash
# One-time setup: create the Android release keystore and print the values to
# paste into Gitea (repo -> Settings -> Actions -> Secrets).
#
# Usage: scripts/make_release_keystore.sh [output-file]
# (default: ~/iris-release.keystore)
#
# WARNING: back up the keystore file immediately. If it is lost, the app can
# never be updated on users' phones (a new key = a brand-new app as far as
# Android is concerned).
set -euo pipefail
OUT="${1:-$HOME/iris-release.keystore}"
if [ -e "$OUT" ]; then
echo "Refusing to overwrite existing file: $OUT" >&2
exit 1
fi
STORE_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
KEY_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
keytool -genkeypair -v \
-keystore "$OUT" -storetype PKCS12 \
-alias iris -keyalg RSA -keysize 2048 -validity 10000 \
-storepass "$STORE_PASS" -keypass "$KEY_PASS" \
-dname "CN=Iris Release, OU=Mobile, O=Iris, C=DE"
echo
echo "Keystore written to: $OUT"
echo ">>> Back it up NOW (password manager / cloud storage). <<<"
echo
echo "Add these four secrets in Gitea (repo -> Settings -> Actions -> Secrets):"
echo
echo " ANDROID_KEYSTORE_BASE64 = $(base64 -w0 "$OUT")"
echo
echo " ANDROID_KEYSTORE_PASSWORD = $STORE_PASS"
echo " ANDROID_KEY_ALIAS = iris"
echo " ANDROID_KEY_PASSWORD = $KEY_PASS"
```
Don't forget: `chmod +x scripts/make_release_keystore.sh`
---
## 6. Prerequisites / checks before the first run
1. **act_runner must be registered** on gitea.zephyre.one with the label
`ubuntu-latest` (that's what both workflows request). Check under
Gitea → (your user or the org) → Actions → Runners.
2. The runner needs internet access (GitHub, Google, Maven Central, Gradle).
3. No Gitea secrets are *required* — but add the four keystore secrets from
§5 for a signed APK, and optionally a `GITEA_TOKEN` (Releases: write) if
the automatic `GITHUB_TOKEN` doesn't have release permission.
## 7. Using it
- **CI**: pushes to `master` / PRs run automatically.
- **Release**: repo → **Actions** → *Release* → **Run workflow** → enter
`version` (e.g. `0.2.0`) + `changelog` → start. The release appears at
`https://gitea.zephyre.one/ARIA/iris_x_hermes/releases/tag/v0.2.0` with:
- `iris-android-v0.2.0.apk` (signed, or `-debug` without keystore secrets)
- `iris-desktop-linux-x64-v0.2.0.zip` (app image, JRE bundled)
- `iris-desktop-linux-x64-v0.2.0.deb`
- Re-running with the same version **replaces** the old release (old tag +
attachments are deleted first).
## 8. Later: Windows / macOS desktop builds
When the Windows VM / MacBook runner exists:
1. Register act_runner on that machine (labels e.g. `windows-latest`,
`macos-latest`).
2. In `release.yml`, copy the `desktop` job, change `runs-on`, and adjust the
artifact names (`iris-desktop-windows-x64-…`, `iris-desktop-macos-…`).
jpackage then produces `msi`/`dmg` natively — no other changes needed.
3. Bump the pinned hermes-agent SHA in both workflows whenever you update the
local `hermes-agent/` checkout (`git -C hermes-agent rev-parse HEAD`).
+70 -25
View File
@@ -2,14 +2,19 @@
A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform). A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
Iris pairs with your running `hermes gateway` over a private WebSocket and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications. Iris pairs with your running `hermes gateway` over a private, token-authenticated connection and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
## Features ## Features
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app - **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
- **Absolute Privacy!** — everything stays on your own infrastructure - **Absolute Privacy!** — everything stays on your own infrastructure
- **No file limit** (push: ntfy by default; FCM is opt-in and routes push metadata via Google —
- **No character limit** see [Push notifications](#push-notifications))
- **100 MB file uploads by default** — configurable on the gateway via
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
on the gateway side (hermes), not in the app
- **No 4,096-character message limit like Telegram** — messages travel over
your own gateway (hard frame-body cap: 1 MiB)
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting… - **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
- **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView - **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView
- **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly. - **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly.
@@ -24,11 +29,11 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
## How it works ## How it works
``` ```
hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (Android / Desktop) hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android / Desktop)
``` ```
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the - `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
`hermes gateway` process and opens a WebSocket server the apps connect to. `hermes gateway` process and opens an HTTP server the apps connect to.
Zero new Python dependencies, zero hermes-core changes. Zero new Python dependencies, zero hermes-core changes.
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the - `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp` code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
@@ -36,14 +41,51 @@ hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (And
- The app is a first-class hermes *messaging platform*, so everything the gateway - The app is a first-class hermes *messaging platform*, so everything the gateway
already does just works: slash commands, cron delivery, `send_message` routing, already does just works: slash commands, cron delivery, `send_message` routing,
coexistence with Telegram/Discord/etc. coexistence with Telegram/Discord/etc.
- Push notifications: FCM (primary) or ntfy (fallback). - Push notifications: ntfy (default) or FCM (opt-in).
## Push notifications
Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost.
- **ntfy (default)** — push metadata stays on your own infrastructure
(self-hosted ntfy recommended). This is the backend for truly private
communication.
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
metadata (notification title, device token) is routed through **Google's
servers**. If you want truly private communication, use ntfy instead.
Setup: [`docs/install.md`](docs/install.md); details:
[`docs/08-push.md`](docs/08-push.md).
## Install the gateway
Three commands on the machine where hermes runs:
```bash
cd hermes-agent && uv sync # 1. hermes with its venv (separate project, not this repo)
# 2. install the Iris plugin — the #gateway-plugin suffix points the
# installer at the plugin subfolder of this monorepo
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
# 3. generate the pairing token + server URL, then run the gateway
hermes gateway setup
hermes gateway
```
`hermes gateway setup` prints the **server URL** and **pairing token / QR**
the app needs on its Connect screen.
All options (LAN binding, TLS, push, device allowlist) and the full app
pairing walkthrough: [`docs/install.md`](docs/install.md).
## Build from source ## Build from source
### Prerequisites ### Prerequisites
| Where | You need | | Where | You need |
|---|---| | --- | --- |
| Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) | | Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) |
| Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device | | Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device |
| Desktop build machine | JDK 17 only | | Desktop build machine | JDK 17 only |
@@ -52,19 +94,21 @@ No system Gradle needed — both apps use the project wrapper (`./gradlew`).
### 1. Gateway (on the gateway host) ### 1. Gateway (on the gateway host)
If you're developing from a checkout, skip `hermes plugins install` and
symlink the plugin so it always tracks your working tree:
```bash ```bash
# hermes-agent is a separate project (not part of this repo)
cd hermes-agent && uv sync cd hermes-agent && uv sync
# install the Iris plugin into the live hermes home
mkdir -p ~/.hermes/plugins mkdir -p ~/.hermes/plugins
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
hermes gateway status # should list "android" hermes gateway status # should list the Iris platform
hermes gateway setup # generates ANDROID_TOKEN, prints the server URL hermes gateway setup # generates IRIS_TOKEN, prints server URL + pairing QR
hermes gateway # run the gateway hermes gateway # run the gateway
``` ```
(Otherwise see [Install the gateway](#install-the-gateway) above.)
### 2. Android app ### 2. Android app
```bash ```bash
@@ -86,21 +130,22 @@ cd app
On the app's **Connect** screen: On the app's **Connect** screen:
1. **Server URL** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`). 1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env` 2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
on the gateway host. on the gateway host.
3. **Test & Connect.** 3. **Test & Connect.**
Notes: Notes:
- The app has **no QR scanner** — pairing is manual URL + token entry. - **Android** has a **Scan QR** button that reads the QR printed by
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone `hermes gateway setup` and pre-fills URL + token; desktop uses manual entry.
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP. - The default bind is `127.0.0.1` (desktop on the same machine only). For a
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS phone on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`). - Remote access: Tailscale/WireGuard, or a reverse proxy/tunnel with TLS
(`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`).
Full walkthrough, push setup (FCM/ntfy), and troubleshooting: Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
[`docs/setup.md`](docs/setup.md). [`docs/install.md`](docs/install.md).
## Contributing ## Contributing
@@ -112,7 +157,7 @@ Contributions are welcome! Before you start:
2. **Know the layout.** 2. **Know the layout.**
| Path | What | | Path | What |
|---|---| | --- | --- |
| `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth | | `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth |
| `app/shared` | KMP module with most of the client code (shared by Android + Desktop) | | `app/shared` | KMP module with most of the client code (shared by Android + Desktop) |
| `app/androidApp` | Thin Android shell (package `dev.iris.app`) | | `app/androidApp` | Thin Android shell (package `dev.iris.app`) |
@@ -123,8 +168,8 @@ Contributions are welcome! Before you start:
- Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` - Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
(never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`). (never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`).
- Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest` - Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest`
- Live check (gateway must be running): `gateway-plugin/tests/ws_probe.py` and - Live check (gateway must be running): `tests/ws_probe.py` and
`gateway-plugin/tests/e2e.py` — see [`gateway-plugin/tests/README.md`](gateway-plugin/tests/README.md). `tests/e2e.py` — see [`tests/README.md`](tests/README.md).
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`, 4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
`app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json` `app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json`
must always agree. must always agree.
+1
View File
@@ -0,0 +1 @@
0.1.2
+52 -9
View File
@@ -1,19 +1,31 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
id("com.android.application") id("com.android.application")
kotlin("android")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
android { android {
namespace = "dev.iris.app" namespace = "dev.iris.app"
compileSdk = 34 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "dev.iris.app" applicationId = "dev.iris.app"
minSdk = 29 minSdk = 29
targetSdk = 34 targetSdk = 34
versionCode = 1 // Play Store requires an incrementing versionCode per upload; CI can
versionName = "0.1.0" // pass -PappVersionCode=<n>. Local builds keep the default.
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
// The repo-root VERSION file is the single source of truth (bump it
// to cut a release); CI can still override with -PappVersion.
versionName =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
} }
buildTypes { buildTypes {
@@ -22,23 +34,54 @@ android {
} }
} }
// CI release signing: .gitea/workflows/release.yml restores a keystore
// from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see
// scripts/make_release_keystore.sh). Without them the release build stays
// unsigned and the workflow falls back to a debug APK.
val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE")
if (ciKeystore != null) {
signingConfigs {
create("release") {
storeFile = file(ciKeystore)
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("release")
}
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { // targetSdk is deliberately pinned to 34 (a stable API level): the
jvmTarget = "17" // reference device is API 29 and API 37 (the only newer installed
// platform) is a preview SDK, which is not appropriate to target for a
// stable build. Silence the informational OldTargetApi hint.
lint {
disable += "OldTargetApi"
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
} }
} }
dependencies { dependencies {
implementation(project(":shared")) implementation(project(":shared"))
implementation(platform("androidx.compose:compose-bom:2024.09.02")) implementation(platform("androidx.compose:compose-bom:2026.08.00"))
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.activity:activity-compose:1.9.2") implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.core:core-splashscreen:1.2.0")
} }
// M5: apply the google-services plugin only when a Firebase project is // M5: apply the google-services plugin only when a Firebase project is
@@ -8,7 +8,16 @@
<!-- M5: ntfy listener foreground service (dataSync type on API 34). --> <!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- M-3: cleartext (http://) is required because the gateway is a LAN host
addressed by IP (e.g. 192.168.x.x), not a domain. Android's
network_security_config can only scope cleartext to domain names, not
IP ranges, so a per-host allowlist isn't possible for this use case.
The token is still required for auth; traffic is only ever sent to the
user-configured gateway on the local network. -->
<application <application
android:label="Iris" android:label="Iris"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -38,8 +47,22 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="chat" /> <data android:scheme="iris" android:host="chat" />
</intent-filter> </intent-filter>
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="pair" />
</intent-filter>
</activity> </activity>
<!-- QR pairing (docs/20): full-screen scanner launched from the
Connect screen. Not exported; started only by our own app. -->
<activity
android:name="iris.platform.QrScanActivity"
android:exported="false"
android:screenOrientation="portrait" />
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). --> <!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -15,12 +15,17 @@ import iris.IrisApp
import iris.platform.AndroidEnv import iris.platform.AndroidEnv
import iris.platform.AndroidSecureStore import iris.platform.AndroidSecureStore
import iris.platform.AppBridge import iris.platform.AppBridge
import iris.platform.NtfyListenerService import iris.platform.syncNtfyListener
import iris.util.PairLink
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val deepLinkChatId = mutableStateOf<String?>(null) private val deepLinkChatId = mutableStateOf<String?>(null)
private val deepLinkThreadId = mutableStateOf<String?>(null) private val deepLinkThreadId = mutableStateOf<String?>(null)
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
// screen with.
private val pendingPair = mutableStateOf<PairLink?>(null)
private val notificationPermission = private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ } registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
@@ -31,11 +36,19 @@ class MainActivity : ComponentActivity() {
val store = AndroidSecureStore(applicationContext) val store = AndroidSecureStore(applicationContext)
handleDeepLink(intent) handleDeepLink(intent)
requestNotificationPermission() requestNotificationPermission()
startNtfyListener(store) // M5: the ntfy listener (and its permanent notification) only runs
// when the paired gateway pushes via ntfy; unknown ("") = not yet.
syncNtfyListener(store.pushBackend)
setContent { setContent {
val chatId by deepLinkChatId val chatId by deepLinkChatId
val threadId by deepLinkThreadId val threadId by deepLinkThreadId
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId) val pair by pendingPair
IrisApp(
store = store,
deepLinkChatId = chatId,
deepLinkThreadId = threadId,
deepLinkPair = pair,
)
} }
} }
@@ -47,6 +60,10 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
AppBridge.foreground = true AppBridge.foreground = true
// Wake the connect loop's backoff: after a background stint the
// network is usually back, so re-probe immediately instead of making
// the user wait out the (up to 30 s) backoff with a Connecting banner.
AppBridge.controller?.client?.poke()
} }
override fun onPause() { override fun onPause() {
@@ -58,9 +75,19 @@ class MainActivity : ComponentActivity() {
* extras or an iris://chat/<id>?thread=<tid> URI). */ * extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) { private fun handleDeepLink(intent: Intent?) {
val data = intent?.data val data = intent?.data
val chatId = intent?.getStringExtra("chat_id") // QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
// the whole payload is in the query string; parse it with the shared
// PairLink parser.
if (data?.scheme == "iris" && data.host == "pair") {
val link = PairLink.parse(data.toString())
if (link != null) pendingPair.value = link
return
}
val chatId =
intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull() ?: data?.pathSegments?.firstOrNull()
val threadId = intent?.getStringExtra("thread_id") val threadId =
intent?.getStringExtra("thread_id")
?: data?.getQueryParameter("thread") ?: data?.getQueryParameter("thread")
if (!chatId.isNullOrBlank()) { if (!chatId.isNullOrBlank()) {
deepLinkChatId.value = chatId deepLinkChatId.value = chatId
@@ -77,9 +104,4 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
private fun startNtfyListener(store: AndroidSecureStore) {
if (store.ntfyTopic.isBlank()) return
ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java))
}
} }
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+10 -9
View File
@@ -7,19 +7,20 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath("com.google.gms:google-services:4.4.2") classpath("com.google.gms:google-services:4.5.0")
} }
} }
plugins { plugins {
kotlin("multiplatform") version "2.1.0" apply false kotlin("multiplatform") version "2.4.10" apply false
kotlin("android") version "2.1.0" apply false kotlin("jvm") version "2.4.10" apply false
kotlin("jvm") version "2.1.0" apply false kotlin("plugin.serialization") version "2.4.10" apply false
kotlin("plugin.serialization") version "2.1.0" apply false id("com.android.application") version "9.3.1" apply false
id("com.android.application") version "8.7.3" apply false id("com.android.kotlin.multiplatform.library") version "9.3.1" apply false
id("com.android.library") version "8.7.3" apply false id("org.jetbrains.compose") version "1.11.1" apply false
id("org.jetbrains.compose") version "1.7.3" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false // Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
id("app.cash.sqldelight") version "2.3.2" apply false
} }
tasks.register("clean") { tasks.register("clean") {
+50 -20
View File
@@ -6,20 +6,32 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
val composeVersion = "1.7.3" val composeVersion = "1.11.1"
val os = OperatingSystem.current() val os = OperatingSystem.current()
val arch = System.getProperty("os.arch") ?: "amd64" val arch = System.getProperty("os.arch") ?: "amd64"
val desktopTarget = when { // The repo-root VERSION file is the single source of truth (bump it to cut
// a release); CI can still override with -PappVersion. jpackage requires a
// plain semver (no leading "v").
val appVersion =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
val desktopTarget =
when {
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64" os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
os.isWindows -> "windows-x64" os.isWindows -> "windows-x64"
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64" else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
} }
dependencies { dependencies {
implementation(project(":shared")) implementation(project(":shared"))
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion") implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
// M6: window-state persistence (window.json). // M6: window-state persistence (window.json).
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
} }
compose.desktop { compose.desktop {
@@ -45,19 +57,21 @@ afterEvaluate {
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input") val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
val fatJar = tasks.register<Jar>("fatJar") { val fatJar =
tasks.register<Jar>("fatJar") {
archiveClassifier.set("all") archiveClassifier.set("all")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest { attributes("Main-Class" to "iris.desktop.MainKt") } manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
from(sourceSets.main.get().output) from(sourceSets.main.get().output)
from({ from({
configurations.runtimeClasspath.get() configurations.runtimeClasspath
.get()
.filter { it.name.endsWith(".jar") } .filter { it.name.endsWith(".jar") }
.map { zipTree(it) } .map { zipTree(it) }
}) { }) {
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
} }
} }
// Stage the fat jar into the jpackage input dir. A dedicated Sync task keeps // Stage the fat jar into the jpackage input dir. A dedicated Sync task keeps
// the Exec task free of task references (configuration-cache compatible) and // the Exec task free of task references (configuration-cache compatible) and
@@ -73,23 +87,39 @@ tasks.register<Exec>("jpackage") {
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage") val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
val type = (project.findProperty("jpackageType") as? String) ?: "app-image" val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
val inputDir = jpackageInputDir.get().asFile val inputDir = jpackageInputDir.get().asFile
val destDir = layout.buildDirectory.dir("jpackage").get().asFile val destDir =
layout.buildDirectory
.dir("jpackage")
.get()
.asFile
inputs.dir(inputDir) inputs.dir(inputDir)
outputs.dir(destDir) outputs.dir(destDir)
commandLine( commandLine(
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage", if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
"--name", "iris", "--name",
"--app-version", "0.1.0", "iris",
"--vendor", "Iris", "--app-version",
"--type", type, appVersion,
"--input", inputDir.absolutePath, "--vendor",
"--main-jar", "desktopApp-all.jar", "Iris",
"--main-class", "iris.desktop.MainKt", "--type",
"--icon", file("src/main/resources/icon.png").absolutePath, type,
"--dest", destDir.absolutePath, "--input",
"--java-options", "-Xmx1g", inputDir.absolutePath,
"--main-jar",
"desktopApp-all.jar",
"--main-class",
"iris.desktop.MainKt",
"--icon",
file("src/main/resources/icon.png").absolutePath,
"--dest",
destDir.absolutePath,
"--java-options",
"-Xmx1g",
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above). // M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
"--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED", "--java-options",
"--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--java-options",
"--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
) )
} }
@@ -19,9 +19,12 @@ import iris.IrisApp
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.DesktopBridge import iris.platform.DesktopBridge
import iris.platform.DesktopSecureStore import iris.platform.DesktopSecureStore
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.Color import java.awt.Color
import java.awt.Graphics2D import java.awt.Graphics2D
import javax.imageio.ImageIO
import java.awt.RenderingHints import java.awt.RenderingHints
import java.awt.SystemTray import java.awt.SystemTray
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
@@ -29,10 +32,7 @@ import java.awt.event.WindowFocusListener
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.File import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import javax.imageio.ImageIO
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** /**
* M6: desktop shell (docs/11 §11.2/§11.3). * M6: desktop shell (docs/11 §11.2/§11.3).
@@ -48,6 +48,7 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso
// Window/taskbar icon (src/main/resources/icon.png). // Window/taskbar icon (src/main/resources/icon.png).
private class IrisDesktop private class IrisDesktop
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap() private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range. // Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
@@ -87,14 +88,36 @@ fun main() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(2_000) delay(2_000)
val state = DesktopBridge.controller?.client?.state?.value val state =
val (color, tooltip) = when (state) { DesktopBridge.controller
?.client
?.state
?.value
val (color, tooltip) =
when (state) {
null, null,
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline" is GatewayClient.State.Disconnected,
-> {
TRAY_OFFLINE to "Iris — offline"
}
is GatewayClient.State.Connecting, is GatewayClient.State.Connecting,
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…" is GatewayClient.State.Reconnecting,
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected" -> {
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed" TRAY_CONNECTING to "Iris — connecting…"
}
is GatewayClient.State.Connected -> {
TRAY_CONNECTED to "Iris — connected"
}
is GatewayClient.State.AuthFailed -> {
TRAY_AUTH_FAILED to "Iris — auth failed"
}
is GatewayClient.State.TlsConfirmRequired -> {
TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation"
}
} }
trayColor = color trayColor = color
trayTooltip = tooltip trayTooltip = tooltip
@@ -126,7 +149,8 @@ fun main() {
state = loadWindowState(), state = loadWindowState(),
) { ) {
composeWindow = window composeWindow = window
window.addWindowFocusListener(object : WindowFocusListener { window.addWindowFocusListener(
object : WindowFocusListener {
override fun windowGainedFocus(e: WindowEvent) { override fun windowGainedFocus(e: WindowEvent) {
DesktopBridge.foreground = true DesktopBridge.foreground = true
} }
@@ -134,7 +158,8 @@ fun main() {
override fun windowLostFocus(e: WindowEvent) { override fun windowLostFocus(e: WindowEvent) {
DesktopBridge.foreground = false DesktopBridge.foreground = false
} }
}) },
)
IrisApp(store) IrisApp(store)
} }
} }
+9
View File
@@ -1,4 +1,13 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
# Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
# unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget).
#
# The JDK-21 home is machine-specific, so it is NOT hardcoded here (a
# committed path would break every other checkout). Set it per machine via
# one of:
# - ~/.gradle/gradle.properties -> org.gradle.java.home=/path/to/jdk21
# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+128 -29
View File
@@ -1,44 +1,124 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
kotlin("multiplatform") kotlin("multiplatform")
kotlin("plugin.serialization") kotlin("plugin.serialization")
id("com.android.library") id("com.android.kotlin.multiplatform.library")
id("org.jetbrains.compose") id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
id("app.cash.sqldelight")
} }
val composeVersion = "1.7.3" val composeVersion = "1.11.1"
val coroutinesVersion = "1.9.0" // material3 and material-icons-extended are published on their own cadence and
val serializationVersion = "1.7.3" // lag the core artifacts; use each one's latest stable release.
val okhttpVersion = "4.12.0" val material3Version = "1.9.0"
// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1); val materialIconsVersion = "1.7.3"
// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the val coroutinesVersion = "1.11.0"
val serializationVersion = "1.11.0"
val okhttpVersion = "5.5.0"
// 2.0.x is the first line built against Compose 1.8+ (1.9.40 was the last
// 1.7.x-compatible release). HTML artifact previews (M9): Android uses the
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use). // platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
val webviewVersion = "1.9.40" val webviewVersion = "2.0.3"
val kcefVersion = "2024.04.20.3" val kcefVersion = "2025.03.23"
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x // 0.34.0+ moved to Compose 1.8+ (0.33.0 was the last 1.7.x / Kotlin 2.1.x
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore, // release). Provides GFM tables, bold/italic/underscore, and the -code module
// and the -code module for language-aware syntax highlighting (Highlights). // for language-aware syntax highlighting (Highlights).
val markdownVersion = "0.33.0" val markdownVersion = "0.44.0"
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
val sqldelightVersion = "2.3.2"
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
val cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.1"
// ── App version (generated) ─────────────────────────────────────────────
// The repo-root VERSION file is the single source of truth (bump it to cut
// a release; CI can still override with -PappVersion). Generate AppVersion.kt
// into commonMain so both targets can display it in Settings and send it to
// the gateway (X-Iris-App-Version header).
//
// A real task with the VERSION file as a DECLARED input: on a
// configuration-cache hit the script body does not re-run, so only the task
// (keyed on the file's content) can regenerate AppVersion.kt after a bump.
// The doLast reads everything from the task's own inputs/outputs (which are
// configuration-cache serializable) — it must not reference script-scope
// vals, because a .kts script lambda captures the script object and the
// configuration cache rejects that.
val generatedVersionDir = layout.buildDirectory.dir("generated/app-version")
val generateAppVersion by tasks.registering {
inputs.file(project.file("../../VERSION"))
inputs.property("appVersionOverride", (project.findProperty("appVersion") as? String).orEmpty())
val outFile = generatedVersionDir.map { it.file("AppVersion.kt") }
outputs.file(outFile)
doLast {
val override = inputs.properties["appVersionOverride"] as? String ?: ""
val versionFile = inputs.files.singleFile
val version =
override.ifBlank {
versionFile.takeIf { it.exists() }?.readText()?.trim() ?: "0.1.0"
}
val f = outFile.get().asFile
f.parentFile?.mkdirs()
f.writeText(
"""
|package iris
|
|/** Generated from the repo-root VERSION file - do not edit. */
|object AppVersion {
| const val VERSION = "$version"
|}
|
""".trimMargin(),
)
}
}
kotlin { kotlin {
androidTarget() android {
jvm("desktop") namespace = "iris.shared"
compileSdk = 37
minSdk = 29
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
withHostTest { }
}
jvm("desktop") {
// Desktop runs on the Java 21 runtime (see gradle.properties
// org.gradle.java.home); target 21 so the Markdown renderer 0.44.0
// (Java-21 bytecode) loads. Android keeps its JVM 17 target above.
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets { sourceSets {
// AppVersion.kt is generated from the repo-root VERSION file (see
// generateAppVersion above) into commonMain so both targets can read it.
commonMain {
kotlin.srcDir(generatedVersionDir)
}
// Both targets are JVM-based (androidTarget + jvm("desktop")), so // Both targets are JVM-based (androidTarget + jvm("desktop")), so
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain. // shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
val jvmMain by creating { dependsOn(commonMain.get()) } val jvmMain by creating { dependsOn(commonMain.get()) }
val androidMain by getting { dependsOn(jvmMain) } val androidMain by getting { dependsOn(jvmMain) }
val desktopMain by getting { dependsOn(jvmMain) } val desktopMain by getting { dependsOn(jvmMain) }
// Host tests shared by the android (withHostTest) and desktop targets
// (e.g. the SQLDelight cache DB tests, which need the JDBC driver).
val jvmTest by creating { dependsOn(commonTest.get()) }
val androidHostTest by getting { dependsOn(jvmTest) }
val desktopTest by getting { dependsOn(jvmTest) }
commonMain.dependencies { commonMain.dependencies {
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion") implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion") implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$composeVersion") implementation("org.jetbrains.compose.material3:material3:$material3Version")
implementation("org.jetbrains.compose.ui:ui:$composeVersion") implementation("org.jetbrains.compose.ui:ui:$composeVersion")
// Full Material icon set (Icons.Filled.ContentCopy for the code // Full Material icon set (Icons.Filled.ContentCopy for the code
// copy buttons); -core only ships a small subset. // copy buttons); -core only ships a small subset.
implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion") implementation("org.jetbrains.compose.material:material-icons-extended:$materialIconsVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based // OkHttp is a JVM library; both targets here are JVM-based
@@ -53,41 +133,60 @@ kotlin {
// M9: HTML artifact previews — WebView composable (platform WebView // M9: HTML artifact previews — WebView composable (platform WebView
// on Android, KCEF/JCEF on desktop). // on Android, KCEF/JCEF on desktop).
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion") implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
// Local cache DB runtime (generated code from commonMain/sqldelight).
implementation("app.cash.sqldelight:runtime:$sqldelightVersion")
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
} }
jvmTest.dependencies {
implementation(kotlin("test"))
// JDBC SQLite driver: in-memory DB for the cache tests.
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
}
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The // M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
// webview library exposes it transitively, but we reference KCEF // webview library exposes it transitively, but we reference KCEF
// directly (init + progress) so declare it explicitly. // directly (init + progress) so declare it explicitly.
desktopMain.dependencies { desktopMain.dependencies {
implementation("dev.datlag:kcef:$kcefVersion") implementation("dev.datlag:kcef:$kcefVersion")
// Local cache DB: JDBC SQLite driver (file in ~/.iris).
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
} }
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only). // M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
androidMain.dependencies { androidMain.dependencies {
// M7: EncryptedSharedPreferences for the pairing token // M7: EncryptedSharedPreferences for the pairing token
// (docs/09 §9.7; AndroidSecureStore). // (docs/09 §9.7; AndroidSecureStore).
implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.media3:media3-exoplayer:1.3.1") implementation("androidx.media3:media3-exoplayer:1.11.0")
implementation("androidx.media3:media3-ui:1.3.1") implementation("androidx.media3:media3-ui:1.11.0")
// SAF picker (rememberLauncherForActivityResult). // SAF picker (rememberLauncherForActivityResult).
implementation("androidx.activity:activity-compose:1.9.2") implementation("androidx.activity:activity-compose:1.13.0")
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
// uses the camera2 CameraX backend and ML Kit's barcode model.
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
// M5: FCM push (inert without a Firebase project / google-services.json; // M5: FCM push (inert without a Firebase project / google-services.json;
// the ntfy listener is the fallback). The google-services plugin is // the ntfy listener is the fallback). The google-services plugin is
// applied conditionally in the app module. // applied conditionally in the app module.
implementation("com.google.firebase:firebase-messaging:23.1.2") implementation("com.google.firebase:firebase-messaging:25.1.2")
// Local cache DB: Android SQLite driver (app database dir).
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
} }
} }
} }
android { sqldelight {
namespace = "iris.shared" databases {
compileSdk = 34 create("IrisDatabase") {
defaultConfig { packageName.set("iris.db")
minSdk = 29
} }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
} }
} }
// Every Kotlin compile depends on the generated AppVersion.kt being current.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(generateAppVersion)
}
@@ -40,16 +40,16 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.MediaItem as M3MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import iris.data.MediaItem import iris.data.MediaItem
import iris.media.extForMime import iris.media.extForMime
import iris.protocol.KIND_VIDEO import iris.protocol.KIND_VIDEO
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import androidx.media3.common.MediaItem as M3MediaItem
/** App context holder (set in MainActivity.onCreate). */ /** App context holder (set in MainActivity.onCreate). */
object AndroidEnv { object AndroidEnv {
@@ -59,7 +59,8 @@ object AndroidEnv {
@Composable @Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) { if (uri == null) {
onPicked(null) onPicked(null)
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
@@ -85,7 +86,8 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
@Composable @Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) { if (uri == null) {
onPicked(null) onPicked(null)
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
@@ -107,7 +109,10 @@ actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") } Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
} }
private fun queryDisplayName(context: Context, uri: Uri): String? { private fun queryDisplayName(
context: Context,
uri: Uri,
): String? {
context.contentResolver.query(uri, null, null, null, null)?.use { c -> context.contentResolver.query(uri, null, null, null, null)?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && c.moveToFirst()) return c.getString(idx) if (idx >= 0 && c.moveToFirst()) return c.getString(idx)
@@ -119,15 +124,20 @@ actual fun mediaCacheBaseDir(): String =
AndroidEnv.context.externalCacheDir?.absolutePath AndroidEnv.context.externalCacheDir?.absolutePath
?: AndroidEnv.context.cacheDir.absolutePath ?: AndroidEnv.context.cacheDir.absolutePath
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) { actual suspend fun loadMediaImage(path: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try { try {
BitmapFactory.decodeFile(path)?.asImageBitmap() BitmapFactory.decodeFile(path)?.asImageBitmap()
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) { actual suspend fun loadScaledImage(
path: String,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try { try {
// inJustDecodeBounds fills [bounds] and returns null by design — only // inJustDecodeBounds fills [bounds] and returns null by design — only
// the outWidth/outHeight are meaningful here. // the outWidth/outHeight are meaningful here.
@@ -141,9 +151,40 @@ actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = w
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) { actual suspend fun decodeImageBytes(
bytes: ByteArray,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
actual fun readBackdropBytes(id: String): ByteArray? =
try {
AndroidEnv.context.assets
.open("backdrops/$id.jpg")
.use { it.readBytes() }
} catch (e: Exception) {
null
}
actual suspend fun encodeImageAsBase64(
path: String,
maxSize: Int,
): String? =
withContext(Dispatchers.IO) {
try { try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds) BitmapFactory.decodeFile(path, bounds)
@@ -159,19 +200,23 @@ actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = wi
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) { actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try { try {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
@Composable @Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { actual fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
) {
val path = media.localPath val path = media.localPath
if (path == null) { if (path == null) {
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
@@ -191,7 +236,10 @@ actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
} }
@Composable @Composable
private fun VideoPlayerView(path: String, modifier: Modifier) { private fun VideoPlayerView(
path: String,
modifier: Modifier,
) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PlayerView(ctx).apply { PlayerView(ctx).apply {
@@ -205,16 +253,22 @@ private fun VideoPlayerView(path: String, modifier: Modifier) {
}, },
update = {}, update = {},
onRelease = { view -> view.player?.release() }, onRelease = { view -> view.player?.release() },
modifier = modifier modifier =
modifier
.fillMaxWidth() .fillMaxWidth()
.height(220.dp), .height(220.dp),
) )
} }
@Composable @Composable
private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) { private fun AudioPlayerView(
path: String,
filename: String,
modifier: Modifier,
) {
val context = LocalContext.current val context = LocalContext.current
val player = remember { val player =
remember {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
setMediaItem(M3MediaItem.fromUri(path.toUri())) setMediaItem(M3MediaItem.fromUri(path.toUri()))
prepare() prepare()
@@ -237,7 +291,8 @@ private fun AudioPlayerView(path: String, filename: String, modifier: Modifier)
} }
Row( Row(
modifier = modifier modifier =
modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp), .padding(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -270,17 +325,21 @@ private fun fmt(seconds: Float): String {
return "%d:%02d".format(s / 60, s % 60) return "%d:%02d".format(s / 60, s % 60)
} }
actual fun openDocument(path: String, mime: String) { actual fun openDocument(
path: String,
mime: String,
) {
val context = AndroidEnv.context val context = AndroidEnv.context
try { try {
val file = File(path) val file = File(path)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply { val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime) setDataAndType(uri, mime)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} }
context.startActivity(intent) context.startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText(context, "No app to open ${mime}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "No app to open $mime", Toast.LENGTH_SHORT).show()
} }
} }
@@ -1,6 +1,8 @@
package iris.platform package iris.platform
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import iris.state.IrisController import iris.state.IrisController
@@ -11,6 +13,33 @@ actual fun setActiveController(controller: Any?) {
AppBridge.controller = controller as? IrisController AppBridge.controller = controller as? IrisController
} }
actual fun syncNtfyListener(backend: String) {
val context = AndroidEnv.context
val intent = Intent(context, NtfyListenerService::class.java)
// L-18: reuse one AndroidSecureStore instead of rebuilding it (and
// re-running EncryptedSharedPreferences.create + migration) on every call.
val store = ntfyStore(context)
if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) {
ContextCompat.startForegroundService(context, intent)
} else {
// FCM (or unknown) backend: no persistent listener, no permanent
// notification. stopService is a no-op when it isn't running.
context.stopService(intent)
}
}
private val ntfyStoreLock = Any()
@Volatile
private var cachedNtfyStore: AndroidSecureStore? = null
private fun ntfyStore(context: Context): AndroidSecureStore {
cachedNtfyStore?.let { return it }
return synchronized(ntfyStoreLock) {
cachedNtfyStore ?: AndroidSecureStore(context).also { cachedNtfyStore = it }
}
}
actual fun postSystemNotification( actual fun postSystemNotification(
chatId: String?, chatId: String?,
chatName: String?, chatName: String?,
@@ -19,7 +48,7 @@ actual fun postSystemNotification(
threadId: String?, threadId: String?,
) { ) {
val context = AndroidEnv.context val context = AndroidEnv.context
val id = chatId ?: "android:default" val id = chatId ?: "default"
// POST_NOTIFICATIONS is a runtime permission on API 33+. // POST_NOTIFICATIONS is a runtime permission on API 33+.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
@@ -6,7 +6,10 @@ import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import iris.data.SecureStore import iris.data.SecureStore
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import java.util.UUID import java.util.UUID
/** /**
@@ -15,13 +18,17 @@ import java.util.UUID
* values are migrated on first run (read old key, write encrypted, delete * values are migrated on first run (read old key, write encrypted, delete
* old key) so an upgrade never loses the pairing. * old key) so an upgrade never loses the pairing.
*/ */
class AndroidSecureStore(context: Context) : SecureStore { class AndroidSecureStore(
context: Context,
) : SecureStore {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE) private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
private val prefs: SharedPreferences = EncryptedSharedPreferences.create( private val prefs: SharedPreferences =
EncryptedSharedPreferences.create(
appContext, appContext,
SECURE_PREFS_NAME, SECURE_PREFS_NAME,
MasterKey.Builder(appContext) MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(), .build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
@@ -50,7 +57,8 @@ class AndroidSecureStore(context: Context) : SecureStore {
} }
if (migrated) editor.apply() if (migrated) editor.apply()
// The plain store must not keep a copy of any value. // The plain store must not keep a copy of any value.
plainPrefs.edit() plainPrefs
.edit()
.remove(KEY_URL) .remove(KEY_URL)
.remove(KEY_TOKEN) .remove(KEY_TOKEN)
.remove(KEY_DEVICE_ID) .remove(KEY_DEVICE_ID)
@@ -69,11 +77,24 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getString(KEY_TOKEN, "").orEmpty() get() = prefs.getString(KEY_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply() set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
override var deviceToken: String
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
override var pinnedCertFingerprint: String
get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty()
set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply()
override val deviceId: String override val deviceId: String
get() { get() {
var id = prefs.getString(KEY_DEVICE_ID, null) var id = prefs.getString(KEY_DEVICE_ID, null)
if (id.isNullOrBlank()) { if (id.isNullOrBlank()) {
id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16) id = "dev_" +
UUID
.randomUUID()
.toString()
.replace("-", "")
.take(16)
prefs.edit().putString(KEY_DEVICE_ID, id).apply() prefs.edit().putString(KEY_DEVICE_ID, id).apply()
} }
return id return id
@@ -98,6 +119,10 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty() get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply() set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
override var pushBackend: String
get() = prefs.getString(KEY_PUSH_BACKEND, "").orEmpty()
set(value) = prefs.edit().putString(KEY_PUSH_BACKEND, value).apply()
override var threadsEnabled: Boolean override var threadsEnabled: Boolean
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false) get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply() set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
@@ -110,6 +135,10 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true) get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply() set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
override var streamSmoothness: Float
get() = prefs.getFloat(KEY_STREAM_SMOOTHNESS, STREAM_SMOOTHNESS_DEFAULT)
set(value) = prefs.edit().putFloat(KEY_STREAM_SMOOTHNESS, value).apply()
override var reasoningAutoCollapse: Boolean override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true) get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply() set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
@@ -123,7 +152,11 @@ class AndroidSecureStore(context: Context) : SecureStore {
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply() set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
override var backgroundMode: String override var backgroundMode: String
get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" } get() =
prefs
.getString(KEY_BACKGROUND_MODE, BackgroundMode.Image.name.lowercase())
.orEmpty()
.ifBlank { BackgroundMode.Image.name.lowercase() }
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply() set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
override var backgroundColor: Int override var backgroundColor: Int
@@ -131,20 +164,54 @@ class AndroidSecureStore(context: Context) : SecureStore {
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply() set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
override var backgroundImagePath: String override var backgroundImagePath: String
get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty() get() =
prefs
.getString(KEY_BACKGROUND_IMAGE_PATH, Backdrop.DEFAULT.path)
.orEmpty()
.ifBlank { Backdrop.DEFAULT.path }
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply() set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
override var fontSizeScale: Float override var fontSizeScale: Float
get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f) get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f)
set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply() set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply()
override fun savePairing(url: String, token: String) { override var runtimeFooterEnabled: Boolean
get() = prefs.getBoolean(KEY_RUNTIME_FOOTER_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_RUNTIME_FOOTER_ENABLED, value).apply()
override var runtimeFooterFields: String
get() = prefs.getString(KEY_RUNTIME_FOOTER_FIELDS, "").orEmpty()
set(value) = prefs.edit().putString(KEY_RUNTIME_FOOTER_FIELDS, value).apply()
override fun savePairing(
url: String,
token: String,
) {
serverUrl = url serverUrl = url
this.token = token this.token = token
// A (re-)pair may target a different gateway: the old per-device
// token is dead there. The next hello.ack re-mints/returns it.
deviceToken = ""
} }
override fun clear() { override fun clear() {
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply() // M-10: clearing pairing must also wipe the device identity + push
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
prefs
.edit()
.remove(KEY_URL)
.remove(KEY_TOKEN)
.remove(KEY_DEVICE_TOKEN)
.remove(KEY_DEVICE_ID)
.remove(KEY_SYNC_CURSOR)
.remove(KEY_FCM_TOKEN)
.remove(KEY_NTFY_TOPIC)
.remove(KEY_NTFY_SERVER)
.remove(KEY_PUSH_BACKEND)
.remove(KEY_PINNED_CERT)
.apply()
} }
private companion object { private companion object {
@@ -152,14 +219,18 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val SECURE_PREFS_NAME = "iris_secure" const val SECURE_PREFS_NAME = "iris_secure"
const val KEY_URL = "server_url" const val KEY_URL = "server_url"
const val KEY_TOKEN = "token" const val KEY_TOKEN = "token"
const val KEY_DEVICE_TOKEN = "device_token"
const val KEY_DEVICE_ID = "device_id" const val KEY_DEVICE_ID = "device_id"
const val KEY_SYNC_CURSOR = "sync_cursor" const val KEY_SYNC_CURSOR = "sync_cursor"
const val KEY_FCM_TOKEN = "fcm_token" const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server" const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_PUSH_BACKEND = "push_backend"
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
const val KEY_THREADS_ENABLED = "threads_enabled" const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled" const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_STREAM_SMOOTHNESS = "stream_smoothness"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse" const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color" const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color" const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
@@ -167,5 +238,7 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_BACKGROUND_COLOR = "background_color" const val KEY_BACKGROUND_COLOR = "background_color"
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path" const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
const val KEY_FONT_SIZE_SCALE = "font_size_scale" const val KEY_FONT_SIZE_SCALE = "font_size_scale"
const val KEY_RUNTIME_FOOTER_ENABLED = "runtime_footer_enabled"
const val KEY_RUNTIME_FOOTER_FIELDS = "runtime_footer_fields"
} }
} }
@@ -0,0 +1,12 @@
package iris.platform
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import iris.db.IrisDatabase
actual fun appDataDir(): String = AndroidEnv.context.filesDir.absolutePath
// The schema-aware driver creates the schema on first run and applies
// pending .sqm migrations on existing DBs (e.g. v1 -> v2: the `tool`
// table), stamping PRAGMA user_version along the way.
actual fun createCacheDriver(): SqlDriver = AndroidSqliteDriver(IrisDatabase.Schema, AndroidEnv.context, "iris_cache.db")
@@ -22,7 +22,10 @@ import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/" private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable @Composable
actual fun PlatformWebView(html: String, modifier: Modifier) { actual fun PlatformWebView(
html: String,
modifier: Modifier,
) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL) val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier) WebView(state, modifier = modifier)
@@ -33,16 +36,18 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack) val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) { DisposableEffect(dispatcher) {
if (dispatcher != null) { val callback =
val callback = object : OnBackPressedCallback(true) { dispatcher?.let { d ->
val c =
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
currentOnBack.value() currentOnBack.value()
} }
} }
dispatcher.addCallback(callback) d.addCallback(c)
onDispose { callback.remove() } c
} }
onDispose { } onDispose { callback?.remove() }
} }
return this return this
} }
@@ -14,7 +14,14 @@ object AppBridge {
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
/** True while the launcher activity is resumed (set by MainActivity). */ /** True while the launcher activity is resumed (set by MainActivity).
* A change is forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = false var foreground: Boolean = false
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
} }
@@ -4,39 +4,59 @@ import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import iris.net.GatewayClient
/** /**
* M5: FCM handler (docs/08 §8.1). * M5: FCM handler (docs/08 §8.1).
* *
* - [onNewToken]: persist the rotated token and push it to the server via * - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token). * `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: the data payload drives a silent sync. When the app * - [onMessageReceived]: posts a system notification from the data payload.
* is foregrounded the WS path already delivered the frame (in-app banner), * When the app is foregrounded the SSE path already delivered the frame
* so we only post a system notification when backgrounded. * (in-app banner), so we only post a notification when backgrounded.
* *
* Inert without a Firebase project (no google-services.json): the service is * Inert without a Firebase project (no google-services.json): the service is
* declared in the manifest but never receives messages, and the app falls * declared in the manifest but never receives messages, and the app falls
* back to the ntfy listener. * back to the ntfy listener.
*/ */
class IrisFirebaseMessagingService : FirebaseMessagingService() { class IrisFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
val store = AndroidSecureStore(applicationContext) val store = AndroidSecureStore(applicationContext)
store.fcmToken = token store.fcmToken = token
// Push the rotation to the server if we're connected. // Push the rotation to the server if we're connected (the ntfy topic
// rides along so a wiped registry recovers both push tokens).
AppBridge.controller?.client?.sendFrame( AppBridge.controller?.client?.sendFrame(
iris.protocol.fcmRegisterFrame(fcmToken = token), iris.protocol.fcmRegisterFrame(
fcmToken = token,
ntfyTopic = store.ntfyTopic.ifBlank { null },
),
) )
} }
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
// Foreground + live SSE: the in-app banner already showed this.
if (AppBridge.foreground) return
// Live SSE: the frame arrives over the stream and the controller
// mirrors it to a system notification itself — posting here would
// duplicate it (docs/08 §8.7).
if (AppBridge.controller
?.client
?.state
?.value is GatewayClient.State.Connected
) {
return
}
// Backgrounded/killed: FCM already displayed the `notification`
// payload on our behalf (the data payload only carries sync
// metadata). Posting again would show a second notification with a
// different id. Data-only messages (no notification payload) are the
// exception: the app must display them itself.
if (message.notification != null) return
val data = message.data val data = message.data
val chatId = data["chat_id"] ?: "android:default" val chatId = data["chat_id"] ?: "default"
val threadId = data["thread_id"] val threadId = data["thread_id"]
val title = data["title"] ?: "Iris" val title = data["title"] ?: "Iris"
val body = data["body"] ?: data["title"].orEmpty() val body = data["body"] ?: data["title"].orEmpty()
// Foreground + live WS: the in-app banner already showed this.
if (AppBridge.foreground) return
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
) { ) {
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
/** /**
@@ -21,15 +20,22 @@ object IrisNotifications {
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT" const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000 private const val NOTIF_ID_BASE = 1_000_000
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) { // L-31: notification channel ids are capped at 64 chars (Android limit) and
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return // are user-visible, so a long server-provided chatId must be truncated.
private fun channelIdFor(chatId: String): String = (CHANNEL_PREFIX + chatId).take(64)
fun ensureChannel(
context: Context,
chatId: String,
chatName: String? = null,
) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val id = CHANNEL_PREFIX + chatId val id = channelIdFor(chatId)
val name = chatName ?: chatId val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) { if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel( nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT) NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "Iris messages for $name" } .apply { description = "Iris messages for $name" },
) )
} }
} }
@@ -43,14 +49,18 @@ object IrisNotifications {
threadId: String?, threadId: String?,
) { ) {
ensureChannel(context, chatId, chatName) ensureChannel(context, chatId, chatName)
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff) // L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
val intent = Intent(ACTION_OPEN_CHAT).apply { // to the same notification id and clobber each other.
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff)
val intent =
Intent(ACTION_OPEN_CHAT).apply {
setPackage(context.packageName) setPackage(context.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("chat_id", chatId) putExtra("chat_id", chatId)
if (threadId != null) putExtra("thread_id", threadId) if (threadId != null) putExtra("thread_id", threadId)
} }
val pi = PendingIntent.getActivity( val pi =
PendingIntent.getActivity(
context, context,
id, id,
intent, intent,
@@ -59,7 +69,8 @@ object IrisNotifications {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify( nm.notify(
id, id,
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId) NotificationCompat
.Builder(context, channelIdFor(chatId))
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
@@ -11,11 +11,13 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -28,18 +30,31 @@ import java.util.concurrent.TimeUnit
* *
* A foreground service that subscribes to this device's ntfy topic and posts * A foreground service that subscribes to this device's ntfy topic and posts
* a system notification for each push. The structured payload rides in the * a system notification for each push. The structured payload rides in the
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body * `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
* is the short preview. When the app is foregrounded the WS path already * body is the short preview. When the app is foregrounded the SSE path
* delivered the frame, so the service skips posting to avoid a duplicate. * already delivered the frame, so the service skips posting to avoid a
* duplicate.
*
* The stream is reconnected with capped exponential backoff when it drops
* (EOF, network error, or a non-2xx response) — `START_STICKY` alone only
* restarts the service after process death, not after a failed read.
*/ */
class NtfyListenerService : Service() { class NtfyListenerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null private var streamJob: Job? = null
private val client = OkHttpClient.Builder() private val client =
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream OkHttpClient
.Builder()
// ntfy sends keep-alive comments every ~10 s; a 60 s read timeout
// detects a half-open connection instead of hanging forever.
.readTimeout(60, TimeUnit.SECONDS)
.build() .build()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
startForeground(NOTIF_ID, foregroundNotification()) startForeground(NOTIF_ID, foregroundNotification())
streamJob?.cancel() streamJob?.cancel()
streamJob = scope.launch { stream() } streamJob = scope.launch { stream() }
@@ -60,24 +75,56 @@ class NtfyListenerService : Service() {
if (topic.isBlank()) return if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/") val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic" val url = "$server/$topic"
val request = Request.Builder() val request =
Request
.Builder()
.url(url) .url(url)
.header("Accept", "text/event-stream") .header("Accept", "text/event-stream")
.build() .build()
var backoff = 1_000L
while (true) {
try { try {
client.newCall(request).execute().use { resp -> client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return if (!resp.isSuccessful) {
IrisLog.w("ntfy stream HTTP ${resp.code}")
} else {
val body = resp.body ?: return val body = resp.body ?: return
val source = body.source() readEvents(body.source())
}
}
} catch (e: Exception) {
IrisLog.w("ntfy stream dropped: ${e.message}")
}
// Stream ended (EOF, error, or non-2xx): back off and reconnect.
delay(backoff)
backoff = (backoff * 2).coerceAtMost(30_000L)
}
}
/**
* Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF;
* do NOT use `source.exhausted()` here — it reads until EOF and would
* block forever on a live stream.
*/
private fun readEvents(source: okio.BufferedSource) {
var data: String? = null var data: String? = null
var title: String? = null var title: String? = null
var msgBody: String? = null var msgBody: String? = null
while (!source.exhausted()) { while (true) {
val line = source.readUtf8Line() ?: break val line = source.readUtf8Line() ?: break
when { when {
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim() line.startsWith("X-Data:") -> {
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim() data = line.removePrefix("X-Data:").trim()
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim() }
line.startsWith("X-Title:") -> {
title = line.removePrefix("X-Title:").trim()
}
line.startsWith("data:") -> {
msgBody = line.removePrefix("data:").trim()
}
line.isEmpty() -> { line.isEmpty() -> {
// Event boundary: process the accumulated message. // Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) } data?.let { handleData(it, title, msgBody) }
@@ -88,19 +135,19 @@ class NtfyListenerService : Service() {
} }
} }
} }
} catch (_: Exception) {
// Stream dropped; the service is START_STICKY so the system
// restarts it. If it keeps failing, the WS path still works.
}
}
private fun handleData(dataJson: String, title: String?, msgBody: String?) { private fun handleData(
val data = try { dataJson: String,
title: String?,
msgBody: String?,
) {
val data =
try {
IrisJson.instance.decodeFromString<JsonObject>(dataJson) IrisJson.instance.decodeFromString<JsonObject>(dataJson)
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
val chatId = data?.str("chat_id") ?: "android:default" val chatId = data?.str("chat_id") ?: "default"
val threadId = data?.str("thread_id") val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the // The short preview rides in the SSE `data:` field; fall back to the
// X-Title, then a generic label. // X-Title, then a generic label.
@@ -126,11 +173,12 @@ class NtfyListenerService : Service() {
LISTENER_CHANNEL, LISTENER_CHANNEL,
"Iris push listener", "Iris push listener",
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_MIN,
) ),
) )
} }
} }
return NotificationCompat.Builder(context, LISTENER_CHANNEL) return NotificationCompat
.Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris") .setContentTitle("Iris")
.setContentText("Listening for messages") .setContentText("Listening for messages")
@@ -146,5 +194,4 @@ class NtfyListenerService : Service() {
} }
/** Read a string field from a JSON object (null when absent / not a string). */ /** Read a string field from a JSON object (null when absent / not a string). */
private fun JsonObject?.str(key: String): String? = private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
(this?.get(key) as? JsonPrimitive)?.content
@@ -0,0 +1,49 @@
package iris.platform
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
* scanned text (or null on cancel) to [onResult].
*/
@Composable
actual fun QrScanButton(onResult: (String?) -> Unit) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
val text =
if (result.resultCode == Activity.RESULT_OK) {
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
} else {
null
}
onResult(text)
}
Button(onClick = {
val activity = context.resolveActivity()
if (activity != null) {
launcher.launch(Intent(activity, QrScanActivity::class.java))
}
}) {
Text("Scan QR")
}
}
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
private fun Context.resolveActivity(): Activity? =
when (this) {
is Activity -> this
is ContextWrapper -> baseContext.resolveActivity()
else -> null
}
@@ -0,0 +1,136 @@
package iris.platform
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.ComponentActivity
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
/**
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
*
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
* stopped in [onDestroy].
*/
class QrScanActivity : ComponentActivity() {
companion object {
/** Intent extra carrying the scanned QR text. */
const val EXTRA_QR = "qr"
}
private lateinit var previewView: PreviewView
private var cameraProvider: ProcessCameraProvider? = null
private var settled = false
private val permissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) startCamera() else finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
previewView =
PreviewView(this).apply {
layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
setContentView(previewView)
onBackPressedDispatcher.addCallback(this) {
if (!settled) setResult(Activity.RESULT_CANCELED)
finish()
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
) {
startCamera()
} else {
permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
private fun startCamera() {
val future = ProcessCameraProvider.getInstance(this)
future.addListener(
{
val provider = future.get()
cameraProvider = provider
val preview =
Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val analyzer =
ImageAnalysis
.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
try {
provider.unbindAll()
provider.bindToLifecycle(
this,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analyzer,
)
} catch (e: Exception) {
// No usable camera (e.g. headless emulator): bail out.
finish()
return@addListener
}
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
analyzeImage(proxy)
}
},
ContextCompat.getMainExecutor(this),
)
}
private fun analyzeImage(proxy: ImageProxy) {
val mediaImage = proxy.image
if (mediaImage == null) {
proxy.close()
return
}
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
BarcodeScanning
.getClient()
.process(inputImage)
.addOnSuccessListener { barcodes ->
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
if (text != null) finishWithResult(text)
}.addOnCompleteListener { proxy.close() }
}
private fun finishWithResult(text: String) {
if (settled) return
settled = true
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
finish()
}
override fun onDestroy() {
cameraProvider?.unbindAll()
super.onDestroy()
}
}
@@ -10,6 +10,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -26,19 +27,21 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage import iris.ui.theme.rememberBackgroundImage
import iris.util.PairLink
/** /**
* Root composable shared by the Android and Desktop shells. * Root composable shared by the Android and Desktop shells.
* *
* M1: routes between the Connect screen (unpaired / auth failed) and the * Routes between the Connect screen (unpaired / auth failed) and the main
* Chat screen (paired). Later milestones add the channel list, search, * app (paired), which hosts the channel list, chat, search, settings, and
* settings, and media (docs/10-android-app.md). * media (docs/10-android-app.md).
*/ */
@Composable @Composable
fun IrisApp( fun IrisApp(
store: SecureStore, store: SecureStore,
deepLinkChatId: String? = null, deepLinkChatId: String? = null,
deepLinkThreadId: String? = null, deepLinkThreadId: String? = null,
deepLinkPair: PairLink? = null,
) { ) {
val controller = remember(store) { IrisController(store) } val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) { DisposableEffect(controller) {
@@ -66,7 +69,8 @@ fun IrisApp(
// untouched, so the UI keeps its proportions at any size. // untouched, so the UI keeps its proportions at any size.
CompositionLocalProvider( CompositionLocalProvider(
LocalUserTheme provides theme, LocalUserTheme provides theme,
LocalDensity provides Density( LocalDensity provides
Density(
density = baseDensity.density, density = baseDensity.density,
fontScale = baseDensity.fontScale * fontScale, fontScale = baseDensity.fontScale * fontScale,
), ),
@@ -95,20 +99,49 @@ fun IrisApp(
) )
} }
val s = state val s = state
// QR pairing (docs/20): an iris://pair deep link prefills
// the Connect screen, taking priority over stored creds.
val pairUrl = deepLinkPair?.url ?: store.serverUrl
val pairToken = deepLinkPair?.token ?: store.token
when (s) { when (s) {
GatewayClient.State.Disconnected -> GatewayClient.State.Disconnected -> {
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token) key(deepLinkPair) {
is GatewayClient.State.AuthFailed -> ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
}
}
is GatewayClient.State.AuthFailed -> {
key(deepLinkPair) {
ConnectScreen( ConnectScreen(
controller, controller,
prefillUrl = store.serverUrl, prefillUrl = pairUrl,
prefillToken = store.token, prefillToken = pairToken,
initialError = "Pairing rejected: ${s.message}", initialError = "Pairing rejected: ${s.message}",
) )
}
}
// TLS: the gateway's certificate is untrusted and not
// the pinned one (docs/09 §9.4) — ask the user to
// confirm its fingerprint (SSH host-key style).
is GatewayClient.State.TlsConfirmRequired -> {
key(deepLinkPair) {
ConnectScreen(
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.",
tlsFingerprint = s.fingerprint,
)
}
}
// Connecting / Reconnecting / Connected all render the chat; the header // Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state // status bubble + connection banner show the link state
// without blocking the view (M7's full-screen spinner is gone). // without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller) else -> {
ChatScreen(controller)
}
} }
// M9: full-screen HTML artifact preview (opened from an // M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open. // artifact card in the chat); covers everything while open.
@@ -26,15 +26,32 @@ class ChannelStore {
_channels.value = sorted(channels) _channels.value = sorted(channels)
} }
/** Restore the directory from the persistent local cache on startup
* (offline: the drawer is populated before the gateway connection is
* up). The server re-seeds it on connect (`hello.ack`). No-op when
* [channels] is empty (first launch). */
fun loadFromCache(channels: List<ChannelInfo>) {
if (channels.isNotEmpty()) _channels.value = sorted(channels)
}
/** Reconcile a server frame into the cache. */ /** Reconcile a server frame into the cache. */
fun onFrame(frame: Frame) { fun onFrame(frame: Frame) {
when (frame.type) { when (frame.type) {
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame) TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> {
TYPE_CHANNEL_DELETED -> remove(frame) upsert(frame)
}
TYPE_CHANNEL_DELETED -> {
remove(frame)
}
TYPE_CHANNEL_LIST -> { TYPE_CHANNEL_LIST -> {
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) } frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
} }
else -> Unit
else -> {
Unit
}
} }
} }
@@ -48,7 +65,58 @@ class ChannelStore {
private fun remove(frame: Frame) { private fun remove(frame: Frame) {
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
_channels.value = _channels.value.filter { it.chatId != p.chatId } // A channel delete also removes its threads (child entries); a thread
// delete removes just that thread. Both are hard deletes server-side.
_channels.value =
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
}
// ── Optimistic local updates (M-7) ────────────────────────────────────
//
// The gateway only broadcasts channel.created/renamed/deleted — there are
// no favorite/icon/automation/default events. So a toggle sent by THIS
// device would not update its own UI until a full channel.list re-fetch.
// These apply the change locally (optimistically); a later channel.list /
// hello.ack re-seed reconciles any divergence (e.g. a server rejection).
/** Toggle the cosmetic favorite flag locally. */
fun setFavorite(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(favorite = on) }
/** Toggle the automation flag locally. */
fun setAutomation(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(automation = on) }
/** Set the icon (base64) and/or avatar color locally; null clears a field. */
fun setIcon(
chatId: String,
icon: String?,
color: String?,
) = update(chatId) { it.copy(icon = icon, color = color) }
/** Make [chatId] the default channel locally (clearing the previous one). */
fun setDefault(chatId: String) {
_channels.value =
sorted(
_channels.value.map {
when {
it.chatId == chatId -> it.copy(isDefault = true)
it.isDefault -> it.copy(isDefault = false)
else -> it
}
},
)
}
private fun update(
chatId: String,
transform: (ChannelInfo) -> ChannelInfo,
) {
_channels.value = sorted(_channels.value.map { if (it.chatId == chatId) transform(it) else it })
} }
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> = private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
@@ -62,18 +130,14 @@ class ChannelStore {
// ── UI helpers ──────────────────────────────────────────────────────── // ── UI helpers ────────────────────────────────────────────────────────
/** Channels (default + user channels) for the drawer/rail. */ /** Channels (default + user channels) for the drawer/rail. */
fun channelsForDrawer(): List<ChannelInfo> = fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" }
_channels.value.filter { it.kind != "thread" }
/** Threads under a channel, for the topic switcher. */ /** Threads under a channel, for the topic switcher. */
fun threadsFor(chatId: String): List<ChannelInfo> = fun threadsFor(chatId: String): List<ChannelInfo> = _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
fun byId(chatId: String): ChannelInfo? = fun byId(chatId: String): ChannelInfo? = _channels.value.firstOrNull { it.chatId == chatId }
_channels.value.firstOrNull { it.chatId == chatId }
fun defaultChannel(): ChannelInfo? = fun defaultChannel(): ChannelInfo? = _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
_channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
fun clear() { fun clear() {
_channels.value = emptyList() _channels.value = emptyList()
@@ -0,0 +1,203 @@
package iris.data
import app.cash.sqldelight.db.SqlDriver
import iris.db.IrisDatabase
import iris.protocol.ChannelInfo
import iris.protocol.IrisJson
/**
* Persistent local cache (SQLite via SQLDelight — the locked "Local DB (KMP)"
* decision, docs/16; schema docs/10 §10.7). The server is authoritative; this
* is a cache that lets the app start instantly and read chats offline:
*
* - [loadLanes] / [loadChannels] restore the in-memory stores on startup,
* before the gateway connection is up (snappy start, offline reading).
* - [saveLanes] / [saveChannels] snapshot the in-memory stores (debounced by
* the controller) so every reconciled frame survives a process death.
* - [metaGet] / [metaPut] hold small UI state (last-viewed lane).
*
* [MessageItem]s and [ToolItem]s are persisted; live streaming state and
* local system notices are ephemeral. Rows are JSON payloads keyed by
* (lane, id), so the schema does not drift with the model fields.
*
* All access is synchronized: the debounced save collectors run on the
* controller scope while [dispose] may flush from the UI thread.
*/
class ChatDb(
driver: SqlDriver,
) {
private val db = IrisDatabase(driver)
private val json = IrisJson.instance
private val lock = Any()
// ── messages ──────────────────────────────────────────────────────────
/** All persisted lanes (lane key -> items: messages ordered by ts with
* tool cards interleaved at their anchored position). */
fun loadLanes(): Map<String, List<ChatItem>> =
synchronized(lock) {
val messages = linkedMapOf<String, MutableList<ChatItem>>()
for (row in db.cacheQueries.allMessages().executeAsList()) {
val item = decodeMessage(row.payload) ?: continue
messages.getOrPut(row.lane) { mutableListOf() }.add(item)
}
val tools = linkedMapOf<String, MutableList<ToolItem>>()
for (row in db.cacheQueries.allTools().executeAsList()) {
val item = decodeTool(row.payload) ?: continue
tools.getOrPut(row.lane) { mutableListOf() }.add(item)
}
(messages.keys + tools.keys).distinct().associateWith { lane ->
val items: MutableList<ChatItem> = messages[lane].orEmpty().toMutableList()
// Insert each tool card after its anchor message (the message
// it followed live). Cards sharing an anchor keep their seq
// order; a card whose anchor is gone (deleted message) falls
// to the end of the lane. The lastPos cache assumes anchors
// are monotonically non-decreasing per lane (true for the
// onToolStart anchor rule: last non-streaming message) — a
// tool anchored to an EARLIER message processed after a
// later-anchored one would be misplaced.
val lastPos = mutableMapOf<String, Int>()
for (tool in tools[lane].orEmpty()) {
val anchor = tool.anchorId
val pos =
if (anchor == null) {
-1
} else {
lastPos.getOrPut(anchor) { items.indexOfFirst { it.id == anchor } }
}
if (pos >= 0) {
items.add(pos + 1, tool)
if (anchor != null) lastPos[anchor] = pos + 1
} else {
items.add(tool)
}
}
items
}
}
/** Replace the whole message + tool cache with [lanes] (atomic snapshot).
* Local system notices are skipped (ephemeral). */
fun saveLanes(lanes: Map<String, List<ChatItem>>) {
synchronized(lock) {
db.transaction {
db.cacheQueries.clearMessages()
db.cacheQueries.clearTools()
for ((lane, items) in lanes) {
var toolSeq = 0
for (item in items) {
when (item) {
is MessageItem -> {
if (!item.isSystem) {
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
}
}
is ToolItem -> {
db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item))
}
// Picker cards ride in the message table (JSON
// payload; decodeMessage picks the type back out).
is PickerItem -> {
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
}
}
}
}
}
}
}
// ── channels ──────────────────────────────────────────────────────────
/** The persisted channel directory (channels + threads). */
fun loadChannels(): List<ChannelInfo> =
synchronized(lock) {
val list = mutableListOf<ChannelInfo>()
for (row in db.cacheQueries.allChannels().executeAsList()) {
try {
list.add(json.decodeFromString(row.payload))
} catch (_: Exception) {
// Corrupt row — skip it; the server re-seeds on connect.
}
}
list
}
/** Replace the whole channel directory (atomic snapshot). */
fun saveChannels(channels: List<ChannelInfo>) {
synchronized(lock) {
db.transaction {
db.cacheQueries.clearChannels()
for (c in channels) {
db.cacheQueries.upsertChannel(c.chatId, json.encodeToString(c))
}
}
}
}
// ── meta ──────────────────────────────────────────────────────────────
fun metaGet(key: String): String? =
synchronized(lock) {
db.cacheQueries.metaGet(key).executeAsOneOrNull()
}
fun metaPut(
key: String,
value: String,
) {
synchronized(lock) {
db.cacheQueries.metaPut(key, value)
}
}
/** Wipe the whole cache (forget pairing → a different gateway). */
fun clearAll() {
synchronized(lock) {
db.transaction {
db.cacheQueries.clearMessages()
db.cacheQueries.clearTools()
db.cacheQueries.clearChannels()
db.cacheQueries.clearMeta()
}
}
}
private fun decodeMessage(payload: String): ChatItem? =
try {
json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
} catch (_: Exception) {
// Not a message payload — a picker card (MessageItem requires
// "role", PickerItem requires "title": the two never cross-decode).
try {
json.decodeFromString<PickerItem>(payload)
} catch (_: Exception) {
null
}
}
private fun decodeTool(payload: String): ToolItem? =
try {
json.decodeFromString<ToolItem>(payload).sanitizeForRestore()
} catch (_: Exception) {
null
}
/** A restored message is never mid-flight: a streaming bubble is
* finalized (the sync replay finalizes it for real on reconnect) and a
* pending send becomes failed (tap to retry) — the gateway never
* acknowledged it before the process died. */
private fun MessageItem.sanitizeForRestore(): MessageItem =
copy(
streaming = false,
pending = false,
status = if (status == MsgStatus.Pending) MsgStatus.Failed else status,
)
/** A restored tool card is never mid-flight: an open card (the process
* died before tool.end) is closed as interrupted, mirroring
* [ChatStore.finalizeInterrupted]. */
private fun ToolItem.sanitizeForRestore(): ToolItem = if (done) this else copy(done = true, ok = false)
}
@@ -8,17 +8,24 @@ import iris.protocol.MessagePayload
import iris.protocol.MessageStartPayload import iris.protocol.MessageStartPayload
import iris.protocol.MessageStopPayload import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload import iris.protocol.MessageUpdatePayload
import iris.protocol.PickerChoice
import iris.protocol.PickerChoicePayload
import iris.protocol.ROLE_ASSISTANT import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER import iris.protocol.ROLE_USER
import iris.protocol.RuntimeMeta
import iris.protocol.TYPE_COMMENTARY import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MEDIA_OFFER import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_PICKER_CHOICE
import iris.protocol.TYPE_TODO_UPDATE
import iris.protocol.TYPE_TOOL_END import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START import iris.protocol.TYPE_TOOL_START
import iris.protocol.TodoItem
import iris.protocol.TodoUpdatePayload
import iris.protocol.ToolEndPayload import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload import iris.protocol.ToolStartPayload
@@ -26,6 +33,7 @@ import iris.util.nowMillis
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlin.random.Random import kotlin.random.Random
@@ -53,7 +61,9 @@ enum class MsgStatus {
/** A chat message (user / assistant / commentary / streaming bubble). /** A chat message (user / assistant / commentary / streaming bubble).
* [isSystem] marks a locally generated, centered notice (gateway restart / * [isSystem] marks a locally generated, centered notice (gateway restart /
* online) — not a user or agent turn, not selectable or deletable. */ * online) — not a user or agent turn, not selectable or deletable.
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
@Serializable
data class MessageItem( data class MessageItem(
override val id: String, override val id: String,
val role: String, val role: String,
@@ -66,6 +76,7 @@ data class MessageItem(
val streaming: Boolean = false, val streaming: Boolean = false,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val media: List<MediaItem> = emptyList(), val media: List<MediaItem> = emptyList(),
val isSystem: Boolean = false, val isSystem: Boolean = false,
) : ChatItem ) : ChatItem
@@ -75,6 +86,7 @@ data class MessageItem(
* has been pulled to the local cache (outbound) — inbound attachments the * has been pulled to the local cache (outbound) — inbound attachments the
* app itself uploaded carry no local path (the agent reads the server copy). * app itself uploaded carry no local path (the agent reads the server copy).
*/ */
@Serializable
data class MediaItem( data class MediaItem(
val mediaId: String, val mediaId: String,
val kind: String, val kind: String,
@@ -84,18 +96,42 @@ data class MediaItem(
val localPath: String? = null, val localPath: String? = null,
) )
/** A structured tool-activity card (spinner until [done]). */ /** A structured tool-activity card (spinner until [done]).
* [anchorId] is the id of the message this card follows in the lane (the
* last non-streaming message when the tool started) — persisted with the
* card so a restart restores it in its correct position (user message →
* tool card → answer) instead of dropping it or appending it at the end.
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
@Serializable
data class ToolItem( data class ToolItem(
override val id: String, override val id: String,
val index: Int, val index: Int,
val name: String, val name: String,
val preview: String? = null, val preview: String? = null,
val args: JsonElement? = null, val args: JsonElement? = null,
/** Cosmetic per-tool glyph from the gateway (hermes get_tool_emoji);
* null for unknown tools / older frames — the UI falls back to 🔧. */
val emoji: String? = null,
val note: String? = null, val note: String? = null,
val done: Boolean = false, val done: Boolean = false,
val ok: Boolean = true, val ok: Boolean = true,
val duration: Double? = null, val duration: Double? = null,
val outputPreview: String? = null, val outputPreview: String? = null,
val anchorId: String? = null,
) : ChatItem
/** An interactive choice picker card (one tap → one value), sent by
* finite-choice slash commands (/reasoning, /fast, …) via `picker.choice`.
* [selected] is the value the user tapped (null = still pending); the
* server's reply arrives as a normal message afterwards.
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
@Serializable
data class PickerItem(
override val id: String, // picker_id (unique; the picker.select key)
val title: String,
val choices: List<PickerChoice> = emptyList(),
val ts: Long = 0,
val selected: String? = null,
) : ChatItem ) : ChatItem
class ChatStore { class ChatStore {
@@ -107,8 +143,25 @@ class ChatStore {
private val _currentLane = MutableStateFlow(DEFAULT_LANE) private val _currentLane = MutableStateFlow(DEFAULT_LANE)
val currentLane: StateFlow<String> = _currentLane.asStateFlow() val currentLane: StateFlow<String> = _currentLane.asStateFlow()
private var localSeq = 0 /** The agent's live todo list per lane (todo.update; last-write-wins).
private var toolSeq = 0 * Ephemeral: not persisted — the gateway re-sends a snapshot when the
* app reconnects, and the next `todo` tool call refreshes it. */
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow()
/** Unread message count per lane (M8: unread indicator). Ephemeral
* (in-memory): a process death resets it, and the `sync` delta re-counts
* genuinely new messages on reconnect. A lane absent from the map has
* no unread messages. */
private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
/** Single lock for the lane/todo/unread maps: they are mutated from the
* UI thread (addPending via send) and the frame-collector thread
* (Dispatchers.Default). A non-atomic read-modify-write loses a frame
* that lands between the read and the write (e.g. a streaming delta
* dropped while the user sends). */
private val lock = Any()
/** When false, `message.start`/`message.update` frames are ignored and each /** When false, `message.start`/`message.update` frames are ignored and each
* reply materializes as a single final message on `message.stop` * reply materializes as a single final message on `message.stop`
@@ -117,18 +170,20 @@ class ChatStore {
var streamingEnabled: Boolean = true var streamingEnabled: Boolean = true
companion object { companion object {
const val DEFAULT_LANE = "android:default" const val DEFAULT_LANE = "default"
fun randomId(prefix: String): String = fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
} }
// ── Lane helpers ────────────────────────────────────────────────────── // ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because /** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
* chat ids already contain a single `:` (e.g. `android:chan_1`). */ * thread lane can never collide with a chat id (chat ids are direct,
fun laneKey(chatId: String, threadId: String?): String = * e.g. `chan_1`, and never contain `:`). */
if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId" fun laneKey(
chatId: String,
threadId: String?,
): String = if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
/** Split a lane key back into (chatId, threadId). */ /** Split a lane key back into (chatId, threadId). */
fun parseLane(key: String): Pair<String, String?> { fun parseLane(key: String): Pair<String, String?> {
@@ -145,18 +200,45 @@ class ChatStore {
return laneKey(chatId, frame.threadId) return laneKey(chatId, frame.threadId)
} }
private fun updateLane(lane: String, transform: (List<ChatItem>) -> List<ChatItem>) { private fun updateLane(
lane: String,
transform: (List<ChatItem>) -> List<ChatItem>,
) {
synchronized(lock) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty()) map[lane] = transform(map[lane].orEmpty())
_lanes.value = map _lanes.value = map
} }
}
/** Apply [transform] to every lane, writing back only when something
* changed. Callers must hold [lock]. */
private fun mapLanes(transform: (List<ChatItem>) -> List<ChatItem>) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = transform(list)
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
// ── Optimistic send ─────────────────────────────────────────────────── // ── Optimistic send ───────────────────────────────────────────────────
/** Optimistic add: show the user's message immediately (pending) in [lane]. */ /** Optimistic add: show the user's message immediately (pending) in [lane]. */
fun addPending(text: String, lane: String, media: List<MediaItem> = emptyList()): String { fun addPending(
localSeq++ text: String,
val id = "local_$localSeq" lane: String,
media: List<MediaItem> = emptyList(),
): String {
// Process-unique id: the in-memory seq resets on every ChatStore
// creation, and failed sends are persisted — a restart would
// otherwise re-mint local_1 and collide with the restored bubble
// (duplicate list key).
val id = randomId("local")
updateLane(lane) { updateLane(lane) {
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media) it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
} }
@@ -166,9 +248,14 @@ class ChatStore {
/** Append a locally generated, centered system notice (gateway restart / /** Append a locally generated, centered system notice (gateway restart /
* online) to [lane]. Not a user or agent turn: it is not selectable, * online) to [lane]. Not a user or agent turn: it is not selectable,
* deletable, or echoed to the server. */ * deletable, or echoed to the server. */
fun addSystemMessage(lane: String, text: String) { fun addSystemMessage(
localSeq++ lane: String,
val id = "sys_$localSeq" text: String,
) {
// randomId (not a process-local seq): system messages are persisted,
// and a seq that resets on restart would re-mint sys_0 and collide
// with the restored one (upsert overwrite).
val id = randomId("sys")
updateLane(lane) { updateLane(lane) {
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true) it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
} }
@@ -187,15 +274,20 @@ class ChatStore {
TYPE_TOOL_START -> onToolStart(lane, frame) TYPE_TOOL_START -> onToolStart(lane, frame)
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame) TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
TYPE_TOOL_END -> onToolEnd(lane, frame) TYPE_TOOL_END -> onToolEnd(lane, frame)
TYPE_TODO_UPDATE -> onTodoUpdate(lane, frame)
TYPE_COMMENTARY -> onCommentary(lane, frame) TYPE_COMMENTARY -> onCommentary(lane, frame)
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame) TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
else -> Unit else -> Unit
} }
} }
// ── message (final / standalone, incl. non-streaming + user echo) ───── // ── message (final / standalone, incl. non-streaming + user echo) ─────
private fun onMessage(lane: String, frame: Frame) { private fun onMessage(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessagePayload>() ?: return val p = frame.payloadAs<MessagePayload>() ?: return
// Auto-threaded send: the gateway created a fresh thread for this // Auto-threaded send: the gateway created a fresh thread for this
// message; the optimistic bubble is still in the parent flat lane. // message; the optimistic bubble is still in the parent flat lane.
@@ -205,7 +297,8 @@ class ChatStore {
val byId = list.indexOfFirst { it.id == p.messageId } val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) { if (byId >= 0) {
val cur = list[byId] as? MessageItem ?: return@updateLane list val cur = list[byId] as? MessageItem ?: return@updateLane list
val updated = cur.copy( val updated =
cur.copy(
text = p.text, text = p.text,
reasoning = p.reasoning, reasoning = p.reasoning,
pending = false, pending = false,
@@ -213,34 +306,58 @@ class ChatStore {
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent, status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
model = p.model, model = p.model,
tokens = p.tokens, tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
ts = p.ts ?: cur.ts, ts = p.ts ?: cur.ts,
media = mergeMedia(cur.media, p.media), media = mergeMedia(cur.media, p.media),
) )
list.toMutableList().also { it[byId] = updated } list.toMutableList().also { it[byId] = updated }
} else if (p.role == ROLE_USER) { } else if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo). // Replace the matching optimistic bubble (server echo). Also
val pendingIdx = list.indexOfLast { // matches a FAILED bubble: the send may have arrived after
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text // its POST response was lost in a network drop — the echo is
// the proof of delivery, so reconcile instead of duplicating.
val pendingIdx =
list.indexOfLast {
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed)
} }
if (pendingIdx >= 0) { if (pendingIdx >= 0) {
list.toMutableList().also { list.toMutableList().also {
it[pendingIdx] = MessageItem( it[pendingIdx] =
id = p.messageId, role = p.role, text = p.text, MessageItem(
ts = p.ts ?: 0, reasoning = p.reasoning, id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media), media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
) )
} }
} else { } else {
list + MessageItem( list +
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0, MessageItem(
reasoning = p.reasoning, model = p.model, tokens = p.tokens, id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
media = p.media.map { it.toMediaItem() }, media = p.media.map { it.toMediaItem() },
) )
} }
} else { } else {
list + MessageItem( list +
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0, MessageItem(
reasoning = p.reasoning, model = p.model, tokens = p.tokens, id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
media = p.media.map { it.toMediaItem() }, media = p.media.map { it.toMediaItem() },
) )
} }
@@ -254,50 +371,76 @@ class ChatStore {
* it in the thread lane. No-op when [lane] is not a thread lane or no * it in the thread lane. No-op when [lane] is not a thread lane or no
* pending bubble with the same text exists in the flat lane. * pending bubble with the same text exists in the flat lane.
*/ */
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) { private fun relocatePendingToThreadLane(
lane: String,
p: MessagePayload,
) {
val (chatId, threadId) = parseLane(lane) val (chatId, threadId) = parseLane(lane)
if (threadId == null) return if (threadId == null) return
val flatLane = chatId val flatLane = chatId
synchronized(lock) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty() val flatList = map[flatLane].orEmpty()
val idx = flatList.indexOfLast { val idx =
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text flatList.indexOfLast {
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed)
} }
if (idx < 0) return if (idx < 0) return@synchronized
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) } map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map _lanes.value = map
} }
}
/** Merge server media refs into existing items, keeping local paths. */ /** Merge server media refs into existing items, keeping local paths. */
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> { private fun mergeMedia(
existing: List<MediaItem>,
incoming: List<MediaRef>,
): List<MediaItem> {
if (incoming.isEmpty()) return existing if (incoming.isEmpty()) return existing
val byId = existing.associateBy { it.mediaId } val byId = existing.associateBy { it.mediaId }
return incoming.map { ref -> return incoming.map { ref ->
byId[ref.mediaId]?.copy( byId[ref.mediaId]?.copy(
kind = ref.kind, mime = ref.mime, size = ref.size, filename = ref.filename, kind = ref.kind,
mime = ref.mime,
size = ref.size,
filename = ref.filename,
) ?: ref.toMediaItem() ) ?: ref.toMediaItem()
} }
} }
private fun MediaRef.toMediaItem() = private fun MediaRef.toMediaItem() = MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
// ── message.start (open a live streaming bubble) ────────────────────── // ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(lane: String, frame: Frame) { private fun onMessageStart(
lane: String,
frame: Frame,
) {
if (!streamingEnabled) return if (!streamingEnabled) return
val p = frame.payloadAs<MessageStartPayload>() ?: return val p = frame.payloadAs<MessageStartPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list if (list.any { it.id == p.messageId }) {
else list + MessageItem( list
id = p.messageId, role = p.role, text = "", ts = 0, streaming = true, } else {
list +
MessageItem(
id = p.messageId,
role = p.role,
text = "",
ts = 0,
streaming = true,
) )
} }
} }
}
// ── message.update (replace live bubble text; full snapshot) ────────── // ── message.update (replace live bubble text; full snapshot) ──────────
private fun onMessageUpdate(lane: String, frame: Frame) { private fun onMessageUpdate(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessageUpdatePayload>() ?: return val p = frame.payloadAs<MessageUpdatePayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId } val idx = list.indexOfFirst { it.id == p.messageId }
@@ -309,25 +452,36 @@ class ChatStore {
// ── message.stop (finalize the live bubble) ─────────────────────────── // ── message.stop (finalize the live bubble) ───────────────────────────
private fun onMessageStop(lane: String, frame: Frame) { private fun onMessageStop(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MessageStopPayload>() ?: return val p = frame.payloadAs<MessageStopPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId } val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) { if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one. // No live bubble (e.g. missed start) — materialize a final one.
list + MessageItem( list +
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText, MessageItem(
ts = p.ts ?: 0, reasoning = p.reasoning, id = p.messageId,
model = p.model, tokens = p.tokens, role = ROLE_ASSISTANT,
text = p.finalText,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
) )
} else { } else {
val cur = list[idx] as? MessageItem ?: return@updateLane list val cur = list[idx] as? MessageItem ?: return@updateLane list
list.toMutableList().also { list.toMutableList().also {
it[idx] = cur.copy( it[idx] =
cur.copy(
text = p.finalText, text = p.finalText,
reasoning = p.reasoning, reasoning = p.reasoning,
model = p.model, model = p.model,
tokens = p.tokens, tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
streaming = false, streaming = false,
ts = p.ts ?: cur.ts, ts = p.ts ?: cur.ts,
) )
@@ -338,21 +492,41 @@ class ChatStore {
// ── tool.start (new tool card) ──────────────────────────────────────── // ── tool.start (new tool card) ────────────────────────────────────────
private fun onToolStart(lane: String, frame: Frame) { private fun onToolStart(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolStartPayload>() ?: return val p = frame.payloadAs<ToolStartPayload>() ?: return
toolSeq++ // Process-unique id: the in-memory seq resets on every ChatStore
val id = "tool_$toolSeq" // creation, and cards are persisted — a restart would otherwise
// re-mint tool_1 and collide with the restored card (duplicate list
// key, upsert overwrite). onToolProgress/onToolEnd match by index,
// so non-sequential ids are safe.
val id = randomId("tool")
updateLane(lane) { list -> updateLane(lane) { list ->
list + ToolItem( // Anchor the card to the message it follows: the last non-streaming
id = id, index = p.index, name = p.name, // message (a live streaming bubble is the answer that arrives AFTER
preview = p.preview, args = p.args, // the tool, so it is skipped). Restored with the card on restart.
val anchorId = list.lastOrNull { it is MessageItem && !it.streaming }?.id
list +
ToolItem(
id = id,
index = p.index,
name = p.name,
preview = p.preview,
args = p.args,
emoji = p.emoji,
anchorId = anchorId,
) )
} }
} }
// ── tool.progress (in-progress note) ────────────────────────────────── // ── tool.progress (in-progress note) ──────────────────────────────────
private fun onToolProgress(lane: String, frame: Frame) { private fun onToolProgress(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolProgressPayload>() ?: return val p = frame.payloadAs<ToolProgressPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index } val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
@@ -364,41 +538,70 @@ class ChatStore {
// ── tool.end (mark the tool card complete) ──────────────────────────── // ── tool.end (mark the tool card complete) ────────────────────────────
private fun onToolEnd(lane: String, frame: Frame) { private fun onToolEnd(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<ToolEndPayload>() ?: return val p = frame.payloadAs<ToolEndPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index } val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return@updateLane list if (idx < 0) return@updateLane list
val cur = list[idx] as ToolItem val cur = list[idx] as ToolItem
list.toMutableList().also { list.toMutableList().also {
it[idx] = cur.copy( it[idx] =
done = true, ok = p.ok, duration = p.duration, cur.copy(
done = true,
ok = p.ok,
duration = p.duration,
outputPreview = p.outputPreview, outputPreview = p.outputPreview,
) )
} }
} }
} }
// ── todo.update (the agent's live todo list, last-write-wins) ─────────
private fun onTodoUpdate(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<TodoUpdatePayload>() ?: return
synchronized(lock) {
val map = _todos.value.toMutableMap()
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
_todos.value = map
}
}
// ── commentary (dimmed interim beat) ────────────────────────────────── // ── commentary (dimmed interim beat) ──────────────────────────────────
private fun onCommentary(lane: String, frame: Frame) { private fun onCommentary(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<CommentaryPayload>() ?: return val p = frame.payloadAs<CommentaryPayload>() ?: return
// Gateway lifecycle notices (restart / shutdown / online) are rendered // Gateway lifecycle notices (restart / shutdown / online) are the
// as a centered system notice by the controller, on the down/up state // controller's business: it renders the "restarting" / "online" pair
// transition. The server also emits the "restarting" notice as a // as centered system notices on the down/up state transitions (gated
// commentary frame (and the sync catch-up can replay it *after* the // on the gateway's status{restarting} frame). The server also emits
// local "online" notice), so drop it here: the app is the source of // these as commentary frames (and the sync catch-up can replay them
// truth for these notices, which keeps the order (restarting → online) // out of order), so drop them here to prevent duplicates.
// and prevents duplicates.
if (isGatewayLifecycleNotice(p.text)) return if (isGatewayLifecycleNotice(p.text)) return
updateLane(lane) { list -> updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list if (list.any { it.id == p.messageId }) {
else list + MessageItem( list
id = p.messageId, role = ROLE_ASSISTANT, text = p.text, } else {
ts = 0, isCommentary = true, list +
MessageItem(
id = p.messageId,
role = ROLE_ASSISTANT,
text = p.text,
ts = 0,
isCommentary = true,
) )
} }
} }
}
/** True for hermes gateway lifecycle notices (same wording on all apps). */ /** True for hermes gateway lifecycle notices (same wording on all apps). */
private fun isGatewayLifecycleNotice(text: String): Boolean { private fun isGatewayLifecycleNotice(text: String): Boolean {
@@ -415,16 +618,24 @@ class ChatStore {
* falling back to the lane's last assistant message). The item has no local * falling back to the lane's last assistant message). The item has no local
* path yet — the controller pulls it and calls [setMediaLocalPath]. * path yet — the controller pulls it and calls [setMediaLocalPath].
*/ */
private fun onMediaOffer(lane: String, frame: Frame) { private fun onMediaOffer(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<MediaOfferPayload>() ?: return val p = frame.payloadAs<MediaOfferPayload>() ?: return
updateLane(lane) { list -> updateLane(lane) { list ->
// Already attached (offer replayed)? Skip. // Already attached (offer replayed)? Skip.
if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list
val item = MediaItem( val item =
mediaId = p.mediaId, kind = p.kind, mime = p.mime, MediaItem(
size = p.size, filename = p.filename, mediaId = p.mediaId,
kind = p.kind,
mime = p.mime,
size = p.size,
filename = p.filename,
) )
val targetIdx = if (p.messageId != null) { val targetIdx =
if (p.messageId != null) {
list.indexOfFirst { it.id == p.messageId } list.indexOfFirst { it.id == p.messageId }
} else { } else {
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT } list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
@@ -436,43 +647,132 @@ class ChatStore {
} }
/** Record a pulled file's local path for [mediaId] (all lanes). */ /** Record a pulled file's local path for [mediaId] (all lanes). */
fun setMediaLocalPath(mediaId: String, localPath: String) { fun setMediaLocalPath(
val map = _lanes.value.toMutableMap() mediaId: String,
var changed = false localPath: String,
for ((lane, list) in map) { ) {
val updated = list.map { item -> synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is MessageItem) { if (item is MessageItem) {
item.copy(media = item.media.map { m -> item.copy(
media =
item.media.map { m ->
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
}) },
} else item )
} } else {
if (updated != list) { item
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
}
}
// ── picker.choice (interactive slash-command menu) ──────────────────────
/**
* Append a choice-picker card to [lane]. Idempotent by picker id: the
* frame is outboxed, so a sync replay of an already-rendered picker is a
* no-op (a locally resolved card is never re-opened by a replay).
*/
private fun onPickerChoice(
lane: String,
frame: Frame,
) {
val p = frame.payloadAs<PickerChoicePayload>() ?: return
updateLane(lane) { list ->
if (list.any { it.id == p.pickerId }) {
list
} else {
list +
PickerItem(
id = p.pickerId,
title = p.title,
choices = p.choices,
ts = nowMillis(),
)
}
}
}
/** Mark the picker [pickerId] as answered with [value] (all lanes; the
* picker id is unique). Optimistic: the server's reply message follows
* as a normal message; an expired picker (gateway restart) simply never
* replies. */
fun resolvePicker(
pickerId: String,
value: String,
) {
synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is PickerItem && item.id == pickerId && item.selected == null) {
item.copy(selected = value)
} else {
item
}
}
}
}
}
/** M8: a new message arrived in [lane] that the user hasn't seen —
* increment its unread count. */
fun markUnread(lane: String) {
synchronized(lock) {
val map = _unread.value.toMutableMap()
map[lane] = (map[lane] ?: 0) + 1
_unread.value = map
}
}
/** M8: the user is now viewing [lane]'s newest content — clear its unread
* count. Idempotent (a lane with no unread is a no-op). */
fun markLaneRead(lane: String) {
synchronized(lock) {
val map = _unread.value.toMutableMap()
if (map.remove(lane) != null) _unread.value = map
}
}
/** M8: unread count for a single lane (0 when none). */
fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0
/** M5: mark the user message [messageId] as read (read.receipt). */ /** M5: mark the user message [messageId] as read (read.receipt). */
fun markRead(messageId: String) { fun markRead(messageId: String) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) { list.map { item ->
val updated = list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER && if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read item.status != MsgStatus.Read
) { ) {
item.copy(pending = false, status = MsgStatus.Read) item.copy(pending = false, status = MsgStatus.Read)
} else item } else {
item
} }
if (updated != list) { }
map[lane] = updated }
changed = true }
}
/** M7: mark a single user message as failed (the send never reached the
* gateway — network drop, or the gateway rejected it); tap the bubble
* to retry. */
fun failMessage(messageId: String) {
synchronized(lock) {
mapLanes { list ->
list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Failed
) {
item.copy(pending = false, status = MsgStatus.Failed)
} else {
item
}
}
} }
} }
if (changed) _lanes.value = map
} }
/** /**
@@ -483,17 +783,10 @@ class ChatStore {
*/ */
fun removeMessages(messageIds: Set<String>) { fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list -> list.filterNot { it.id in messageIds } }
for ((lane, list) in map) {
val updated = list.filterNot { it.id in messageIds }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
}
/** /**
* Finalize in-flight items after the gateway goes away (restart / network * Finalize in-flight items after the gateway goes away (restart / network
@@ -503,48 +796,46 @@ class ChatStore {
* message.stop) will never arrive to close them. * message.stop) will never arrive to close them.
*/ */
fun finalizeInterrupted() { fun finalizeInterrupted() {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) { list.map { item ->
val updated = list.map { item ->
when (item) { when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
is PickerItem -> item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: mark all pending user messages as failed (gateway error frame). */ /** M7: mark all pending user messages as failed (gateway error frame). */
fun failPending() { fun failPending() {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) { list.map { item ->
val updated = list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) { if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed) item.copy(pending = false, status = MsgStatus.Failed)
} else item } else {
item
} }
if (updated != list) { }
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: re-arm a failed user message for a retry send. */ /** M7: re-arm a failed user message for a retry send. */
fun rearmForRetry(lane: String, messageId: String) { fun rearmForRetry(
lane: String,
messageId: String,
) {
updateLane(lane) { list -> updateLane(lane) { list ->
list.map { item -> list.map { item ->
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) { if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
item.copy(pending = true, status = MsgStatus.Pending) item.copy(pending = true, status = MsgStatus.Pending)
} else item } else {
item
}
} }
} }
} }
@@ -553,24 +844,96 @@ class ChatStore {
* Load a history page into [lane] (oldest → newest). The history is the * Load a history page into [lane] (oldest → newest). The history is the
* authoritative full list of final messages for the lane; it replaces the * authoritative full list of final messages for the lane; it replaces the
* lane's final messages and preserves non-final items (tool cards, live * lane's final messages and preserves non-final items (tool cards, live
* streaming bubbles, commentary) that are not part of the history. Used to * streaming bubbles, commentary) that are not part of the history. Tool
* restore the view on first open of a chat / after a process death, where * cards sort at their anchor message's position, so a history refresh
* the in-memory store is empty and the `sync` delta does not cover older * keeps them between the user message and the answer. Used to restore the
* view on first open of a chat / after a process death, where the
* in-memory store is empty and the `sync` delta does not cover older
* messages. * messages.
*/ */
fun loadHistory(lane: String, messages: List<MessageItem>) { fun loadHistory(
lane: String,
messages: List<MessageItem>,
) {
updateLane(lane) { list -> updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet() val historyIds = messages.map { it.id }.toSet()
val preserved = list.filter { item -> // A local FAILED bubble whose text+media matches a history user
item !is MessageItem || item.id !in historyIds // message was actually delivered (the POST response was lost in
// the network drop) — the history copy is authoritative, so drop
// the local duplicate instead of showing the message twice.
val historyUser = messages.filter { it.role == ROLE_USER }
val preserved =
list.filter { item ->
if (item !is MessageItem) return@filter true
if (item.id in historyIds) return@filter false
if (item.role == ROLE_USER && item.status == MsgStatus.Failed &&
historyUser.any {
it.text == item.text &&
it.media.map { m -> m.mediaId } == item.media.map { m -> m.mediaId }
}
) {
return@filter false
}
true
}
// ts of every item in the current lane: ts-less items (commentary,
// tool cards) inherit the ts of the item before them, so a tool
// card anchored to a commentary still sorts at the right place.
val tsOf = mutableMapOf<String, Long>()
var lastTs = 0L
for (item in list) {
val t = (item as? MessageItem)?.ts?.takeIf { it > 0 } ?: lastTs
if (t > 0) lastTs = t
tsOf[item.id] = t
}
// History is authoritative for the ts of its messages.
for (m in messages) {
if (m.ts > 0) tsOf[m.id] = m.ts
} }
(messages + preserved).sortedBy { item -> (messages + preserved).sortedBy { item ->
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE when (item) {
is MessageItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
// A resolved ts of 0 means the anchor itself is ts-less
// (lane start) — sort with it (end) instead of to the top.
is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE
is PickerItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
}
}
}
}
/**
* Restore lanes from the persistent local cache on startup (before the
* gateway connection is up), so the UI is populated instantly and chats
* are readable offline. The server remains authoritative: the `sync`
* delta and the `history` refresh reconcile the cache on connect.
* No-op when [lanes] is empty (first launch).
*/
fun loadFromCache(lanes: Map<String, List<ChatItem>>) {
if (lanes.isEmpty()) return
synchronized(lock) {
// The cache is ordered by (ts, id); pending/failed sends are
// persisted with ts = 0, so the DB returns them FIRST — but in
// memory addPending appends them to the END of the lane. Move the
// ts=0 message bubbles to the end (preserving their relative
// order); everything else keeps its stored order, so a restored
// lane looks like the live one until loadHistory re-sorts it
// after a connect.
_lanes.value =
lanes.mapValues { (_, items) ->
val (zeroTs, rest) = items.partition { (it as? MessageItem)?.ts == 0L }
rest + zeroTs
} }
} }
} }
fun clear() { fun clear() {
synchronized(lock) {
_lanes.value = emptyMap() _lanes.value = emptyMap()
_unread.value = emptyMap()
_todos.value = emptyMap()
}
} }
} }
@@ -2,16 +2,26 @@ package iris.data
/** /**
* Pairing settings storage. The token is a secret: platform actuals keep it * Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android — M5; plain * in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
* SharedPreferences for M1 dev, file on desktop). * encrypted file on desktop).
*/ */
interface SecureStore { interface SecureStore {
/** ws(s)://host:port/ws */ /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
var serverUrl: String var serverUrl: String
/** ANDROID_TOKEN presented in the hello frame. */ /** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */
var token: String var token: String
/** Per-device token minted at pairing (hello.ack ``device_token``,
* docs/09 §9.3). Presented INSTEAD of [token] when non-empty; the
* gateway can revoke it per device. Empty until the first hello.ack. */
var deviceToken: String
/** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS
* certificate, confirmed by the user on first pair (docs/09 §9.4).
* Empty = nothing pinned (CA-signed certs need no pin). */
var pinnedCertFingerprint: String
/** Stable app-generated device id (persisted). */ /** Stable app-generated device id (persisted). */
val deviceId: String val deviceId: String
@@ -30,6 +40,11 @@ interface SecureStore {
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */ /** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
var ntfyServer: String var ntfyServer: String
/** Push backend of the paired gateway ("fcm"/"ntfy"; from hello.ack
* server_caps; empty when unknown — decides whether the ntfy listener
* foreground service runs at all). */
var pushBackend: String
/** UI setting: show threads (topics) in the chat view. */ /** UI setting: show threads (topics) in the chat view. */
var threadsEnabled: Boolean var threadsEnabled: Boolean
@@ -39,6 +54,10 @@ interface SecureStore {
/** UI setting: stream assistant replies live (token by token). */ /** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean var streamingEnabled: Boolean
/** UI setting: streaming smoothness — seconds between visible text
* updates while streaming (0.2–0.8; lower = faster/smoother). */
var streamSmoothness: Float
/** UI setting: auto-collapse long reasoning blocks in the chat view. */ /** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean var reasoningAutoCollapse: Boolean
@@ -61,6 +80,19 @@ interface SecureStore {
* system font scale). */ * system font scale). */
var fontSizeScale: Float var fontSizeScale: Float
fun savePairing(url: String, token: String) /** UI setting: show the runtime-metadata footer under final assistant
* messages (model / context % / cwd / latency / cost). */
var runtimeFooterEnabled: Boolean
/** UI setting: which runtime-footer fields to show, as a comma-separated
* list in display order (e.g. "model,context_pct,cwd"). Empty = the
* default set. Valid keys: model, context_pct, cwd, latency, cost. */
var runtimeFooterFields: String
fun savePairing(
url: String,
token: String,
)
fun clear() fun clear()
} }
@@ -1,10 +0,0 @@
package iris.media
/** A readable local file (expect/actual; JVM impl in jvmMain). */
expect class FileSource(path: String) : AutoCloseable {
/** Total size in bytes. */
fun size(): Long
/** Read up to [buf.size] bytes into [buf]; returns bytes read or -1 at EOF. */
fun read(buf: ByteArray): Int
}
@@ -6,15 +6,17 @@ import iris.protocol.KIND_IMAGE
import iris.protocol.KIND_VIDEO import iris.protocol.KIND_VIDEO
/** Map a MIME type to a media kind (docs/07 §7.1). */ /** Map a MIME type to a media kind (docs/07 §7.1). */
fun kindFromMime(mime: String): String = when { fun kindFromMime(mime: String): String =
when {
mime.startsWith("image/") -> KIND_IMAGE mime.startsWith("image/") -> KIND_IMAGE
mime.startsWith("video/") -> KIND_VIDEO mime.startsWith("video/") -> KIND_VIDEO
mime.startsWith("audio/") -> KIND_AUDIO mime.startsWith("audio/") -> KIND_AUDIO
else -> KIND_DOCUMENT else -> KIND_DOCUMENT
} }
/** Best-effort file extension for a MIME type (cache file naming). */ /** Best-effort file extension for a MIME type (cache file naming). */
fun extForMime(mime: String): String = when { fun extForMime(mime: String): String =
when {
mime == "image/jpeg" -> ".jpg" mime == "image/jpeg" -> ".jpg"
mime == "image/png" -> ".png" mime == "image/png" -> ".png"
mime == "image/webp" -> ".webp" mime == "image/webp" -> ".webp"
@@ -34,4 +36,13 @@ fun extForMime(mime: String): String = when {
mime == "application/zip" -> ".zip" mime == "application/zip" -> ".zip"
mime == "text/plain" -> ".txt" mime == "text/plain" -> ".txt"
else -> ".bin" else -> ".bin"
} }
/**
* Validate a server-provided media id before it is used in a file path or a
* `GET /v1/media/{id}` URL (M-2 / S-1). The id comes from the gateway's
* `media.offer` frame; a value like `../../x` would write outside the media
* directory and break the pull URL. Only a conservative token charset is
* accepted.
*/
fun isValidMediaId(id: String): Boolean = id.length in 1..128 && id.all { it.isLetterOrDigit() || it == '_' || it == '-' }
@@ -1,66 +1,48 @@
package iris.net package iris.net
import iris.data.SecureStore import iris.data.SecureStore
import iris.media.FileSource
import iris.media.Sha256
import iris.protocol.ChannelInfo import iris.protocol.ChannelInfo
import iris.protocol.ErrorPayload
import iris.protocol.Frame import iris.protocol.Frame
import iris.protocol.HelloAckPayload import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaPullEndPayload
import iris.protocol.MediaUploadAckPayload
import iris.protocol.ServerCaps import iris.protocol.ServerCaps
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HELLO_ACK
import iris.protocol.TYPE_MEDIA_PULL_END
import iris.protocol.TYPE_MEDIA_UPLOAD_ACK
import iris.protocol.TYPE_PONG
import iris.protocol.helloFrame
import iris.protocol.mediaPullFrame
import iris.protocol.mediaUploadEndFrame
import iris.protocol.mediaUploadStartFrame
import iris.protocol.messageSendFrame import iris.protocol.messageSendFrame
import iris.protocol.pingFrame
import iris.protocol.syncFrame import iris.protocol.syncFrame
import iris.util.IrisLog
import iris.util.nowMillis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okio.ByteString
import okio.ByteString.Companion.toByteString
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
/** /**
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3). * HTTP client for the hermes iris gateway (docs/19).
* *
* - connect + hello (real auth leg), hello.ack * HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
*
* - connect: health probe + SSE hello (the HTTP hello.ack)
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect * - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - heartbeat: app-level ping every 20s; reap after ~60s of silence * - events: server frames on [events]
* - events: server frames (minus hello.ack) on [events] * - correlation: the POST body carries the synchronous reply (e.g. read
* - request/response correlation by id (M2+ consumers) * receipt, errors); everything else arrives on the event stream, and the
* app reconciles by frame id (docs/19 §19.7)
*/ */
class GatewayClient( class GatewayClient(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -68,10 +50,30 @@ class GatewayClient(
) { ) {
sealed interface State { sealed interface State {
data object Disconnected : State data object Disconnected : State
data object Connecting : State data object Connecting : State
data class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
data class Connected(
val caps: ServerCaps,
val channels: List<ChannelInfo>,
/** M5: highest outbox cursor already pushed to this device
* (from hello.ack; 0 = never). Sync-replayed frames at/below
* it must not re-post system notifications (docs/08 §8.7). */
val lastPushedCursor: Long = 0,
) : State
data object Reconnecting : State data object Reconnecting : State
data class AuthFailed(val message: String) : State
data class AuthFailed(
val message: String,
) : State
/** The gateway's TLS certificate is untrusted and doesn't match the
* pinned fingerprint (docs/09 §9.4). Terminal: the connect loop
* stops and the UI asks the user to confirm the fingerprint. */
data class TlsConfirmRequired(
val fingerprint: String,
) : State
} }
private val _state = MutableStateFlow<State>(State.Disconnected) private val _state = MutableStateFlow<State>(State.Disconnected)
@@ -84,36 +86,79 @@ class GatewayClient(
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128) private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
val events: SharedFlow<Frame> = _events.asSharedFlow() val events: SharedFlow<Frame> = _events.asSharedFlow()
private val client: OkHttpClient = OkHttpClient.Builder() // TLS: the pinning trust manager wraps the platform default (CA-signed
// certs behave as before); a self-signed gateway cert is accepted only
// after the user confirms its fingerprint (docs/09 §9.4). The pin is
// read live from the store so a fresh confirm takes effect without
// rebuilding the client.
private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint }
private val client: OkHttpClient =
OkHttpClient
.Builder()
.pingInterval(20, TimeUnit.SECONDS) .pingInterval(20, TimeUnit.SECONDS)
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
.build() .build()
private var connectJob: Job? = null private var connectJob: Job? = null
private var socket: WebSocket? = null
private var nextRequestId = 1 private var nextRequestId = 1
// Incremented from the SSE callback thread (onHttpHello) and the UI
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
// request/response correlation breaks.
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
// Written by poke() (UI thread) and the connect loop (Default).
@Volatile
private var attempt = 0 private var attempt = 0
// Set by poke() (app returned to the foreground): the connect loop's
// backoff waits in 500 ms slices and re-probes immediately when set.
@Volatile
private var wakeRequested = false
// True once a connection has been established this session; reset by // True once a connection has been established this session; reset by
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a // start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen. // drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// M4: binary frames (media upload chunks / pull stream) have no per-frame // HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
// id, so at most one binary session is active per socket. The gateway // the resume cursor (SSE id / outbox high-water mark), updated from the
// allows one upload per connection; pull is request/response. // SSE/poll callback threads.
private sealed interface BinarySession { private var http: HttpGateway? = null
data class Pulling(
val requestId: Int,
val chunks: Channel<ByteArray>,
val end: CompletableDeferred<Frame>,
) : BinarySession
}
private var binarySession: BinarySession? = null @Volatile
private var httpCursor: Long = 0
private var sseFailures = 0
private var usingLongPoll = false
// M4: only one pull may be in flight at a time (binarySession is a single // Last hello.ack payload — used to restore State.Connected after a
// slot). Serialize concurrent offers so their byte streams don't interleave. // reconnect state race in the connect loop. Written by the SSE callback
// thread, read by the long-poll loop.
@Volatile
private var lastAck: HelloAckPayload? = null
// Epoch ms of the last frame delivered by the receive stream (SSE or
// long-poll). The watchdog uses this to detect a STALE stream: a live
// connection (heartbeats / poll answers keep it open, so the read timeout
// never fires) that has stopped delivering frames — the state a gateway
// restart can leave the app in, where sent messages sit at "sending…"
// because their echo is never delivered (issue #6). Set when the receive
// loop (re)starts so the first window isn't treated as stale.
@Volatile
private var lastFrameMs: Long = 0L
/**
* Fired promptly the moment the SSE hello (hello.ack) is received — on
* every (re)connect. Used for time-critical work that must not wait for
* the state collector, which can be starved for seconds during app
* startup (Dispatchers.Default) and would push a history request past a
* flaky network's window. Set before [start].
*/
var onHelloAck: ((State.Connected) -> Unit)? = null
// Only one pull may be in flight at a time. Serialize concurrent offers so
// their byte streams don't interleave.
private val pullMutex = Mutex() private val pullMutex = Mutex()
// ── Lifecycle ───────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -126,13 +171,34 @@ class GatewayClient(
connectJob = scope.launch { connectLoop() } connectJob = scope.launch { connectLoop() }
} }
/** Stop the connect loop and close the socket. */ /**
* Stop the connect loop and reset the HTTP leg. Without the reset, a
* re-pair to a *different* gateway would keep using the cached
* [HttpGateway] (old URL, old token, old resume cursor) until the process
* is killed.
*/
fun stop() { fun stop() {
connectJob?.cancel() connectJob?.cancel()
connectJob = null connectJob = null
socket?.close(1000, "client shutdown")
socket = null
_state.value = State.Disconnected _state.value = State.Disconnected
http?.close()
http = null
httpCursor = 0
sseFailures = 0
usingLongPoll = false
lastAck = null
}
/**
* Call when the app returns to the foreground: if the connect loop is
* between attempts (backing off after failed health probes — up to 30 s),
* wake it so it re-probes immediately instead of making the user wait out
* the backoff with a "Connecting…" banner. No-op while connected.
*/
fun poke() {
if (_state.value is State.Connected) return
attempt = 0
wakeRequested = true
} }
/** Re-pair: stop, then start fresh (used after saving new settings). */ /** Re-pair: stop, then start fresh (used after saving new settings). */
@@ -144,178 +210,294 @@ class GatewayClient(
private suspend fun connectLoop() { private suspend fun connectLoop() {
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
val url = store.serverUrl.trim() val url = store.serverUrl.trim()
val token = store.token // Per-device token when the gateway minted one (docs/09 §9.3),
// else the shared IRIS_TOKEN (bootstrap).
val token = store.deviceToken.ifBlank { store.token }
if (url.isBlank() || token.isBlank()) { if (url.isBlank() || token.isBlank()) {
_state.value = State.Disconnected _state.value = State.Disconnected
return return
} }
_state.value = if (hasConnected) State.Reconnecting else State.Connecting _state.value = if (hasConnected) State.Reconnecting else State.Connecting
val dial = dial(url, token) val gw = httpGateway() ?: continue
when (val result = dial.result) { // Health probe: if the gateway is alive, open the SSE receive loop
is DialResult.AuthFailed -> { // (which delivers the hello.ack). Otherwise back off and retry.
_state.value = State.AuthFailed(result.message) val healthOk =
dial.socket.close(1000, "auth failed") try {
return gw.health()
} catch (e: Exception) {
if (failTls(e)) return
false
} }
DialResult.Connected -> { if (!healthOk) {
hasConnected = true
attempt = 0
lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await()
if (!currentCoroutineContext().isActive) return
// socket dropped -> loop again (Reconnecting)
}
is DialResult.Failed -> {
attempt++ attempt++
delay(backoffMs(attempt)) backoffOrWake(backoffMs(attempt))
continue
} }
} attempt = 0
} hasConnected = true
} sseFailures = 0
usingLongPoll = false
// ── Dial (one connect + hello) ──────────────────────────────────────── httpCursor = store.syncCursor
lastFrameMs = nowMs()
private sealed interface DialResult { // Provisional Connected state (previous caps/channels) until the
data object Connected : DialResult // SSE hello arrives with the real ones.
data class AuthFailed(val message: String) : DialResult val prev = _state.value
data class Failed(val message: String) : DialResult _state.value =
} State.Connected(
caps = (prev as? State.Connected)?.caps ?: ServerCaps(),
private data class Dial( channels = (prev as? State.Connected)?.channels ?: emptyList(),
val result: DialResult, lastPushedCursor = (prev as? State.Connected)?.lastPushedCursor ?: 0,
val socket: WebSocket,
val closed: CompletableDeferred<Unit>,
) )
// Run the HTTP receive loop (SSE/long-poll) until cancelled.
coroutineScope {
val receiveJob = launch { httpReceiveLoop(gw) }
// Dead-stream watchdog: the SSE read timeout (45 s) is the
// only in-stream dead-connection detector; probe /v1/health
// in parallel so a dropped network flips the state to
// Reconnecting within ~20 s (2 failed probes) instead of 45,
// and a stuck stream can't keep a stale "Connected".
var probeFailures = 0
while (receiveJob.isActive) {
delay(10_000)
val ok =
try {
gw.health()
} catch (e: Exception) {
if (failTls(e)) {
receiveJob.cancel()
break
}
false
}
probeFailures = if (ok) 0 else probeFailures + 1
if (probeFailures >= 2) {
receiveJob.cancel()
break
}
// Stale-stream watchdog: the gateway is up (health ok) but
// the receive stream has delivered no frames for a while —
// a live-but-dead connection the read timeout can't see.
// Force a fresh (re)connect so parked frames (e.g. our own
// echo) are re-delivered from the outbox.
if (lastFrameMs > 0L && nowMs() - lastFrameMs > STALE_STREAM_TIMEOUT_MS) {
IrisLog.w("receive stream stale (no frames for ${STALE_STREAM_TIMEOUT_MS}ms); forcing reconnect")
receiveJob.cancel()
break
}
}
receiveJob.join()
}
// Terminal failures: don't redial with the same bad token / the
// same untrusted certificate (the UI asks the user to act).
if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return
}
}
private suspend fun dial(url: String, token: String): Dial { // ── HTTP receive leg ──────────────────────────────────────────────────
val closed = CompletableDeferred<Unit>()
val helloAck = CompletableDeferred<HelloAckPayload>()
val authError = CompletableDeferred<String>()
val fail = CompletableDeferred<String>()
val request = Request.Builder().url(url).build() /** Lazily build the HTTP client from the stored URL. Synchronized: two
val ws = client.newWebSocket( * threads racing the check-then-create would leak a gateway (and its
request, * OkHttp clients). */
object : WebSocketListener() { private fun httpGateway(): HttpGateway? =
override fun onOpen(webSocket: WebSocket, response: Response) { synchronized(this) {
webSocket.send( val url = store.serverUrl.trim()
helloFrame( val token = store.deviceToken.ifBlank { store.token }
token = token, if (url.isBlank() || token.isBlank()) return@synchronized null
deviceId = store.deviceId, http
?: HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
// Live provider: a device token minted by the next
// hello.ack is picked up without rebuilding the client.
token = { store.deviceToken.ifBlank { store.token } },
store.deviceId,
deviceName = store.deviceName, deviceName = store.deviceName,
fcmToken = store.fcmToken.ifBlank { null }, fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = store.ntfyTopic.ifBlank { null }, ntfyTopic = { store.ntfyTopic.ifBlank { null } },
).toWire(), ).also { http = it }
)
} }
override fun onMessage(webSocket: WebSocket, text: String) { /**
lastLiveness = TimeSource.Monotonic.markNow() * The HTTP receive loop: SSE by default; after two consecutive SSE open
val frame = try { * failures (buffering proxy) it switches to long-poll until the next full
IrisJson.instance.decodeFromString(Frame.serializer(), text) * (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
} catch (_: Exception) { */
private suspend fun httpReceiveLoop(gw: HttpGateway) {
var backoff = 1_000L
while (currentCoroutineContext().isActive) {
if (usingLongPoll) {
try {
val res = gw.poll(httpCursor)
res.frames.forEach { emitHttpFrame(it) }
if (res.cursor > httpCursor) httpCursor = res.cursor
// The poll answered: the link is back (long-poll has no
// hello — restore the Connected state from the last one).
restoreConnected()
} catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return return
} catch (e: Exception) {
if (failTls(e)) return
markStreamLost()
IrisLog.w("http poll failed: ${e.message}")
delay(backoff)
backoff = minOf(backoff * 2, 15_000)
} }
when (frame.type) { } else {
TYPE_HELLO_ACK -> { try {
val ack = frame.payloadAs<HelloAckPayload>() gw.events(
if (ack != null) helloAck.complete(ack) cursor = httpCursor,
} onHello = { onHttpHello(it) },
TYPE_ERROR -> { onFrame = { emitHttpFrame(it) },
val err = frame.payloadAs<ErrorPayload>() onCursor = { if (it > httpCursor) httpCursor = it },
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed") // A keep-alive comment proves the stream is alive —
// M7: post-connect error frames are app events, not // count it toward liveness so an idle-but-healthy
// auth failures — let the controller react. // stream isn't force-reconnected by the stale
_events.tryEmit(frame) // watchdog every 3 minutes.
} onKeepAlive = { lastFrameMs = nowMs() },
TYPE_PONG -> Unit
else -> {
_events.tryEmit(frame)
frame.id?.let { id ->
pending[id]?.complete(frame)
// M4: terminal frame of a pull stream — close
// the chunk channel so the pull loop exits.
if (frame.type == TYPE_MEDIA_PULL_END) {
(binarySession as? BinarySession.Pulling)?.let {
it.chunks.close()
binarySession = null
}
}
}
}
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
lastLiveness = TimeSource.Monotonic.markNow()
// M4: binary frames belong to the active pull stream
// (uploads are outbound; stray inbound chunks are dropped).
(binarySession as? BinarySession.Pulling)
?.chunks
?.trySend(bytes.toByteArray())
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
closed.complete(Unit)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
fail.complete(t.message ?: "connection failed")
closed.complete(Unit)
}
},
) )
socket = ws // Clean EOF: back off briefly so a server that keeps
// closing cleanly can't tight-loop the reconnect.
backoff = 1_000L
delay(backoff)
} catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return
} catch (e: Exception) {
if (failTls(e)) return
sseFailures++
markStreamLost()
if (sseFailures >= 2) {
// SSE seems blocked: switch to long-poll.
usingLongPoll = true
continue
}
IrisLog.w("sse read failed: ${e.message}")
delay(backoff)
backoff = minOf(backoff * 2, 15_000)
}
}
}
}
val winner = CompletableDeferred<DialResult>() /** The SSE `event: hello` (the HTTP hello.ack). */
helloAck.invokeOnCompletion { e -> private fun onHttpHello(ack: HelloAckPayload) {
if (e == null) { lastAck = ack
val ack = helloAck.getCompleted() // Per-device token (docs/09 §9.3): minted at pairing, stable across
_state.value = State.Connected(ack.serverCaps, ack.channels) // (re)connects. Store it — from the next request on the app presents
// it instead of the shared IRIS_TOKEN, so the gateway can revoke
// THIS device without touching the others.
if (ack.deviceToken.isNotBlank() && ack.deviceToken != store.deviceToken) {
store.deviceToken = ack.deviceToken
}
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
_state.value = connected
// M5: reconnect catch-up — replay frames parked while offline. // M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor val local = store.syncCursor
if (local < ack.syncCursor) { if (local < ack.syncCursor) {
val id = nextRequestId++ val id = nextId()
ws.send(syncFrame(id, local).toWire()) scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
} }
winner.complete(DialResult.Connected) // Prompt fast path (before the possibly-starved state collector).
onHelloAck?.invoke(connected)
}
/** The receive stream just died: don't keep claiming "Connected" while
* between attempts (a stale green dot through a Wi-Fi drop). */
private fun markStreamLost() {
if (_state.value is State.Connected) _state.value = State.Reconnecting
}
/** The receive stream is open again (long-poll answered): the link is
* back. Long-poll has no hello, so restore the Connected state from the
* last hello.ack (the SSE path gets a fresh one). Fire [onHelloAck] too:
* the long-poll path has no `event: hello`, so without this the app's
* "on connected" side effects (resend queued offline sends, load the
* active lane's history) never run when a gateway restart lands while
* the app is on the long-poll fallback — queued messages would sit at
* "sending…" forever until an app restart (issue #6). */
private fun restoreConnected() {
if (_state.value !is State.Reconnecting) return
val connected =
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
?: State.Connected(ServerCaps(), emptyList())
_state.value = connected
onHelloAck?.invoke(connected)
}
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */
private fun emitHttpFrame(frame: Frame) {
lastFrameMs = nowMs()
if (!_events.tryEmit(frame)) {
// The collector is starved beyond the 128-frame buffer: the frame
// is dropped. Log it — a silent drop here loses user-visible
// content (echoes, deltas, notifications).
IrisLog.e("events buffer full — dropped frame ${frame.type} (id=${frame.id})")
} }
} }
authError.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted())) /** Deliver the POST body's synchronous reply (or null) and handle a 401.
* Shared by [sendMessage] and [sendFrame]. */
private fun handlePostResult(res: HttpGateway.PostResult?) {
res?.frame?.let { emitHttpFrame(it) }
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
} }
fail.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
} }
val result = withTimeoutOrNull(15_000) { winner.await() }
?: DialResult.Failed("timeout waiting for hello.ack") /** Terminal TLS failure: the presented certificate is untrusted and not
return Dial(result, ws, closed) * the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect
* loop and let the UI ask the user to confirm the fingerprint. True when
* the state was set. */
private fun failTls(e: Exception): Boolean {
val tls = tlsFingerprintRequired(e) ?: return false
IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})")
_state.value = State.TlsConfirmRequired(tls.fingerprint)
return true
} }
// ── Outbound ────────────────────────────────────────────────────────── // ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back). /** Send a text message (fire-and-forget; the server echoes it back).
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs). * [mediaRefs] reference completed uploads (POST /v1/media refs).
* [autoThread] asks the gateway to mint a fresh thread for the message * [autoThread] asks the gateway to mint a fresh thread for the message
* (auto-threading, docs/06 §6.3). */ * (auto-threading, docs/06 §6.3).
* [onResult] is called with the POST's HTTP status — 0 means "no
* response" (not connected, or the network failed); 2xx means the
* gateway accepted it; 4xx is a gateway rejection (error frame already
* delivered via [events]). Used to fail the optimistic bubble instead
* of leaving it at "sending…" forever. */
fun sendMessage( fun sendMessage(
chatId: String, chatId: String,
text: String, text: String,
threadId: String? = null, threadId: String? = null,
mediaRefs: List<String> = emptyList(), mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false, autoThread: Boolean = false,
onResult: ((Int) -> Unit)? = null,
) { ) {
val ws = socket ?: return if (_state.value !is State.Connected) {
val id = nextRequestId++ onResult?.invoke(0)
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire()) return
}
val id = nextId()
scope.launch {
val res =
httpGateway()?.postFrame(
messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread),
)
// The synchronous reply (e.g. the read receipt, or an error frame
// on 4xx) comes back in the POST body, not on the event stream —
// deliver it or it is lost (docs/19 §19.7).
handlePostResult(res)
onResult?.invoke(res?.status ?: 0)
}
} }
// ── M4: media upload / pull ─────────────────────────────────────────── // ── Media upload / pull ───────────────────────────────────────────────
/** /**
* Upload a local file as media (docs/07 §7.2): media.upload.start, * Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's * Returns the server's media_ref (for message.send media_refs) on success.
* media_ref (for message.send media_refs) on success.
*/ */
suspend fun uploadMedia( suspend fun uploadMedia(
path: String, path: String,
@@ -324,163 +506,140 @@ class GatewayClient(
filename: String, filename: String,
mediaRef: String, mediaRef: String,
): Result<String> { ): Result<String> {
val ws = socket ?: return Result.failure(IllegalStateException("not connected")) val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
val source = FileSource(path) return http.uploadMedia(path, mime, kind, filename, mediaRef)
val size = source.size()
if (size <= 0) {
source.close()
return Result.failure(IllegalStateException("empty file"))
}
val id = nextRequestId++
val reply = CompletableDeferred<Frame>()
pending[id] = reply
try {
ws.send(mediaUploadStartFrame(id, mediaRef, kind, mime, filename, size).toWire())
val sha = Sha256()
source.use {
val buf = ByteArray(UPLOAD_CHUNK_BYTES)
while (true) {
val n = it.read(buf)
if (n < 0) break
if (n == 0) continue
sha.update(buf, 0, n)
ws.send(buf.copyOfRange(0, n).toByteString())
}
}
ws.send(mediaUploadEndFrame(id, mediaRef, sha.hex()).toWire())
val frame = withTimeout(UPLOAD_TIMEOUT_MS) { reply.await() }
return when (frame.type) {
TYPE_MEDIA_UPLOAD_ACK -> {
val p = frame.payloadAs<MediaUploadAckPayload>()
if (p != null && p.ok) Result.success(p.mediaRef)
else Result.failure(IllegalStateException("upload rejected by server"))
}
TYPE_ERROR -> {
val e = frame.payloadAs<ErrorPayload>()
Result.failure(IllegalStateException(e?.message ?: "upload failed"))
}
else -> Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
}
} catch (e: Exception) {
return Result.failure(e)
} finally {
pending.remove(id)
}
} }
/** /**
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until * Pull offered media via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
* media.pull.end. Each chunk is handed to [onChunk] (write to cache). * chunk is handed to [onChunk] (write to cache).
*/ */
suspend fun pullMedia(mediaId: String, onChunk: suspend (ByteArray) -> Unit): Result<Unit> = suspend fun pullMedia(
mediaId: String,
onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> =
pullMutex.withLock { pullMutex.withLock {
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected")) val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
val id = nextRequestId++ http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
val chunks = Channel<ByteArray>(Channel.UNLIMITED)
val end = CompletableDeferred<Frame>()
pending[id] = end
binarySession = BinarySession.Pulling(id, chunks, end)
try {
ws.send(mediaPullFrame(id, mediaId).toWire())
val frame = withTimeout(PULL_TIMEOUT_MS) {
for (chunk in chunks) onChunk(chunk)
end.await()
}
when (frame.type) {
TYPE_MEDIA_PULL_END -> {
val p = frame.payloadAs<MediaPullEndPayload>()
if (p != null && p.ok) Result.success(Unit)
else Result.failure(IllegalStateException("pull failed"))
}
TYPE_ERROR -> {
val e = frame.payloadAs<ErrorPayload>()
Result.failure(IllegalStateException(e?.message ?: "pull failed"))
}
else -> Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
}
} catch (e: Exception) {
Result.failure(e)
} finally {
pending.remove(id)
chunks.cancel()
val s = binarySession
if (s is BinarySession.Pulling && s.requestId == id) binarySession = null
}
} }
companion object { companion object {
/** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */ /** No frames (or keep-alive comments) from the receive stream for
const val UPLOAD_CHUNK_BYTES = 256 * 1024 * this long (gateway still
const val UPLOAD_TIMEOUT_MS = 120_000L * healthy) = stale stream: force a fresh (re)connect. 3 min keeps an
const val PULL_TIMEOUT_MS = 300_000L * idle app from churning while still recovering a stuck stream well
* before a user notices (issue #6). */
const val STALE_STREAM_TIMEOUT_MS = 180_000L
} }
/** /**
* Send an arbitrary frame with a fresh request id (fire-and-forget). * Send an arbitrary frame with a fresh request id (fire-and-forget). The
* The server replies (or broadcasts) a frame carrying the same id; the * server replies (or broadcasts) a frame carrying the same id; the app
* app reconciles from [events]. Returns the id used, or -1 if not connected. * reconciles from [events]. Returns the id used, or -1 if not connected.
*/ */
fun sendFrame(frame: Frame): Int { fun sendFrame(frame: Frame): Int {
val ws = socket ?: return -1 if (_state.value !is State.Connected) return -1
val id = nextRequestId++ val id = nextId()
ws.send(frame.copy(id = id).toWire()) scope.launch {
val res = httpGateway()?.postFrame(frame.copy(id = id))
// Single-frame responses (commands.catalog, channel.list, search,
// history, sync, errors) come back in the POST body, not on the
// event stream — deliver it or it is lost (docs/19 §19.7).
handlePostResult(res)
}
return id return id
} }
/** Send a ping (heartbeat). */
fun ping() {
socket?.send(pingFrame().toWire())
}
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
fun isStale(timeoutMs: Long = 60_000): Boolean =
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
fun reapStale() {
if (isStale()) {
socket?.close(1000, "heartbeat timeout")
}
}
// ── One-shot hello test (Connect screen) ────────────────────────────── // ── One-shot hello test (Connect screen) ──────────────────────────────
/** /**
* Real `hello` test: dial, wait for hello.ack (or auth error), close. * Real connection test: health probe + SSE open. The auth leg is proven
* Exercises the auth leg, not just TCP (docs/10 §10.8). * by the stream being accepted (200 vs 401) — we do NOT wait for the
* hello event, because the server replays the outbox (up to 72 h of
* frames) before it and a large outbox would time out a healthy gateway
* (docs/10 §10.8).
*/ */
suspend fun testHello(url: String, token: String): Result<Unit> { suspend fun testHello(
val dial = dial(url, token) url: String,
return when (val result = dial.result) { token: String,
DialResult.Connected -> { ): Result<Unit> {
dial.socket.close(1000, "test complete") val gw =
HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
// An already-paired device presents its per-device token
// (docs/09 §9.3); a fresh pairing falls back to the entered
// shared token (bootstrap).
token = { store.deviceToken.ifBlank { token } },
store.deviceId,
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
)
return try {
if (!gw.health()) {
Result.failure(IllegalStateException("gateway unreachable"))
} else {
// Open the SSE stream briefly: 200 = auth leg proven, 401 =
// bad token. Don't wait for the hello (outbox replay first).
val opened = CompletableDeferred<Unit>()
val job =
scope.launch {
try {
gw.events(
cursor = store.syncCursor,
onOpen = { opened.complete(Unit) },
onHello = { },
onFrame = { },
onCursor = { },
)
} catch (e: Exception) {
opened.completeExceptionally(e)
}
}
try {
if (withTimeoutOrNull(10_000) { opened.await() } == null) {
Result.failure(IllegalStateException("timeout opening the event stream"))
} else {
Result.success(Unit) Result.success(Unit)
} }
is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message)) } catch (e: HttpGateway.HttpAuthException) {
is DialResult.Failed -> Result.failure(IllegalStateException(result.message)) Result.failure(IllegalStateException("unauthorized — check the pairing token"))
} } catch (e: CancellationException) {
} throw e
} catch (e: Exception) {
// ── Heartbeat job ───────────────────────────────────────────────────── // Unwrap the pinning manager's signal so the Connect
// screen can offer the fingerprint confirm dialog.
fun startHeartbeat() { Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}"))
scope.launch { } finally {
while (isActive) { job.cancel()
delay(20_000)
if (_state.value is State.Connected) {
ping()
reapStale()
} }
} }
} catch (e: Exception) {
Result.failure(tlsFingerprintRequired(e) ?: e)
} }
} }
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
/** Backoff that wakes early when [poke] is called (app foregrounded). */
private suspend fun backoffOrWake(ms: Long) {
var remaining = ms
while (remaining > 0 && currentCoroutineContext().isActive) {
delay(minOf(remaining, 500L))
if (wakeRequested) {
wakeRequested = false
return
}
remaining -= 500L
}
}
private fun backoffMs(attempt: Int): Long { private fun backoffMs(attempt: Int): Long {
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
val capped = minOf(base, 30_000L) val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500) return capped + Random.nextLong(0, 500)
} }
}
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this) private fun nowMs(): Long = nowMillis()
}
@@ -0,0 +1,507 @@
package iris.net
import iris.AppVersion
import iris.media.Sha256
import iris.media.isValidMediaId
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaUploadAckPayload
import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* HTTP transport client (docs/19) — the only transport.
*
* The app sends over `POST /v1/frame` and receives over SSE
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
* media travels via `POST/GET /v1/media`.
*
* [events] is ONE SSE connection attempt (blocking read on
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
* outbox cursor, so resume is just the last seen id).
*/
class HttpGateway(
private val client: OkHttpClient,
private val baseUrl: String,
/** Live auth-token provider, read per request: the per-device token
* (docs/09 §9.3) when the gateway minted one, else the shared
* IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is
* picked up without rebuilding the client. */
private val token: () -> String,
private val deviceId: String,
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
* upserts it into the device registry on every SSE open — the HTTP
* equivalent of the old WS hello upsert). */
private val deviceName: String? = null,
/** Live push-token providers, read per request so a rotated FCM token or
* a fresh ntfy topic is picked up without rebuilding the client. */
private val fcmToken: () -> String? = { null },
private val ntfyTopic: () -> String? = { null },
) {
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
* with the same token can't succeed. */
class HttpAuthException : IOException("unauthorized (HTTP 401)")
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
// with extended call timeouts (see the *Client() helpers below).
private val healthClient: OkHttpClient = client.healthClient()
private val streamClient: OkHttpClient = client.streamClient()
private val pollClient: OkHttpClient = client.pollClient()
private val mediaClient: OkHttpClient = client.mediaClient()
/** Close the per-purpose clients (and their idle connections). The
* shared base [client] is owned by the caller. */
fun close() {
healthClient.dispatcher.executorService.shutdown()
streamClient.dispatcher.executorService.shutdown()
pollClient.dispatcher.executorService.shutdown()
mediaClient.dispatcher.executorService.shutdown()
healthClient.connectionPool.evictAll()
streamClient.connectionPool.evictAll()
pollClient.connectionPool.evictAll()
mediaClient.connectionPool.evictAll()
}
/** POST /v1/frame result. [frame] is the handler's synchronous reply
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
* 202 accept-and-ack. */
data class PostResult(
val ok: Boolean,
val status: Int,
val frame: Frame?,
)
/** Long-poll result: new high-water [cursor] + frames with cursor >
* the requested one (may be empty at timeout). */
data class PollResult(
val cursor: Long,
val frames: List<Frame>,
)
companion object {
val JSON = "application/json".toMediaType()
/** Default port of the gateway's HTTP leg (WS default is 8790). */
const val DEFAULT_PORT = 8791
/** Media transfer chunk (docs/07 §7.5). */
private const val MEDIA_CHUNK_BYTES = 256 * 1024
/**
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
* a different service, so the port is always replaced with the
* HTTP leg's default. Pure function (unit-tested).
*/
fun deriveHttpUrl(wsUrl: String): String {
val u = wsUrl.trim()
val (scheme, rest) =
when {
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
else -> return u // already http(s)
}
val authority = rest.substringBefore('/')
val host = authority.substringBefore(':')
return "$scheme://$host:$DEFAULT_PORT"
}
}
private fun authHeaders(): Headers {
val b =
Headers
.Builder()
.add("Authorization", "Bearer ${token()}")
.add("X-Iris-Device", deviceId)
// Device registration (docs/19): the gateway upserts name + push
// tokens from these headers on every SSE open (COALESCE — absent
// headers never clobber a newer fcm.register value).
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
// Release version (repo-root VERSION baked in at build time); the
// gateway stores it in the device registry (docs/04 hello.ack note).
b.add("X-Iris-App-Version", AppVersion.VERSION)
return b.build()
}
/** Parse a response body as a protocol frame (null when not a frame,
* e.g. the plain `{"ok":true}` ack). */
private fun parseFrame(body: String): Frame? =
try {
if (body.startsWith("{")) {
val obj = IrisJson.instance.parseToJsonElement(body)
if (obj.jsonObject.containsKey("type")) {
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
} else {
null
}
} else {
null
}
} catch (e: Exception) {
null
}
/** Liveness probe (unauthenticated by design). True on 200. */
suspend fun health(): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/health")
.build()
healthClient
.newCall(request)
.execute()
.use { response ->
response.body?.close()
response.code == 200
}
}
/**
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
* (with the synchronous reply frame when the handler sent one); 4xx ->
* the error frame as the body. Network failures (timeout, reset, DNS —
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
* callers are fire-and-forget coroutines — an uncaught exception here
* kills the app process.
*/
suspend fun postFrame(frame: Frame): PostResult =
withContext(Dispatchers.IO) {
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
val request =
Request
.Builder()
.url("$baseUrl/v1/frame")
.headers(authHeaders())
.post(wire.toRequestBody(JSON))
.build()
try {
client
.newCall(request)
.execute()
.use { response ->
val body = response.body?.string().orEmpty()
val parsed = parseFrame(body)
PostResult(response.isSuccessful, response.code, parsed)
}
} catch (e: Exception) {
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
PostResult(ok = false, status = 0, frame = null)
}
}
/**
* One SSE connection attempt: outbox catch-up from [cursor], then live
* frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
* leg is proven; the server may still replay a large outbox before the
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
* cursor) when present; [onKeepAlive] for SSE comment lines (the
* heartbeat) so callers can count keep-alives toward liveness. Returns
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
* on the IO thread.
*/
suspend fun events(
cursor: Long,
onOpen: (() -> Unit)? = null,
onHello: (HelloAckPayload) -> Unit,
onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit,
onKeepAlive: (() -> Unit)? = null,
) {
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/events?cursor=$cursor")
.headers(authHeaders())
.build()
streamClient
.newCall(request)
.execute()
.use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) {
throw IOException("SSE open failed: HTTP ${response.code}")
}
onOpen?.invoke()
val source = response.body?.source() ?: throw IOException("empty SSE body")
var eventId: String? = null
val dataLines = mutableListOf<String>()
while (true) {
val line = source.readUtf8Line() ?: break // EOF
when {
line.isEmpty() -> {
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n")
try {
val frame =
IrisJson.instance.decodeFromString(
Frame.serializer(),
data,
)
when (eventId) {
"hello" -> {
onHello(
frame.payloadAs<HelloAckPayload>()
?: HelloAckPayload(),
)
}
else -> {
onFrame(frame)
}
}
} catch (e: Exception) {
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
}
}
eventId = null
dataLines.clear()
}
line.startsWith(":") -> {
// heartbeat comment
onKeepAlive?.invoke()
}
line.startsWith("id:") -> {
line
.removePrefix("id:")
.trim()
.toLongOrNull()
?.let(onCursor)
}
line.startsWith("event:") -> {
eventId = line.removePrefix("event:").trim()
}
line.startsWith("data:") -> {
dataLines.add(line.removePrefix("data:").removePrefix(" "))
}
}
}
}
}
}
/**
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
* Returns the new high-water cursor + any frames with cursor > [cursor].
*/
suspend fun poll(cursor: Long): PollResult =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/poll?cursor=$cursor")
.headers(authHeaders())
.build()
pollClient
.newCall(request)
.execute()
.use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) {
throw IOException("poll failed: HTTP ${response.code}")
}
val body = response.body?.string().orEmpty()
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
val frames =
obj["frames"]
?.jsonArray
?.mapNotNull { el ->
try {
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
} catch (e: Exception) {
null
}
}.orEmpty()
PollResult(newCursor, frames)
}
}
/**
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
* with the whole file as the body and the metadata in `X-Iris-Media-*`
* headers (sha256 precomputed in a first pass). Returns the server's
* media_ref (for message.send media_refs) on success.
*/
suspend fun uploadMedia(
path: String,
mime: String,
kind: String,
filename: String,
mediaRef: String,
): Result<String> =
withContext(Dispatchers.IO) {
val file = File(path)
if (!file.isFile() || file.length() <= 0) {
return@withContext Result.failure(IllegalStateException("empty file"))
}
val sha = Sha256()
file.inputStream().use { ins ->
val buf = ByteArray(MEDIA_CHUNK_BYTES)
while (true) {
val n = ins.read(buf)
if (n < 0) break
if (n > 0) sha.update(buf, 0, n)
}
}
val request =
Request
.Builder()
.url("$baseUrl/v1/media")
.headers(
authHeaders()
.newBuilder()
.add("X-Iris-Media-Ref", mediaRef)
.add("X-Iris-Media-Kind", kind)
.add("X-Iris-Media-Filename", filename)
.add("X-Iris-Media-Sha256", sha.hex())
.build(),
).post(file.asRequestBody(mime.toMediaType()))
.build()
try {
mediaClient
.newCall(request)
.execute()
.use { response ->
val body = response.body?.string().orEmpty()
val frame = parseFrame(body)
if (response.isSuccessful) {
val p = frame?.payloadAs<MediaUploadAckPayload>()
if (p != null && p.ok) {
Result.success(p.mediaRef)
} else {
Result.failure(IllegalStateException("upload rejected by server"))
}
} else {
val e = frame?.payloadAs<ErrorPayload>()
Result.failure(
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
)
}
}
} catch (e: Exception) {
// Network failure mid-upload: report, don't throw (the caller
// is a fire-and-forget coroutine — an uncaught exception kills
// the app process).
IrisLog.w("media upload failed: ${e.message}")
Result.failure(e)
}
}
/**
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
* response body is the file, streamed to [onChunk] (write to cache).
*/
suspend fun pullMedia(
mediaId: String,
onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> =
withContext(Dispatchers.IO) {
// Server-controlled id: reject path-traversal / URL-breaking
// values before they reach the request line (M-2 / S-1).
if (!isValidMediaId(mediaId)) {
return@withContext Result.failure(IllegalStateException("invalid media id"))
}
val request =
Request
.Builder()
.url("$baseUrl/v1/media/$mediaId")
.headers(authHeaders())
.build()
try {
mediaClient
.newCall(request)
.execute()
.use { response ->
if (!response.isSuccessful) {
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
Result.failure(
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
)
} else {
try {
val source = response.body?.source() ?: throw IOException("empty body")
val buf = ByteArray(MEDIA_CHUNK_BYTES)
while (true) {
val n = source.read(buf)
if (n < 0) break
if (n == 0) continue
onChunk(buf.copyOfRange(0, n))
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
} catch (e: Exception) {
// Network failure before/while opening the pull: report, don't
// throw (fire-and-forget caller — uncaught = process death).
IrisLog.w("media pull failed: ${e.message}")
Result.failure(e)
}
}
}
/**
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
* (25 s) — it would kill both receive paths while they are simply waiting
* for the next byte, so the streaming clients override it. The read timeout
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
* comment every 15 s; a healthy poll answers within 25 s).
*/
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
* heartbeat (detects a dead connection within 45 s). */
internal fun OkHttpClient.streamClient(): OkHttpClient =
newBuilder()
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(45_000, TimeUnit.MILLISECONDS)
.build()
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
* hold + 15 s margin. */
internal fun OkHttpClient.pollClient(): OkHttpClient =
newBuilder()
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(40_000, TimeUnit.MILLISECONDS)
.build()
internal fun OkHttpClient.healthClient(): OkHttpClient =
newBuilder()
.callTimeout(2_000, TimeUnit.MILLISECONDS)
.build()
/** Media transfers (upload/pull) can take a while on large files. */
internal fun OkHttpClient.mediaClient(): OkHttpClient =
newBuilder()
.callTimeout(300_000, TimeUnit.MILLISECONDS)
.build()
@@ -0,0 +1,96 @@
package iris.net
import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
/**
* Latest-release check (docs/04 hello.ack note): the repo is public on Gitea,
* so the app can ask for the newest release tag without any token. Settings
* shows "vX.Y.Z available" when the running build is older.
*
* Best-effort by design: any failure (offline, DNS, rate limit) just yields
* `null` — the check must never surface an error in the UI.
*/
object ReleaseCheck {
const val LATEST_RELEASE_URL =
"https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest"
/**
* One shared client for the process lifetime: an OkHttpClient owns a
* thread pool and connection pool, so it must not be rebuilt per screen
* open (and never needs explicit shutdown — the pools idle out). Short
* timeouts so a black-holed network can't linger the LaunchedEffect.
*/
private val client: OkHttpClient =
OkHttpClient
.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
/**
* The latest release version (tag without the leading "v"), or `null`
* when the check could not be performed.
*/
suspend fun latestVersion(): String? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(LATEST_RELEASE_URL)
.get()
.build()
try {
client.newCall(request).execute().use { response: Response ->
if (!response.isSuccessful) {
IrisLog.d("ReleaseCheck: HTTP ${response.code}")
return@withContext null
}
val body = response.body?.string() ?: return@withContext null
val tag =
IrisJson
.instance
.parseToJsonElement(body)
.jsonObject
.get("tag_name")
?.jsonPrimitive
?.content
.orEmpty()
tag.removePrefix("v").ifBlank { null }
}
} catch (e: Exception) {
IrisLog.d("ReleaseCheck: ${e.message}")
null
}
}
/**
* True when [latest] is a newer release than [running]. Numeric
* component-wise comparison (so "0.1.10" > "0.1.9"); anything that does
* not parse as dotted numbers is treated as "not newer" — the hint is
* best-effort and must never fire for dev builds ahead of the latest
* release.
*/
fun isNewer(
latest: String,
running: String,
): Boolean {
val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
if (a.isEmpty() || b.isEmpty()) return false
val n = maxOf(a.size, b.size)
for (i in 0 until n) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
}
return false
}
}
@@ -0,0 +1,118 @@
package iris.net
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
*
* A gateway behind `IRIS_HTTP_CERT` may present a
* self-signed certificate the platform doesn't trust. Instead of forcing the
* user to install it into the system trust store, the app shows the
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
* the user confirms it, the fingerprint is pinned in secure storage and
* [PinningTrustManager] accepts exactly that certificate from then on.
*
* Hostname verification is NOT bypassed: OkHttp runs its own hostname check
* on top of the trust manager, so a pinned cert still has to match the URL's
* host. A *changed* certificate (different fingerprint) fails again with
* [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH
* host key.
*/
/**
* The gateway presented a certificate the platform doesn't trust and that
* doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the
* user must confirm. Thrown by [PinningTrustManager] during the handshake;
* the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired].
*/
class TlsFingerprintRequired(
val fingerprint: String,
) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)")
/**
* SHA-256 of the certificate's DER encoding, colon-separated byte pairs
* (SSH host-key style) — the string the user verifies on the gateway host.
*/
fun certFingerprint(cert: X509Certificate): String =
MessageDigest
.getInstance("SHA-256")
.digest(cert.encoded)
.joinToString(":") { String.format("%02X", it) }
/**
* Wraps the platform default trust manager:
* - CA-signed certs behave exactly as before (the default manager decides).
* - A cert the default manager REJECTS is accepted only when its fingerprint
* equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh
* pin is picked up without rebuilding the client).
* - Anything else fails with [TlsFingerprintRequired] carrying the
* presented fingerprint, so the UI can offer the confirm dialog.
*/
class PinningTrustManager(
private val pinnedFingerprint: () -> String,
) : X509TrustManager {
private val default: X509TrustManager = defaultTrustManager()
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
default.checkClientTrusted(chain, authType)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
try {
default.checkServerTrusted(chain, authType)
} catch (e: CertificateException) {
val fp = certFingerprint(chain.first())
if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return
throw TlsFingerprintRequired(fp)
}
}
}
/** The platform default server trust manager (the JDK's CA store). */
fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager)
?: error("no X509TrustManager in the default trust store")
}
/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */
fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory {
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf<TrustManager>(tm), SecureRandom())
return ctx.socketFactory
}
/**
* Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport
* exception: the trust manager throws it during the handshake and the
* JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when
* the failure has nothing to do with an untrusted gateway certificate.
*/
fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? {
var t: Throwable? = e
var depth = 0
while (t != null && depth < 10) {
if (t is TlsFingerprintRequired) return t
t = t.cause
depth++
}
return null
}
@@ -35,21 +35,49 @@ expect fun mediaCacheBaseDir(): String
expect suspend fun loadMediaImage(path: String): ImageBitmap? expect suspend fun loadMediaImage(path: String): ImageBitmap?
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */ /** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? expect suspend fun loadScaledImage(
path: String,
maxSize: Int,
): ImageBitmap?
/**
* Decode an image from raw bytes (bundled backdrops), scaled to at most
* [maxSize] px on the longest edge. Null on failure.
*/
expect suspend fun decodeImageBytes(
bytes: ByteArray,
maxSize: Int,
): ImageBitmap?
/**
* Read a bundled backdrop image (shipped in the app) by its [id] (the file
* name without extension, e.g. `pexels-yunszyveli-12368637`). Android reads it
* from `assets/backdrops/`, desktop from the classpath. Null on failure.
*/
expect fun readBackdropBytes(id: String): ByteArray?
/** /**
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on * Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
* the longest edge. Null on failure. Used for channel icons (stored on the * the longest edge. Null on failure. Used for channel icons (stored on the
* gateway, synced across devices). * gateway, synced across devices).
*/ */
expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? expect suspend fun encodeImageAsBase64(
path: String,
maxSize: Int,
): String?
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */ /** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
expect suspend fun decodeBase64Image(base64: String): ImageBitmap? expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */ /** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable @Composable
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier) expect fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
)
/** Open a document with the system handler (best effort). */ /** Open a document with the system handler (best effort). */
expect fun openDocument(path: String, mime: String) expect fun openDocument(
path: String,
mime: String,
)
@@ -6,7 +6,7 @@ package iris.platform
* The controller (commonMain) needs to know whether the app is in the * The controller (commonMain) needs to know whether the app is in the
* foreground (to decide between an in-app banner and a system notification) * foreground (to decide between an in-app banner and a system notification)
* and to post a system notification when a `notification` frame arrives while * and to post a system notification when a `notification` frame arrives while
* the app is backgrounded but the WS is still live. * the app is backgrounded but the gateway connection is still live.
*/ */
/** True when the app's UI is visible (Android: activity resumed). */ /** True when the app's UI is visible (Android: activity resumed). */
@@ -29,3 +29,12 @@ expect fun postSystemNotification(
* FCM / ntfy services can reach it). No-op on desktop. * FCM / ntfy services can reach it). No-op on desktop.
*/ */
expect fun setActiveController(controller: Any?) expect fun setActiveController(controller: Any?)
/**
* Align the ntfy listener with the paired gateway's push backend (Android:
* start/stop the listener foreground service — and with it the permanent
* "Listening for messages" notification; no-op on desktop). The listener
* only runs when the gateway actually pushes via ntfy; FCM gateways need
* no persistent listener.
*/
expect fun syncNtfyListener(backend: String)
@@ -0,0 +1,13 @@
package iris.platform
import androidx.compose.runtime.Composable
/**
* A "Scan QR" button that launches the platform QR scanner and delivers the
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
*
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
* (the Connect screen hides it there).
*/
@Composable
expect fun QrScanButton(onResult: (String?) -> Unit)
@@ -0,0 +1,13 @@
package iris.platform
import app.cash.sqldelight.db.SqlDriver
/**
* Persistent app-data directory. Unlike the media cache dir, it is not wiped
* by the system or by the user clearing the app cache — it holds the SQLite
* local cache (messages / channels / meta).
*/
expect fun appDataDir(): String
/** Open the SQLite driver for the local cache database. */
expect fun createCacheDriver(): SqlDriver
@@ -12,15 +12,16 @@ import kotlinx.serialization.json.put
/** /**
* Wire protocol frames (mirror of gateway-plugin/protocol.py). * Wire protocol frames (mirror of gateway-plugin/protocol.py).
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send, * See docs/04-wire-protocol.md. Defines all frame types and payloads:
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/ * hello/hello.ack, message (send + streaming), tool cards, commentary,
* progress/end, commentary, reasoning (on message / message.stop). * reasoning, channels, media, notifications, sync, and error frames.
*/ */
const val PROTOCOL_VERSION = 1 const val PROTOCOL_VERSION = 1
object IrisJson { object IrisJson {
val instance: Json = Json { val instance: Json =
Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
encodeDefaults = true encodeDefaults = true
isLenient = true isLenient = true
@@ -29,13 +30,10 @@ object IrisJson {
// ── Frame type constants ──────────────────────────────────────────────── // ── Frame type constants ────────────────────────────────────────────────
const val TYPE_HELLO = "hello"
const val TYPE_HELLO_ACK = "hello.ack" const val TYPE_HELLO_ACK = "hello.ack"
const val TYPE_MESSAGE = "message" const val TYPE_MESSAGE = "message"
const val TYPE_MESSAGE_SEND = "message.send" const val TYPE_MESSAGE_SEND = "message.send"
const val TYPE_ERROR = "error" const val TYPE_ERROR = "error"
const val TYPE_PING = "ping"
const val TYPE_PONG = "pong"
const val TYPE_TYPING = "typing" const val TYPE_TYPING = "typing"
// M2 — streaming / tools / commentary // M2 — streaming / tools / commentary
@@ -49,15 +47,16 @@ const val TYPE_MESSAGE_DELETED = "message.deleted"
const val TYPE_TOOL_START = "tool.start" const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress" const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end" const val TYPE_TOOL_END = "tool.end"
// Agent todo list (live planning state; ephemeral, never outboxed — a
// reconnecting device re-learns it from the snapshot after hello.ack).
const val TYPE_TODO_UPDATE = "todo.update"
const val TYPE_COMMENTARY = "commentary" const val TYPE_COMMENTARY = "commentary"
// M4 — media (upload / offer / pull) // M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
const val TYPE_MEDIA_UPLOAD_START = "media.upload.start"
const val TYPE_MEDIA_UPLOAD_END = "media.upload.end"
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack" const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
const val TYPE_MEDIA_OFFER = "media.offer" const val TYPE_MEDIA_OFFER = "media.offer"
const val TYPE_MEDIA_PULL = "media.pull"
const val TYPE_MEDIA_PULL_END = "media.pull.end"
// M5 — push / notifications / read receipt / gateway status // M5 — push / notifications / read receipt / gateway status
const val TYPE_NOTIFICATION = "notification" const val TYPE_NOTIFICATION = "notification"
@@ -71,6 +70,7 @@ const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_FAVORITE = "channel.favorite" const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
const val TYPE_CHANNEL_ICON = "channel.icon" const val TYPE_CHANNEL_ICON = "channel.icon"
const val TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
const val TYPE_CHANNEL_DELETE = "channel.delete" const val TYPE_CHANNEL_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created" const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed" const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -78,24 +78,22 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted"
const val TYPE_CHANNEL_LIST = "channel.list" const val TYPE_CHANNEL_LIST = "channel.list"
const val TYPE_SEARCH = "search" const val TYPE_SEARCH = "search"
const val TYPE_SEARCH_RESULTS = "search.results" const val TYPE_SEARCH_RESULTS = "search.results"
// Slash-command catalog (the composer's "/" drawer)
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
// Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
const val TYPE_PICKER_CHOICE = "picker.choice"
const val TYPE_PICKER_SELECT = "picker.select"
const val TYPE_SYNC = "sync" const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done" const val TYPE_SYNC_DONE = "sync.done"
const val TYPE_HISTORY = "history" const val TYPE_HISTORY = "history"
// ── Error codes ─────────────────────────────────────────────────────────
const val ERR_AUTH = "auth"
const val ERR_NOT_FOUND = "not_found"
const val ERR_UNSUPPORTED = "unsupported"
const val ERR_INTERNAL = "internal"
const val ERR_MEDIA_TOO_LARGE = "media_too_large"
// M4 — media kinds (docs/07 §7.1) // M4 — media kinds (docs/07 §7.1)
const val KIND_IMAGE = "image" const val KIND_IMAGE = "image"
const val KIND_AUDIO = "audio" const val KIND_AUDIO = "audio"
const val KIND_VIDEO = "video" const val KIND_VIDEO = "video"
const val KIND_DOCUMENT = "document" const val KIND_DOCUMENT = "document"
const val KIND_VOICE = "voice"
// ── Roles ─────────────────────────────────────────────────────────────── // ── Roles ───────────────────────────────────────────────────────────────
@@ -111,6 +109,11 @@ data class Frame(
val type: String, val type: String,
@SerialName("chat_id") val chatId: String? = null, @SerialName("chat_id") val chatId: String? = null,
@SerialName("thread_id") val threadId: String? = null, @SerialName("thread_id") val threadId: String? = null,
/** Outbox cursor this frame was parked under. Set only on frames
* replayed by `sync` (live frames carry none) — the app skips
* re-notifying replayed frames with `cursor <= lastPushedCursor`
* (they already woke the device via push, docs/08 §8.7). */
val cursor: Long? = null,
val payload: JsonElement = JsonObject(emptyMap()), val payload: JsonElement = JsonObject(emptyMap()),
) { ) {
/** Payload as a JSON object (the wire format); parse per-type with /** Payload as a JSON object (the wire format); parse per-type with
@@ -123,18 +126,6 @@ data class Frame(
} }
} }
// ── hello (app -> server) ───────────────────────────────────────────────
@Serializable
data class HelloPayload(
val token: String,
@SerialName("device_id") val deviceId: String,
@SerialName("device_name") val deviceName: String,
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
@SerialName("fcm_token") val fcmToken: String? = null,
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
)
// ── hello.ack (server -> app) ─────────────────────────────────────────── // ── hello.ack (server -> app) ───────────────────────────────────────────
@Serializable @Serializable
@@ -144,9 +135,13 @@ data class ServerCaps(
val tools: Boolean = false, val tools: Boolean = false,
val media: Boolean = false, val media: Boolean = false,
val search: Boolean = false, val search: Boolean = false,
/** The gateway answers `commands.catalog` (the composer's "/" drawer). */
@SerialName("commands_catalog") val commandsCatalog: Boolean = false,
val push: String = "fcm", val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "", @SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false, val pickers: Boolean = false,
/** Release version of the gateway plugin (repo-root VERSION file). */
@SerialName("app_version") val appVersion: String = "",
) )
@Serializable @Serializable
@@ -157,6 +152,9 @@ data class ChannelInfo(
@SerialName("is_default") val isDefault: Boolean = false, @SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null, @SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false, val archived: Boolean = false,
/** Unix timestamp (seconds) when the channel/thread was created; 0.0 if
* the gateway predates the field. Used to order threads newest-first. */
val created: Double = 0.0,
/** Gateway minted this thread for an incoming message (auto-threading); /** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */ * the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false, val auto: Boolean = false,
@@ -168,6 +166,10 @@ data class ChannelInfo(
/** Cosmetic avatar color override ("#RRGGBB"), or null for the /** Cosmetic avatar color override ("#RRGGBB"), or null for the
* auto-generated name-hash color. */ * auto-generated name-hash color. */
val color: String? = null, val color: String? = null,
/** Automation channel: read-only for the user. It only receives
* gateway-originated output (cron jobs, webhooks); the app hides the
* composer and the gateway rejects direct sends into it. */
val automation: Boolean = false,
) )
@Serializable @Serializable
@@ -175,10 +177,35 @@ data class HelloAckPayload(
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(), @SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
@SerialName("sync_cursor") val syncCursor: Long = 0, @SerialName("sync_cursor") val syncCursor: Long = 0,
val channels: List<ChannelInfo> = emptyList(), val channels: List<ChannelInfo> = emptyList(),
/** M5: highest outbox cursor already delivered to THIS device via the
* push backend (0 = never). Sync-replayed frames at/below it must not
* re-post system notifications (dedupe, docs/08 §8.7). */
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
/** Per-device token minted at pairing (docs/09 §9.3). The app stores it
* and presents it INSTEAD of the shared IRIS_TOKEN from then on; the
* gateway can revoke it per device. Empty when the gateway didn't
* issue one (legacy). */
@SerialName("device_token") val deviceToken: String = "",
) )
// ── message (server -> app) ───────────────────────────────────────────── // ── message (server -> app) ─────────────────────────────────────────────
/**
* Structured runtime metadata for the app's footer (mirror of the gateway's
* `runtime` object). The gateway ALWAYS sends this on final assistant
* messages; whether/what is shown is a per-app setting (Settings → Runtime
* footer), NOT a hermes config. Fields are absent when the data is
* unavailable (local models have no cost, etc.).
*/
@Serializable
data class RuntimeMeta(
val model: String? = null,
@SerialName("context_pct") val contextPct: Int? = null,
val cwd: String? = null,
val latency: Double? = null,
val cost: Double? = null,
)
@Serializable @Serializable
data class MessagePayload( data class MessagePayload(
@SerialName("message_id") val messageId: String, @SerialName("message_id") val messageId: String,
@@ -188,6 +215,7 @@ data class MessagePayload(
@SerialName("reply_to") val replyTo: String? = null, @SerialName("reply_to") val replyTo: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
val media: List<MediaRef> = emptyList(), val media: List<MediaRef> = emptyList(),
) )
@@ -204,21 +232,6 @@ data class MediaRef(
val filename: String, val filename: String,
) )
@Serializable
data class MediaUploadStartPayload(
@SerialName("media_ref") val mediaRef: String,
val kind: String,
val mime: String,
val filename: String,
val size: Long,
)
@Serializable
data class MediaUploadEndPayload(
@SerialName("media_ref") val mediaRef: String,
@SerialName("sha256") val sha256: String,
)
@Serializable @Serializable
data class MediaUploadAckPayload( data class MediaUploadAckPayload(
val ok: Boolean, val ok: Boolean,
@@ -235,14 +248,6 @@ data class MediaOfferPayload(
@SerialName("message_id") val messageId: String? = null, @SerialName("message_id") val messageId: String? = null,
) )
@Serializable
data class MediaPullPayload(
@SerialName("media_id") val mediaId: String,
)
@Serializable
data class MediaPullEndPayload(val ok: Boolean)
// ── M2: streaming frames (server -> app) ──────────────────────────────── // ── M2: streaming frames (server -> app) ────────────────────────────────
@Serializable @Serializable
@@ -264,6 +269,7 @@ data class MessageStopPayload(
val reasoning: String? = null, val reasoning: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
) )
@@ -275,6 +281,9 @@ data class ToolStartPayload(
val name: String, val name: String,
val preview: String? = null, val preview: String? = null,
val args: JsonElement? = null, val args: JsonElement? = null,
/** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji);
* absent for unknown tools — the UI falls back to its default. */
val emoji: String? = null,
) )
@Serializable @Serializable
@@ -293,6 +302,30 @@ data class ToolEndPayload(
@SerialName("output_preview") val outputPreview: String? = null, @SerialName("output_preview") val outputPreview: String? = null,
) )
// ── todo.update (server -> app): the agent's live todo list ────────────────
/** One item of the agent's todo list (hermes `todo` tool). [status] is one
* of [TODO_PENDING], [TODO_IN_PROGRESS], [TODO_COMPLETED], [TODO_CANCELLED]. */
@Serializable
data class TodoItem(
val id: String,
val content: String,
val status: String,
)
const val TODO_PENDING = "pending"
const val TODO_IN_PROGRESS = "in_progress"
const val TODO_COMPLETED = "completed"
const val TODO_CANCELLED = "cancelled"
/** The agent's FULL current todo list for a lane (last-write-wins; the
* gateway emits it whenever the `todo` tool completes — the tool result is
* authoritative even for merge writes — and as a snapshot on connect). */
@Serializable
data class TodoUpdatePayload(
val todos: List<TodoItem> = emptyList(),
)
// ── M2: commentary frame (server -> app) ──────────────────────────────── // ── M2: commentary frame (server -> app) ────────────────────────────────
@Serializable @Serializable
@@ -313,16 +346,18 @@ data class MessageSendPayload(
@SerialName("auto_thread") val autoThread: Boolean = false, @SerialName("auto_thread") val autoThread: Boolean = false,
) )
// ── typing / error / ping ─────────────────────────────────────────────── // ── typing / error ──────────────────────────────────────────────────────
@Serializable @Serializable
data class TypingPayload(val on: Boolean) data class TypingPayload(
val on: Boolean,
)
@Serializable @Serializable
data class ErrorPayload(val code: String, val message: String) data class ErrorPayload(
val code: String,
@Serializable val message: String,
data class PingPayload(val ts: Long? = null) )
// ── M3: channel directory (app -> server requests) ────────────────────── // ── M3: channel directory (app -> server requests) ──────────────────────
@@ -334,10 +369,19 @@ data class ChannelCreatePayload(
) )
@Serializable @Serializable
data class ChannelRenamePayload(val name: String) data class ChannelRenamePayload(
val name: String,
)
@Serializable @Serializable
data class ChannelFavoritePayload(val on: Boolean) data class ChannelFavoritePayload(
val on: Boolean,
)
@Serializable
data class ChannelSetAutomationPayload(
val on: Boolean,
)
@Serializable @Serializable
data class ChannelIconPayload( data class ChannelIconPayload(
@@ -348,10 +392,14 @@ data class ChannelIconPayload(
) )
@Serializable @Serializable
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList()) data class ChannelListPayload(
val channels: List<ChannelInfo> = emptyList(),
)
@Serializable @Serializable
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String) data class ChannelDeletedPayload(
@SerialName("chat_id") val chatId: String,
)
// ── M3: search ────────────────────────────────────────────────────────── // ── M3: search ──────────────────────────────────────────────────────────
@@ -381,13 +429,56 @@ data class SearchResultsPayload(
val hits: List<SearchHit> = emptyList(), val hits: List<SearchHit> = emptyList(),
) )
// ── commands.catalog (slash-command catalog for the "/" drawer) ──────────
/** One slash command from the gateway catalog. [name]/[aliases] carry the
* leading slash ("/new", "/reset"); the app fuzzy-matches client-side. */
@Serializable
data class SlashCommand(
val name: String,
val description: String = "",
@SerialName("args_hint") val argsHint: String = "",
val category: String = "",
val aliases: List<String> = emptyList(),
)
@Serializable
data class CommandsCatalogPayload(
val commands: List<SlashCommand> = emptyList(),
)
// ── picker.choice / picker.select (interactive slash-command menus) ────────
/** One option in a choice picker. [isCurrent] marks the active value (the
* UI shows a ✓ on it, like Telegram's inline keyboards). */
@Serializable
data class PickerChoice(
val value: String,
val label: String = value,
@SerialName("is_current") val isCurrent: Boolean = false,
)
/** An interactive choice picker (one tap → one value), sent by finite-choice
* slash commands (/reasoning, /fast, …). The app renders it as a card with
* buttons and answers via [pickerSelectFrame]. */
@Serializable
data class PickerChoicePayload(
@SerialName("picker_id") val pickerId: String,
val title: String,
val choices: List<PickerChoice> = emptyList(),
)
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────── // ── M3: sync (reconnect catch-up) ───────────────────────────────────────
@Serializable @Serializable
data class SyncPayload(val cursor: Long) data class SyncPayload(
val cursor: Long,
)
@Serializable @Serializable
data class SyncDonePayload(val cursor: Long) data class SyncDonePayload(
val cursor: Long,
)
// ── history (full message history for a chat/thread) ──────────────────── // ── history (full message history for a chat/thread) ────────────────────
@@ -400,8 +491,12 @@ data class HistoryMessage(
val reasoning: String? = null, val reasoning: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
val media: List<MediaRef> = emptyList(), // Nullable: the schema says array, but older gateways sent
// `"media": null` for streaming finals — a null there used to break
// deserialization of the whole history page (silently dropping it).
val media: List<MediaRef>? = null,
) )
@Serializable @Serializable
@@ -422,12 +517,9 @@ data class MessageDeletedPayload(
// ── M5: push / notifications ──────────────────────────────────────────── // ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */ /** Notification kinds (mirror of protocol.NOTIF_*). */
const val NOTIF_MESSAGE = "message"
const val NOTIF_APPROVAL = "approval" const val NOTIF_APPROVAL = "approval"
const val NOTIF_CLARIFY = "clarify" const val NOTIF_CLARIFY = "clarify"
const val NOTIF_CRON = "cron" const val NOTIF_CRON = "cron"
const val NOTIF_CHANNEL = "channel"
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */ /** Kinds that stay on screen until dismissed (docs/08 §8.3). */
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON) val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
@@ -456,31 +548,12 @@ data class ReadReceiptPayload(
) )
@Serializable @Serializable
data class StatusPayload(val state: String) data class StatusPayload(
val state: String,
)
// ── Frame builders ────────────────────────────────────────────────────── // ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(
token: String,
deviceId: String,
deviceName: String,
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame(
type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(
token = token,
deviceId = deviceId,
deviceName = deviceName,
fcmToken = fcmToken,
ntfyTopic = ntfyTopic,
),
),
)
fun messageSendFrame( fun messageSendFrame(
id: Int, id: Int,
chatId: String, chatId: String,
@@ -494,68 +567,107 @@ fun messageSendFrame(
type = TYPE_MESSAGE_SEND, type = TYPE_MESSAGE_SEND,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(), MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread), MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
), ),
) )
fun pingFrame(): Frame =
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── M3 frame builders ─────────────────────────────────────────────────── // ── M3 frame builders ───────────────────────────────────────────────────
fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame = fun channelCreateFrame(
id: Int,
name: String,
kind: String = "channel",
parentChatId: String? = null,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_CREATE, type = TYPE_CHANNEL_CREATE,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelCreatePayload.serializer(), ChannelCreatePayload.serializer(),
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId), ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
), ),
) )
fun channelRenameFrame(id: Int, chatId: String, name: String): Frame = fun channelRenameFrame(
id: Int,
chatId: String,
name: String,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_RENAME, type = TYPE_CHANNEL_RENAME,
chatId = chatId, chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelRenamePayload.serializer(), ChannelRenamePayload.serializer(),
ChannelRenamePayload(name = name), ChannelRenamePayload(name = name),
), ),
) )
fun channelSetDefaultFrame(id: Int, chatId: String): Frame = fun channelSetDefaultFrame(
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId) id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame = fun channelFavoriteFrame(
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_FAVORITE, type = TYPE_CHANNEL_FAVORITE,
chatId = chatId, chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelFavoritePayload.serializer(), ChannelFavoritePayload.serializer(),
ChannelFavoritePayload(on = on), ChannelFavoritePayload(on = on),
), ),
) )
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame = fun channelSetAutomationFrame(
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_SET_AUTOMATION,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
ChannelSetAutomationPayload.serializer(),
ChannelSetAutomationPayload(on = on),
),
)
fun channelIconFrame(
id: Int,
chatId: String,
icon: String?,
color: String?,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_ICON, type = TYPE_CHANNEL_ICON,
chatId = chatId, chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
ChannelIconPayload.serializer(), ChannelIconPayload.serializer(),
ChannelIconPayload(icon = icon, color = color), ChannelIconPayload(icon = icon, color = color),
), ),
) )
fun channelDeleteFrame(id: Int, chatId: String): Frame = fun channelDeleteFrame(
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId) id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
fun channelListFrame(id: Int): Frame = fun channelListFrame(id: Int): Frame = Frame(id = id, type = TYPE_CHANNEL_LIST)
Frame(id = id, type = TYPE_CHANNEL_LIST)
fun searchFrame( fun searchFrame(
id: Int, id: Int,
@@ -569,17 +681,44 @@ fun searchFrame(
type = TYPE_SEARCH, type = TYPE_SEARCH,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
SearchPayload.serializer(), SearchPayload.serializer(),
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId), SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
), ),
) )
fun syncFrame(id: Int, cursor: Long): Frame = /** Request the gateway's slash-command catalog (the composer's "/" drawer).
* Answered by a `commands.catalog` frame carrying the same id. */
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
/** Answer an interactive picker (picker.choice) with the chosen value.
* The server runs the command's selection callback and delivers its reply
* as a normal message in the picker's chat. */
fun pickerSelectFrame(
id: Int,
pickerId: String,
value: String,
): Frame =
Frame(
id = id,
type = TYPE_PICKER_SELECT,
payload =
buildJsonObject {
put("picker_id", pickerId)
put("value", value)
},
)
fun syncFrame(
id: Int,
cursor: Long,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_SYNC, type = TYPE_SYNC,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
SyncPayload.serializer(), SyncPayload.serializer(),
SyncPayload(cursor = cursor), SyncPayload(cursor = cursor),
), ),
@@ -600,7 +739,8 @@ fun historyFrame(
type = TYPE_HISTORY, type = TYPE_HISTORY,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = buildJsonObject { payload =
buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId) if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit) put("limit", limit)
}, },
@@ -619,56 +759,22 @@ fun messageDeleteFrame(
type = TYPE_MESSAGE_DELETE, type = TYPE_MESSAGE_DELETE,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = buildJsonObject { payload =
buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) })) put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
}, },
) )
// ── M4 frame builders ────────────────────────────────────────────────────
fun mediaUploadStartFrame(
id: Int,
mediaRef: String,
kind: String,
mime: String,
filename: String,
size: Long,
): Frame =
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_START,
payload = IrisJson.instance.encodeToJsonElement(
MediaUploadStartPayload.serializer(),
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
),
)
fun mediaUploadEndFrame(id: Int, mediaRef: String, sha256: String): Frame =
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_END,
payload = IrisJson.instance.encodeToJsonElement(
MediaUploadEndPayload.serializer(),
MediaUploadEndPayload(mediaRef, sha256),
),
)
fun mediaPullFrame(id: Int, mediaId: String): Frame =
Frame(
id = id,
type = TYPE_MEDIA_PULL,
payload = IrisJson.instance.encodeToJsonElement(
MediaPullPayload.serializer(),
MediaPullPayload(mediaId),
),
)
// ── M5 frame builders ─────────────────────────────────────────────────── // ── M5 frame builders ───────────────────────────────────────────────────
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame = fun fcmRegisterFrame(
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame( Frame(
type = TYPE_FCM_REGISTER, type = TYPE_FCM_REGISTER,
payload = IrisJson.instance.encodeToJsonElement( payload =
IrisJson.instance.encodeToJsonElement(
FcmRegisterPayload.serializer(), FcmRegisterPayload.serializer(),
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic), FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
), ),
File diff suppressed because it is too large. Load diff
@@ -9,17 +9,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -37,15 +41,28 @@ import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.StreamingMarkdownState
import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import iris.util.appendChunk
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import iris.util.preserveNewlinesAsHardBreaksStreaming
import iris.util.revealStep
import iris.util.streamCharsPerSecond
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
/** Reveal tick for the streaming typewriter effect (docs/05 §5.1). */
private const val STREAM_REVEAL_TICK_MS = 50L
/** /**
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM * Renders a chat message as markdown (M8): bold / italic / underscore, GFM
@@ -53,6 +70,12 @@ import org.intellij.markdown.MarkdownElementTypes
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the * syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the * raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in. * rendered text blends in.
*
* While [isStreaming], the text is revealed at a steady rate (controlled by
* [streamSmoothness], Settings → Streaming) and parsed INCREMENTALLY: settled
* blocks are parsed once and never re-laid out, only the tail re-renders per
* tick — so the bubble stays readable instead of reflowing (and flashing raw
* markdown) on every gateway update.
*/ */
@Composable @Composable
fun MarkdownText( fun MarkdownText(
@@ -64,11 +87,30 @@ fun MarkdownText(
// plain highlighted code — the artifact card (and its runnable preview) // plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete. // only appears once the message is complete.
isStreaming: Boolean = false, isStreaming: Boolean = false,
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
) { ) {
val state = rememberMarkdownState(text) // Static path: transform once per text change (leading blanks stripped,
// single newlines → hard breaks).
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
// Whether this bubble has ever been streamed. Static history messages
// skip the reveal pipeline entirely.
val hasStreamed = remember { mutableStateOf(isStreaming) }
if (isStreaming) hasStreamed.value = true
val pipeline =
rememberStreamingMarkdownPipeline(
text = text,
smoothness = streamSmoothness,
active = hasStreamed.value,
streaming = isStreaming,
)
// Keep the streaming renderer until the reveal catches up, even after
// message.stop — a fast model that dumps the whole text at once still
// plays out the typewriter instead of jumping to the full message.
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
// The app is always dark-themed, so force the dark highlight palette // The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop). // (isSystemInDarkTheme() is unreliable on desktop).
val highlights = remember { val highlights =
remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true)) Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
} }
val base = TextStyle(color = color, fontSize = fontSize) val base = TextStyle(color = color, fontSize = fontSize)
@@ -80,7 +122,8 @@ fun MarkdownText(
// Brief green flash shown on the chip right after a copy, so the tap is // Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent). // visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle = val copiedCodeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f)) base
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.toSpanStyle() .toSpanStyle()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -92,9 +135,11 @@ fun MarkdownText(
// Re-render it without the padding, and wrap it in a link so tapping the // Re-render it without the padding, and wrap it in a link so tapping the
// inline code copies it to the clipboard (Telegram-style). The // inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened. // LinkAnnotation carries the click listener; the URL is never opened.
val annotator = markdownAnnotator( val annotator =
markdownAnnotator(
annotate = { content, child -> annotate = { content, child ->
if (child.type == MarkdownElementTypes.CODE_SPAN) { when {
child.type == MarkdownElementTypes.CODE_SPAN -> {
val children = child.children val children = child.children
// Drop the surrounding backtick tokens (present as first/last child). // Drop the surrounding backtick tokens (present as first/last child).
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
@@ -106,13 +151,15 @@ fun MarkdownText(
url = "iris:copy-code", url = "iris:copy-code",
// Keep every interaction state identical to the chip so // Keep every interaction state identical to the chip so
// hover/press never restyles the inline code. // hover/press never restyles the inline code.
styles = TextLinkStyles( styles =
TextLinkStyles(
style = spanStyle, style = spanStyle,
focusedStyle = spanStyle, focusedStyle = spanStyle,
hoveredStyle = spanStyle, hoveredStyle = spanStyle,
pressedStyle = spanStyle, pressedStyle = spanStyle,
), ),
linkInteractionListener = LinkInteractionListener { linkInteractionListener =
LinkInteractionListener {
clipboard.setText(AnnotatedString(code)) clipboard.setText(AnnotatedString(code))
copiedCode.value = code copiedCode.value = code
scope.launch { scope.launch {
@@ -126,23 +173,36 @@ fun MarkdownText(
} }
pop() pop()
true true
} else { }
// Streaming cursor: append ▉ to the last text leaf of the
// document so the cursor sits at the end of the visible text.
// The tail is re-annotated on every reveal tick, so the cursor
// follows the text (and vanishes once the message is final).
useStreaming &&
child.type == MarkdownTokenTypes.TEXT &&
content.length - child.endOffset <= 1 -> {
append(child.getUnescapedTextInNode(content))
append(" ▉")
true
}
else -> {
false false
} }
}
}, },
) )
Markdown( val colors =
markdownState = state, markdownColor(
modifier = modifier,
annotator = annotator,
colors = markdownColor(
text = color, text = color,
codeBackground = Color.Black.copy(alpha = 0.8f), codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground, inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider, dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f), tableBackground = Color.White.copy(alpha = 0.03f),
), )
typography = markdownTypography( val typography =
markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold), h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold), h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold), h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
@@ -157,13 +217,17 @@ fun MarkdownText(
ordered = base, ordered = base,
bullet = base, bullet = base,
list = base, list = base,
link = base.copy(textDecoration = TextDecoration.Underline), textLink =
table = base.copy(fontSize = fontSize * 0.95f), TextLinkStyles(
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
), ),
components = markdownComponents( table = base.copy(fontSize = fontSize * 0.95f),
)
val components =
markdownComponents(
codeFence = { model -> codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style -> MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) { if (!useStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -172,7 +236,7 @@ fun MarkdownText(
}, },
codeBlock = { model -> codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style -> MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) { if (!useStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -182,17 +246,129 @@ fun MarkdownText(
// The core default checkbox renders literal "[x]"/"[ ]" text; use the // The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead. // Material 3 checkbox instead.
checkbox = { checkbox = {
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text) com.mikepenz.markdown.m3.elements
}, .MarkdownCheckBox(it.content, it.node, it.typography.text)
),
loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw
// text so the bubble never goes blank between updates.
Text(text, modifier = m, color = color, fontSize = fontSize)
}, },
) )
if (useStreaming) {
if (pipeline.displayed.isEmpty()) {
// No text revealed yet (message.start, first tick pending): show
// just the cursor so the bubble is visible immediately.
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
} else {
Markdown(
streamingMarkdownState = pipeline.state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
)
}
} else {
// retainState: keep the last formatted output visible while the new
// text re-parses (async) — no raw-markdown flash between updates.
val state = rememberMarkdownState(displayText, retainState = true)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
loading = { m ->
// First parse of a (long) message: show the text so the bubble
// never goes blank.
Text(displayText, modifier = m, color = color, fontSize = fontSize)
},
)
}
} }
/**
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
* snapshot from the gateway; it is revealed at a steady rate controlled by
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
* keep their AST identity, so Compose never re-lays them out — only the
* unstable tail re-renders per tick.
*
* [active] is false for history messages (never streamed): the reveal is
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
* indicates the message is still receiving updates; once the reveal catches
* up and the message is final, the reveal loop stops.
*
* Returns the parser state, the currently revealed text (read inside the
* composition so reveal ticks recompose the caller), and whether the reveal
* has caught up with the target.
*/
@Composable
private fun rememberStreamingMarkdownPipeline(
text: String,
smoothness: Float,
active: Boolean,
streaming: Boolean,
): StreamingPipeline {
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
// a prefix of the revealed text always maps to a prefix of the target, so
// the diff between consecutive reveals is a pure append.
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
val displayed = remember { mutableStateOf("") }
val latestTarget = rememberUpdatedState(target)
val latestSmoothness = rememberUpdatedState(smoothness)
val latestStreaming = rememberUpdatedState(streaming)
// Bumped when the target is rewritten (not extended): the parser state is
// recreated via key() below and re-seeded with the full text.
val generation = remember { mutableIntStateOf(0) }
val state = key(generation.value) { rememberStreamingMarkdownState() }
LaunchedEffect(state, active) {
if (!active) {
// Never streamed (history message): reveal everything at once so
// the caller falls back to the static renderer immediately.
displayed.value = latestTarget.value
return@LaunchedEffect
}
var last = ""
while (true) {
delay(STREAM_REVEAL_TICK_MS)
val t = latestTarget.value
val cur = displayed.value
if (cur == t) {
// Reveal complete: keep ticking only while the message is
// still streaming (more text may arrive); otherwise stop so
// finished bubbles don't burn battery.
if (!latestStreaming.value) return@LaunchedEffect
continue
}
val step =
revealStep(
remaining = t.length - cur.length,
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
)
val next = t.take(cur.length + step)
displayed.value = next
val chunk = appendChunk(last, next)
if (chunk == null) {
// Rewritten, not extended: recreate the parser state. The
// effect restarts (keyed on [state]) and re-seeds it.
generation.value++
return@LaunchedEffect
}
if (chunk.isNotEmpty()) state.append(chunk)
last = next
}
}
return StreamingPipeline(state, displayed.value, displayed.value == target)
}
/** Result of the streaming reveal pipeline. */
private data class StreamingPipeline(
val state: StreamingMarkdownState,
val displayed: String,
val done: Boolean,
)
/** /**
* Renders a highlighted code block with a copy button in the top-right corner * Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard. * (Telegram-style). Tapping the button copies the whole block to the clipboard.
@@ -207,11 +383,12 @@ internal fun CodeBlockWithCopy(
) { ) {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
Box { Box {
MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style) MarkdownHighlightedCode(code = code, language = language, highlightsBuilder = highlights, style = style)
if (code.isNotBlank()) { if (code.isNotBlank()) {
IconButton( IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) }, onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = Modifier modifier =
Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top; // MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it. // match that so the button sits inside the block, not above it.
File diff suppressed because it is too large. Load diff
@@ -11,10 +11,12 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -24,11 +26,16 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import iris.net.TlsFingerprintRequired
import iris.platform.QrScanButton
import iris.platform.isDesktop
import iris.state.IrisController import iris.state.IrisController
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.util.PairLink
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -41,15 +48,46 @@ fun ConnectScreen(
prefillUrl: String = "", prefillUrl: String = "",
prefillToken: String = "", prefillToken: String = "",
initialError: String? = null, initialError: String? = null,
/** Gateway certificate fingerprint awaiting user confirmation (docs/09
* §9.4) — shown as a confirm dialog on entry. */
tlsFingerprint: String? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) } // Default is a cleartext (non-TLS) URL because the typical gateway is on
// the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
var token by remember { mutableStateOf(prefillToken) } var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) } var error by remember { mutableStateOf(initialError) }
// Fingerprint the user still has to confirm (self-signed gateway cert,
// docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a
// connect attempt fails with an untrusted certificate.
var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) }
// "Test & Connect" (also the dialog's confirm action): real hello test,
// then save + (re)connect. An untrusted gateway certificate surfaces as
// the fingerprint confirm dialog instead of a plain error.
fun doConnect() {
if (busy) return
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
val ex = result.exceptionOrNull()
if (ex is TlsFingerprintRequired) {
pendingFingerprint = ex.fingerprint
} else {
error = ex?.message ?: "connection failed"
}
}
}
}
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(24.dp), .padding(24.dp),
@@ -66,7 +104,8 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface) .background(IrisColors.surface)
@@ -76,7 +115,8 @@ fun ConnectScreen(
value = url, value = url,
onValueChange = { url = it }, onValueChange = { url = it },
label = { Text("Server URL") }, label = { Text("Server URL") },
placeholder = { Text("ws://192.168.1.10:8790/ws") }, // Example LAN URL; https:// works too for TLS gateways.
placeholder = { Text("http://192.168.1.10:8791") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -86,26 +126,29 @@ fun ConnectScreen(
value = token, value = token,
onValueChange = { token = it }, onValueChange = { token = it },
label = { Text("Pairing token") }, label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") }, placeholder = { Text("IRIS_TOKEN (64 hex)") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (!isDesktop) {
Spacer(modifier = Modifier.height(12.dp))
QrScanButton { raw ->
if (raw != null) {
val link = PairLink.parse(raw)
if (link != null) {
url = link.url
token = link.token
} else {
error = "Couldn't read that QR code."
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { onClick = { doConnect() },
if (busy) return@Button
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
error = result.exceptionOrNull()?.message ?: "connection failed"
}
}
},
enabled = !busy, enabled = !busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -124,11 +167,49 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" + "Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
"hermes gateway setup on the gateway host.", "hermes gateway setup on the gateway host.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
// Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate
// this device doesn't trust (self-signed). The user verifies the
// fingerprint on the gateway host, then confirms — the pin is stored in
// secure storage and the connect is retried, like an SSH host key.
if (pendingFingerprint != null) {
AlertDialog(
onDismissRequest = { pendingFingerprint = null },
title = { Text("Confirm gateway certificate") },
text = {
Column {
Text(
"The gateway presents a certificate this device doesn't trust. " +
"Verify the fingerprint on the gateway host, then confirm to pin it.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
pendingFingerprint!!,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
}
},
confirmButton = {
Button(
onClick = {
val fp = pendingFingerprint!!
pendingFingerprint = null
controller.confirmTlsFingerprint(fp)
doConnect()
},
) { Text("Confirm & pin") }
},
dismissButton = {
TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") }
},
)
}
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -43,35 +44,59 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import iris.AppVersion
import iris.net.ReleaseCheck
import iris.platform.ImageFilePicker import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
import iris.state.IrisController import iris.state.IrisController
import iris.state.ToolDetail import iris.state.ToolDetail
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Settings screen (post-M7): app-level preferences, opened from the channel * Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time. * drawer / rail. More settings land here over time.
*/ */
/** Which color the picker dialog is editing. */ /** Which color the picker dialog is editing. */
private enum class PickerTarget { UserBubble, AgentBubble, Background } private enum class PickerTarget { UserBubble, AgentBubble, Background }
@Composable @Composable
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) { fun SettingsScreen(
controller: IrisController,
onBack: () -> Unit,
) {
val threadsEnabled by controller.threadsEnabled.collectAsState() val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState() val streamingEnabled by controller.streamingEnabled.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState() val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState() val fontSizeScale by controller.fontSizeScale.collectAsState()
val gatewayVersion by controller.gatewayVersion.collectAsState()
val theme = LocalUserTheme.current val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
// Best-effort latest-release check (docs/04): one query per screen open,
// failures stay silent (ReleaseCheck returns null).
var latestVersion by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
latestVersion = ReleaseCheck.latestVersion()
}
Box(modifier = Modifier.fillMaxSize().background(theme.background)) { Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(12.dp), .padding(12.dp),
@@ -130,6 +155,27 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
onCheckedChange = { controller.toggleStreaming() }, onCheckedChange = { controller.toggleStreaming() },
) )
} }
if (streamingEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Streaming speed", fontSize = 12.sp, color = IrisColors.textDim)
Text(
"How fast new text appears while streaming — lower is faster",
fontSize = 11.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Fast", fontSize = 12.sp, color = IrisColors.textDim)
Slider(
value = streamSmoothness,
onValueChange = { controller.setStreamSmoothness(it) },
valueRange = STREAM_SMOOTHNESS_MIN..STREAM_SMOOTHNESS_MAX,
steps = 5, // 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.7 / 0.8
modifier = Modifier.weight(1f),
)
Text("Slow", fontSize = 12.sp, color = IrisColors.textDim)
}
}
} }
SettingsCard { SettingsCard {
Row( Row(
@@ -150,6 +196,39 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
) )
} }
} }
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("📊 Runtime footer", fontSize = 14.sp)
Text(
"Show model, context, workdir, latency and cost under replies",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = runtimeFooterEnabled,
onCheckedChange = { controller.toggleRuntimeFooter() },
)
}
if (runtimeFooterEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Fields to show", fontSize = 12.sp, color = IrisColors.textDim)
Spacer(modifier = Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
IrisController.RUNTIME_FIELD_KEYS.forEach { field ->
TopicChip(
label = runtimeFieldLabel(field),
selected = field in runtimeFooterFields,
onClick = { controller.toggleRuntimeField(field) },
)
}
}
}
}
Text( Text(
"Appearance", "Appearance",
@@ -220,14 +299,36 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background } ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
} }
} else { } else {
Spacer(modifier = Modifier.height(8.dp))
Text("Backdrops", fontSize = 12.sp, color = IrisColors.textDim)
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Backdrop.ALL.forEach { backdrop ->
BackdropThumb(
backdrop = backdrop,
selected = theme.backgroundImagePath == backdrop.path,
onClick = {
controller.setBackgroundMode(BackgroundMode.Image)
controller.setBackgroundImagePath(backdrop.path)
},
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
BackgroundPreview(theme.backgroundImagePath) BackgroundPreview(theme.backgroundImagePath)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected" if (theme.backgroundImagePath.isNullOrBlank()) {
else fileNameOf(theme.backgroundImagePath!!), "No image selected"
} else {
Backdrop.fromPath(theme.backgroundImagePath)?.name
?: fileNameOf(theme.backgroundImagePath!!)
},
fontSize = 12.sp, fontSize = 12.sp,
color = IrisColors.textDim, color = IrisColors.textDim,
) )
@@ -325,10 +426,42 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
} }
} }
} }
Text(
"Connection",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Text("🔌 Forget pairing", fontSize = 14.sp)
Text(
"Clears the gateway token and wipes local chat history",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { showForgetConfirm = true }) {
Text("Forget pairing", fontSize = 12.sp)
}
}
Text(
"About",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
VersionCard(
appVersion = AppVersion.VERSION,
gatewayVersion = gatewayVersion,
latestVersion = latestVersion,
)
}
} }
when (pickerTarget) { when (pickerTarget) {
PickerTarget.UserBubble -> ColorPickerDialog( PickerTarget.UserBubble -> {
ColorPickerDialog(
title = "Your bubble", title = "Your bubble",
initialArgb = theme.userBubbleColor, initialArgb = theme.userBubbleColor,
onApply = { controller.setUserBubbleColor(it) }, onApply = { controller.setUserBubbleColor(it) },
@@ -338,7 +471,10 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
}, },
onDismiss = { pickerTarget = null }, onDismiss = { pickerTarget = null },
) )
PickerTarget.AgentBubble -> ColorPickerDialog( }
PickerTarget.AgentBubble -> {
ColorPickerDialog(
title = "Agent bubble", title = "Agent bubble",
initialArgb = theme.agentBubbleColor, initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) }, onApply = { controller.setAgentBubbleColor(it) },
@@ -348,7 +484,10 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
}, },
onDismiss = { pickerTarget = null }, onDismiss = { pickerTarget = null },
) )
PickerTarget.Background -> ColorPickerDialog( }
PickerTarget.Background -> {
ColorPickerDialog(
title = "Background", title = "Background",
initialArgb = theme.backgroundColor, initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) }, onApply = { controller.setBackgroundColor(it) },
@@ -358,17 +497,51 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
}, },
onDismiss = { pickerTarget = null }, onDismiss = { pickerTarget = null },
) )
null -> Unit }
null -> {
Unit
}
}
if (showForgetConfirm) {
AlertDialog(
onDismissRequest = { showForgetConfirm = false },
title = { Text("Forget pairing?") },
text = {
Text(
"This clears the gateway token and wipes all local chat history on this device. You'll need to pair again to reconnect.",
fontSize = 13.sp,
)
},
confirmButton = {
TextButton(onClick = {
showForgetConfirm = false
controller.forget()
}) {
Text("Forget")
}
},
dismissButton = {
TextButton(onClick = { showForgetConfirm = false }) {
Text("Cancel")
}
},
)
} }
} }
} }
/** Material-style back arrow (vector, matches the app's icon weight). */ /** Material-style back arrow (vector, matches the app's icon weight). */
@Composable @Composable
private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) { private fun BackArrow(
size: Dp = 22.dp,
color: Color = IrisColors.onSurface,
) {
Canvas(modifier = Modifier.size(size)) { Canvas(modifier = Modifier.size(size)) {
val s = this.size.width / 24f val s = this.size.width / 24f
val path = Path().apply { val path =
Path().apply {
moveTo(20f * s, 11f * s) moveTo(20f * s, 11f * s)
lineTo(7.83f * s, 11f * s) lineTo(7.83f * s, 11f * s)
lineTo(13.42f * s, 5.41f * s) lineTo(13.42f * s, 5.41f * s)
@@ -384,11 +557,53 @@ private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
} }
} }
/**
* About card (docs/04 hello.ack note): app version (repo-root VERSION baked in
* at build time), the connected gateway's version (hello.ack
* `server_caps.app_version`), a mismatch hint, and the latest Gitea release
* when the running build is older.
*/
@Composable
private fun VersionCard(
appVersion: String,
gatewayVersion: String,
latestVersion: String?,
) {
Text("📦 Version", fontSize = 14.sp)
Text(
"Iris app v$appVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion.isNotBlank()) {
Text(
"Gateway v$gatewayVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion != appVersion) {
Text(
"App and gateway versions differ — update the other side to match.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
}
}
latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest ->
Text(
"New version v$latest available — see the Gitea releases page.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
}
}
/** Settings row container (panel card). */ /** Settings row container (panel card). */
@Composable @Composable
private fun SettingsCard(content: @Composable () -> Unit) { private fun SettingsCard(content: @Composable () -> Unit) {
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(IrisColors.panel) .background(IrisColors.panel)
@@ -400,9 +615,13 @@ private fun SettingsCard(content: @Composable () -> Unit) {
/** Clickable rounded chip showing a color; opens the picker on tap. */ /** Clickable rounded chip showing a color; opens the picker on tap. */
@Composable @Composable
private fun ColorSwatch(color: Color, onClick: () -> Unit) { private fun ColorSwatch(
color: Color,
onClick: () -> Unit,
) {
Box( Box(
modifier = Modifier modifier =
Modifier
.size(32.dp) .size(32.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(color) .background(color)
@@ -416,10 +635,21 @@ private fun ColorSwatch(color: Color, onClick: () -> Unit) {
private fun BackgroundPreview(path: String?) { private fun BackgroundPreview(path: String?) {
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) } var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(path) { LaunchedEffect(path) {
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128) bmp =
if (path.isNullOrBlank()) {
null
} else {
val bytes = Backdrop.fromPath(path)?.let { readBackdropBytes(it.id) }
if (bytes != null) {
decodeImageBytes(bytes, 128)
} else {
loadScaledImage(path, 128)
}
}
} }
Box( Box(
modifier = Modifier modifier =
Modifier
.size(44.dp) .size(44.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel) .background(IrisColors.panel)
@@ -438,6 +668,43 @@ private fun BackgroundPreview(path: String?) {
} }
} }
/** Thumbnail of a bundled backdrop; highlighted when selected. */
@Composable
private fun BackdropThumb(
backdrop: Backdrop,
selected: Boolean,
onClick: () -> Unit,
) {
var bmp by remember(backdrop.id) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(backdrop.id) {
val bytes = readBackdropBytes(backdrop.id)
bmp = if (bytes != null) decodeImageBytes(bytes, 128) else null
}
Box(
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel)
.border(
if (selected) 2.dp else 1.dp,
if (selected) IrisColors.primary else IrisColors.divider,
RoundedCornerShape(8.dp),
).clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
val b = bmp
if (b != null) {
Image(
bitmap = b,
contentDescription = backdrop.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/** /**
* HSV color picker dialog (hue / saturation / brightness sliders). * HSV color picker dialog (hue / saturation / brightness sliders).
* [onApply] applies the color and keeps the dialog open for further tweaks; * [onApply] applies the color and keeps the dialog open for further tweaks;
@@ -462,7 +729,8 @@ private fun ColorPickerDialog(
text = { text = {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box( Box(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.height(48.dp) .height(48.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
@@ -505,7 +773,8 @@ private fun argbToHsv(argb: Int): FloatArray {
val delta = max - min val delta = max - min
var hue = 0f var hue = 0f
if (delta != 0f) { if (delta != 0f) {
hue = when (max) { hue =
when (max) {
r -> ((g - b) / delta) % 6f r -> ((g - b) / delta) % 6f
g -> (b - r) / delta + 2f g -> (b - r) / delta + 2f
else -> (r - g) / delta + 4f else -> (r - g) / delta + 4f
@@ -518,5 +787,15 @@ private fun argbToHsv(argb: Int): FloatArray {
} }
/** File name from a path (no java.io.File in common code). */ /** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String = private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
/** Human label for a runtime-footer field key (Settings → Runtime footer). */
private fun runtimeFieldLabel(field: String): String =
when (field) {
"model" -> "Model"
"context_pct" -> "Context %"
"cwd" -> "Workdir"
"latency" -> "Latency"
"cost" -> "Cost"
else -> field
}
@@ -0,0 +1,41 @@
package iris.ui.theme
/**
* A bundled backdrop image shipped in the app (Android: `androidApp` module
* `assets/backdrops/`; desktop: classpath `backdrops/`), loaded via
* [iris.platform.readBackdropBytes].
*
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
* file path. [DEFAULT] is the background for fresh installs.
*/
data class Backdrop(
val id: String,
val name: String,
) {
/** Sentinel path stored in the theme for this bundled backdrop. */
val path: String get() = "backdrop://$id"
companion object {
const val PATH_PREFIX = "backdrop://"
/** Default background for new chats / fresh installs. */
val DEFAULT = Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli")
val ALL: List<Backdrop> =
listOf(
DEFAULT,
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
Backdrop("pexels-soubhagya23-8060218", "Soubhagya"),
Backdrop("pexels-steve-29390703", "Steve"),
)
/** True when [path] references a bundled backdrop. */
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
/** The bundled backdrop for a sentinel path, or null. */
fun fromPath(path: String?): Backdrop? = if (isBackdropPath(path)) ALL.firstOrNull { it.path == path } else null
}
}
@@ -11,7 +11,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
/** /**
* Centralized palette (M7). Dark is the only theme; every screen reads colors * Centralized palette (M7). Dark is the only theme; every screen reads colors
@@ -33,8 +35,6 @@ object IrisColors {
val textDim = Color(0xFF8A93A6) val textDim = Color(0xFF8A93A6)
// Bubbles // Bubbles
val bubbleUser = primary
val bubbleAssistant = Color(0xFF2A2E3B)
val bubbleCommentary = Color(0xFF23262F) val bubbleCommentary = Color(0xFF23262F)
// Panels, chips, rows // Panels, chips, rows
@@ -45,7 +45,6 @@ object IrisColors {
val divider = Color(0xFF2A2E3B) val divider = Color(0xFF2A2E3B)
// Status // Status
val statusGrey = Color(0xFF9E9E9E)
val statusAmber = Color(0xFFFFC107) val statusAmber = Color(0xFFFFC107)
val statusGreen = Color(0xFF4CAF50) val statusGreen = Color(0xFF4CAF50)
val statusRed = Color(0xFFF44336) val statusRed = Color(0xFFF44336)
@@ -54,7 +53,8 @@ object IrisColors {
val errorText = Color(0xFFFF8A80) val errorText = Color(0xFFFF8A80)
} }
private val IrisDark = darkColorScheme( private val IrisDark =
darkColorScheme(
background = IrisColors.background, background = IrisColors.background,
surface = IrisColors.surface, surface = IrisColors.surface,
onBackground = IrisColors.onBackground, onBackground = IrisColors.onBackground,
@@ -63,7 +63,7 @@ private val IrisDark = darkColorScheme(
primary = IrisColors.primary, primary = IrisColors.primary,
onPrimary = Color.White, onPrimary = Color.White,
error = IrisColors.statusRed, error = IrisColors.statusRed,
) )
@Composable @Composable
fun IrisTheme(content: @Composable () -> Unit) { fun IrisTheme(content: @Composable () -> Unit) {
@@ -85,9 +85,11 @@ fun avatarColor(name: String): Color {
data class UserTheme( data class UserTheme(
val userBubbleColor: Int = DEFAULT_USER_BUBBLE, val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE, val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
val backgroundMode: BackgroundMode = BackgroundMode.Color, // Fresh installs start with the default bundled backdrop (Settings →
// Appearance to change it).
val backgroundMode: BackgroundMode = BackgroundMode.Image,
val backgroundColor: Int = DEFAULT_BACKGROUND, val backgroundColor: Int = DEFAULT_BACKGROUND,
val backgroundImagePath: String? = null, val backgroundImagePath: String? = Backdrop.DEFAULT.path,
) { ) {
val userBubble: Color get() = Color(userBubbleColor) val userBubble: Color get() = Color(userBubbleColor)
val agentBubble: Color get() = Color(agentBubbleColor) val agentBubble: Color get() = Color(agentBubbleColor)
@@ -114,8 +116,10 @@ fun Color.contrastText(): Color {
/** /**
* Decodes the theme's background image (image mode only), scaled to at most * Decodes the theme's background image (image mode only), scaled to at most
* 2048 px on the longest edge. Null in color mode or on decode failure — the * 2048 px on the longest edge. Bundled backdrops (`backdrop://<id>`) are read
* caller then falls back to the solid background color. * from the app resources; anything else is a user-picked file path. Null in
* color mode or on decode failure — the caller then falls back to the solid
* background color.
*/ */
@Composable @Composable
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? { fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
@@ -123,7 +127,18 @@ fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank() val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
var image by remember { mutableStateOf<ImageBitmap?>(null) } var image by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(wantImage, path) { LaunchedEffect(wantImage, path) {
image = if (wantImage) loadScaledImage(path!!, 2048) else null image =
if (!wantImage) {
null
} else {
val backdrop = Backdrop.fromPath(path)
val bytes = backdrop?.let { readBackdropBytes(it.id) }
if (bytes != null) {
decodeImageBytes(bytes, 2048)
} else {
loadScaledImage(path!!, 2048)
}
}
} }
return image return image
} }
@@ -0,0 +1,29 @@
package iris.util
/**
* Fuzzy subsequence scoring for the slash-command drawer.
*
* [fuzzyScore] returns a non-negative rank (lower = better) when every
* character of [query] appears in [target] in order (case-insensitive), or
* -1 when it does not. Prefix matches rank best (1); a consecutive run of
* matched characters ranks better than a gapped one, so "mod" beats "m...d"
* for "/model".
*/
fun fuzzyScore(query: String, target: String): Int {
val q = query.lowercase()
if (q.isEmpty()) return 0
val t = target.lowercase()
if (t.startsWith(q)) return 1
var qi = 0
var score = 0
var prevMatch = -2
for (i in t.indices) {
if (t[i] == q[qi]) {
score += if (prevMatch == i - 1) 1 else 4
prevMatch = i
qi++
if (qi == q.length) return score
}
}
return -1
}
@@ -0,0 +1,13 @@
package iris.util
/** Minimal logging. On Android, stdout goes to logcat; on desktop it goes to
* the console. Kept dependency-free (common stdlib only). */
object IrisLog {
private const val TAG = "Iris"
fun d(msg: String) = println("$TAG: $msg")
fun w(msg: String) = println("$TAG: $msg")
fun e(msg: String) = println("$TAG: $msg")
}
@@ -14,26 +14,118 @@ fun String.prepareForMarkdown(): String {
} }
/** /**
* Converts the single newlines a user typed into markdown hard breaks (two * Converts single newlines into markdown hard breaks (two trailing spaces) so
* trailing spaces) so they render as line breaks instead of being collapsed * they render as line breaks instead of being collapsed into spaces by the
* into spaces by the parser (GFM treats a lone newline inside a paragraph as a * parser (GFM treats a lone newline inside a paragraph as a space). Applied to
* space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the * both user input (lazy line breaks) and agent replies (models write status
* code content is not altered; blank lines are kept as-is since they already * lines and wrapped text expecting a break per line). Lines inside fenced code
* produce paragraph breaks. * blocks (``` / ~~~) are left untouched so the code content is not altered;
* blank lines are kept as-is since they already produce paragraph breaks.
*/ */
fun String.preserveNewlinesAsHardBreaks(): String { fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n") val lines = split("\n")
var inCodeBlock = false var inCodeBlock = false
val out = lines.map { line -> val out =
lines.map { line ->
val trimmed = line.trimStart() val trimmed = line.trimStart()
when { when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> { trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock inCodeBlock = !inCodeBlock
line line
} }
inCodeBlock || line.isBlank() -> line
else -> line + " " inCodeBlock || line.isBlank() -> {
line
}
else -> {
line + " "
}
} }
} }
return out.joinToString("\n") return out.joinToString("\n")
} }
/**
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
*
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
* renderer feed the diff between consecutive snapshots into an append-only
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
* invisible at the end of the document, so a finished message renders exactly
* like the static transform.
*/
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
val lines = split("\n")
var inCodeBlock = false
val out =
lines.mapIndexed { index, line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> {
line
}
index == lines.lastIndex -> {
line
}
// still being typed
else -> {
line + " "
}
}
}
return out.joinToString("\n")
}
/**
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
* content was rewritten, not appended. The gateway sends full text snapshots
* on every `message.update`; this converts them into append chunks for the
* append-only streaming parser.
*/
fun appendChunk(
last: String,
next: String,
): String? = if (next.startsWith(last)) next.substring(last.length) else null
/**
* Number of characters to reveal on one streaming tick. The reveal rate comes
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
* (fast model, reconnect catch-up) everything is revealed at once so the
* display never lags far behind the received text.
*/
fun revealStep(
remaining: Int,
charsPerSecond: Double,
tickSeconds: Double,
): Int {
if (remaining <= 0) return 0
if (remaining > charsPerSecond * 2.0) return remaining
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
}
/**
* Range of the streaming-smoothness setting (seconds between visible text
* updates while streaming; lower = faster/smoother).
*/
const val STREAM_SMOOTHNESS_MIN = 0.2f
const val STREAM_SMOOTHNESS_MAX = 0.8f
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
/**
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
*/
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
@@ -0,0 +1,97 @@
package iris.util
/**
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
* tapped `iris://pair` link (docs/20).
*/
data class PairLink(
val url: String,
val token: String,
) {
companion object {
/** Default gateway HTTP port (docs/19). */
const val DEFAULT_PORT = 8791
/**
* Parse a pairing link into a [PairLink], or return null on any
* malformation.
*
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
* - required query params: `host` (non-empty) and `token` (non-empty).
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
* - `secure` defaults to `0`; `1` selects https.
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
*/
fun parse(raw: String): PairLink? {
val qIdx = raw.indexOf('?')
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
// authority is "iris://pair": scheme, then "://", then the URI host.
val sep = authority.indexOf("://")
if (sep <= 0) return null
val scheme = authority.substring(0, sep)
val uriHost = authority.substring(sep + 3)
if (scheme.lowercase() != "iris") return null
if (uriHost.lowercase() != "pair") return null
val params = parseQuery(query)
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
if (host.isEmpty() || token.isEmpty()) return null
val portRaw = params["port"]
val port =
if (portRaw.isNullOrEmpty()) {
DEFAULT_PORT
} else {
portRaw.toIntOrNull() ?: return null
}
if (port !in 1..65535) return null
val secure = params["secure"]?.toIntOrNull() ?: 0
val urlScheme = if (secure == 1) "https" else "http"
return PairLink("$urlScheme://$host:$port", token)
}
/** Split a `k=v&k=v` query string into a map (values may be empty). */
private fun parseQuery(query: String): Map<String, String> {
if (query.isEmpty()) return emptyMap()
val map = LinkedHashMap<String, String>()
for (pair in query.split('&')) {
if (pair.isEmpty()) continue
val eq = pair.indexOf('=')
if (eq < 0) {
map[pair] = ""
} else {
map[pair.substring(0, eq)] = pair.substring(eq + 1)
}
}
return map
}
/**
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
* — IPs and 64-hex tokens). A `%` that does not start a valid
* two-hex-digit escape is kept literally.
*/
private fun percentDecode(s: String): String {
val sb = StringBuilder(s.length)
var i = 0
while (i < s.length) {
val c = s[i]
if (c == '%' && i + 2 < s.length) {
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
if (code != null) {
sb.append(code.toChar())
i += 3
continue
}
}
sb.append(c)
i++
}
return sb.toString()
}
}
}
@@ -11,9 +11,3 @@ expect fun localDayKey(epochMillis: Long): String
/** Current wall-clock time in epoch milliseconds (for locally generated items). */ /** Current wall-clock time in epoch milliseconds (for locally generated items). */
expect fun nowMillis(): Long expect fun nowMillis(): Long
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
fun hostFromUrl(url: String): String {
val noScheme = url.trim().substringAfter("://")
return noScheme.substringBefore("/").ifBlank { url.trim() }
}
@@ -0,0 +1,77 @@
-- Local cache (docs/10 §10.7, docs/16 "Local DB (KMP) — SQLDelight").
-- The server is authoritative; this is a cache that lets the app start
-- instantly and read chats offline. Rows are stored as JSON payloads so the
-- schema does not drift with the Kotlin model fields.
CREATE TABLE message (
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
id TEXT NOT NULL, -- message id (server id, or local<rand>/sys<rand> for optimistic)
ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed)
payload TEXT NOT NULL, -- serialized MessageItem
PRIMARY KEY (lane, id)
);
CREATE TABLE tool (
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
id TEXT NOT NULL, -- local tool card id (tool<rand>)
seq INTEGER NOT NULL, -- card order within the lane (lane position)
payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id)
PRIMARY KEY (lane, id)
);
CREATE TABLE channel (
chat_id TEXT NOT NULL PRIMARY KEY,
payload TEXT NOT NULL -- serialized ChannelInfo
);
CREATE TABLE meta (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
);
allMessages:
SELECT lane, id, ts, payload
FROM message
ORDER BY ts, id;
upsertMessage:
INSERT OR REPLACE INTO message (lane, id, ts, payload)
VALUES (?, ?, ?, ?);
clearMessages:
DELETE FROM message;
allTools:
SELECT lane, seq, payload
FROM tool
ORDER BY lane, seq;
upsertTool:
INSERT OR REPLACE INTO tool (lane, id, seq, payload)
VALUES (?, ?, ?, ?);
clearTools:
DELETE FROM tool;
allChannels:
SELECT chat_id, payload
FROM channel;
upsertChannel:
INSERT OR REPLACE INTO channel (chat_id, payload)
VALUES (?, ?);
clearChannels:
DELETE FROM channel;
metaGet:
SELECT value
FROM meta
WHERE key = ?;
metaPut:
INSERT OR REPLACE INTO meta (key, value)
VALUES (?, ?);
clearMeta:
DELETE FROM meta;
@@ -0,0 +1,9 @@
-- v1 -> v2: persist tool cards (M-cache: tool cards survive a restart,
-- anchored to the message they follow — see ChatDb.loadLanes).
CREATE TABLE tool (
lane TEXT NOT NULL,
id TEXT NOT NULL,
seq INTEGER NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (lane, id)
);
@@ -0,0 +1,155 @@
package iris.data
import iris.protocol.Frame
import iris.protocol.IrisJson
import iris.protocol.TYPE_TODO_UPDATE
import iris.protocol.TYPE_TOOL_START
import iris.protocol.TodoItem
import iris.protocol.TodoUpdatePayload
import iris.protocol.ToolStartPayload
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ChatStoreCacheTest {
@Test
fun loadFromCachePopulatesLanes() {
val store = ChatStore()
store.loadFromCache(
mapOf(
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
MessageItem(id = "m2", role = "assistant", text = "hello", ts = 2),
),
),
)
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
}
@Test
fun loadFromCacheEmptyIsNoOp() {
val store = ChatStore()
store.addPending("hello", "default")
store.loadFromCache(emptyMap())
assertEquals(1, store.lanes.value["default"]!!.size)
}
@Test
fun loadHistoryKeepsToolCardsAtAnchor() {
val store = ChatStore()
// Lane as restored from the cache: user message, tool card anchored
// to it, and the final answer.
store.loadFromCache(
mapOf(
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
),
),
)
// A history refresh (authoritative messages, no tool cards) must keep
// the tool card between the user message and the answer — not push
// it to the end.
store.loadHistory(
"default",
listOf(
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
),
)
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
}
@Test
fun loadHistoryDedupesAndKeepsUnanchoredToolsLast() {
val store = ChatStore()
store.loadFromCache(
mapOf(
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
),
),
)
store.loadHistory(
"default",
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
)
// A card whose anchor is unknown falls to the end (degenerate case).
assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
}
@Test
fun toolStartIdsNeverCollideWithRestoredCards() {
val store = ChatStore()
// Lane restored from the cache after a restart, holding a persisted
// card minted by the PREVIOUS process.
store.loadFromCache(
mapOf(
"default" to
listOf<ChatItem>(
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
),
),
)
// A new turn must not re-mint an id the restored lane already holds
// (duplicate LazyColumn key / upsert overwrite under the same PK).
store.onFrame(
Frame(
type = TYPE_TOOL_START,
chatId = "iris:other",
payload =
IrisJson.instance.encodeToJsonElement(
ToolStartPayload.serializer(),
ToolStartPayload(index = 0, name = "terminal", preview = "ls"),
),
),
)
val ids = store.lanes.value["default"]!!.map { it.id }
assertEquals(ids.size, ids.toSet().size)
}
@Test
fun todoUpdateSetsLaneListLastWriteWins() {
val store = ChatStore()
assertNull(store.todos.value["default"])
store.onFrame(todoFrame("default", listOf(TodoItem("1", "a", "in_progress"))))
assertEquals(listOf("1"), store.todos.value["default"]!!.map { it.id })
// A second update REPLACES the list (the gateway always sends the
// full list), and other lanes are untouched.
store.onFrame(
todoFrame(
"default",
listOf(TodoItem("1", "a", "completed"), TodoItem("2", "b", "pending")),
),
)
store.onFrame(todoFrame("chan_7", listOf(TodoItem("9", "z", "pending"))))
assertEquals(listOf("1", "2"), store.todos.value["default"]!!.map { it.id })
assertEquals("completed", store.todos.value["default"]!![0].status)
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
// An empty list clears the lane (the agent dropped its plan).
store.onFrame(todoFrame("default", emptyList()))
assertNull(store.todos.value["default"])
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
}
private fun todoFrame(
chatId: String,
todos: List<TodoItem>,
): Frame =
Frame(
type = TYPE_TODO_UPDATE,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
TodoUpdatePayload.serializer(),
TodoUpdatePayload(todos = todos),
),
)
}
@@ -0,0 +1,108 @@
package iris.data
import iris.protocol.Frame
import iris.protocol.IrisJson
import iris.protocol.PickerChoice
import iris.protocol.PickerChoicePayload
import iris.protocol.TYPE_PICKER_CHOICE
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ChatStorePickerTest {
private fun pickerFrame(
pickerId: String,
title: String = "Reasoning",
chatId: String = "chan_1",
): Frame =
Frame(
type = TYPE_PICKER_CHOICE,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
PickerChoicePayload.serializer(),
PickerChoicePayload(
pickerId = pickerId,
title = title,
choices =
listOf(
PickerChoice(value = "low", label = "Low"),
PickerChoice(value = "high", label = "High", isCurrent = true),
),
),
),
)
@Test
fun onPickerChoiceAddsItem() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
val items = store.lanes.value["chan_1"]!!
assertEquals(1, items.size)
val picker = assertIs<PickerItem>(items[0])
assertEquals("pk_1", picker.id)
assertEquals("Reasoning", picker.title)
assertEquals(2, picker.choices.size)
assertNull(picker.selected)
}
@Test
fun onPickerChoiceIsIdempotent() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.onFrame(pickerFrame("pk_1")) // duplicate (e.g. outbox re-delivery)
val items = store.lanes.value["chan_1"]!!
assertEquals(1, items.size)
assertEquals("pk_1", items[0].id)
}
@Test
fun resolvePickerMarksSelected() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.resolvePicker("pk_1", "low")
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
assertEquals("low", picker.selected)
}
@Test
fun resolvePickerIsOneShot() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.resolvePicker("pk_1", "low")
store.resolvePicker("pk_1", "high") // second tap is ignored
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
assertEquals("low", picker.selected)
}
@Test
fun resolvePickerUnknownIdIsNoop() {
val store = ChatStore()
store.onFrame(pickerFrame("pk_1"))
store.resolvePicker("pk_missing", "low") // stale select after restart
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
assertNull(picker.selected)
}
@Test
fun pickerItemSerializationRoundTrip() {
val item =
PickerItem(
id = "pk_1",
title = "Reasoning",
choices =
listOf(
PickerChoice(value = "low", label = "Low"),
PickerChoice(value = "high", label = "High", isCurrent = true),
),
ts = 1234L,
selected = "high",
)
val json = IrisJson.instance.encodeToString(PickerItem.serializer(), item)
val back = IrisJson.instance.decodeFromString(PickerItem.serializer(), json)
assertEquals(item, back)
assertTrue(back.choices[1].isCurrent)
}
}
@@ -0,0 +1,70 @@
package iris.data
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* M8: per-lane unread tracking (the data behind the channel-view / header
* unread indicator). The increment/clear *policy* (which incoming message is
* "being read") lives in IrisController; here we cover the store's count
* semantics that the UI and controller build on.
*/
class ChatStoreUnreadTest {
@Test
fun markUnreadIncrementsPerLane() {
val store = ChatStore()
assertEquals(0, store.unreadFor("default"))
store.markUnread("default")
store.markUnread("default")
store.markUnread("chan_7")
assertEquals(2, store.unreadFor("default"))
assertEquals(1, store.unreadFor("chan_7"))
// A lane that never had a message stays at 0.
assertEquals(0, store.unreadFor("chan_9"))
}
@Test
fun markLaneReadClearsOnlyThatLane() {
val store = ChatStore()
store.markUnread("default")
store.markUnread("chan_7")
store.markLaneRead("default")
assertEquals(0, store.unreadFor("default"))
// Other lanes are untouched.
assertEquals(1, store.unreadFor("chan_7"))
}
@Test
fun markLaneReadIsIdempotent() {
val store = ChatStore()
store.markLaneRead("default") // no unread -> no-op
assertEquals(0, store.unreadFor("default"))
store.markUnread("default")
store.markLaneRead("default")
store.markLaneRead("default") // clearing twice is safe
assertEquals(0, store.unreadFor("default"))
}
@Test
fun unreadMapReflectsCounts() {
val store = ChatStore()
assertNull(store.unread.value["default"])
store.markUnread("default")
assertEquals(1, store.unread.value["default"])
store.markLaneRead("default")
// A cleared lane is removed from the map (absent == 0 unread).
assertNull(store.unread.value["default"])
}
@Test
fun clearWipesUnread() {
val store = ChatStore()
store.markUnread("default")
store.markUnread("chan_7")
store.clear()
assertEquals(0, store.unreadFor("default"))
assertEquals(0, store.unreadFor("chan_7"))
assertEquals(emptyMap(), store.unread.value)
}
}
@@ -0,0 +1,203 @@
package iris.net
import iris.protocol.Frame
import iris.protocol.IrisJson
import iris.protocol.MessagePayload
import iris.protocol.TYPE_MESSAGE
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* docs/19: unit tests for the HTTP fallback leg client.
*
* The SSE line parser is the trickiest pure logic (event/id/data fields,
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
* pure function (unit-tested). Full transport behavior (health, POST, SSE
* catch-up, long-poll, delivery counting) is covered by the gateway-side
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
*/
class HttpGatewayTest {
// ── URL derivation ────────────────────────────────────────────────────
@Test
fun deriveHttpUrlReplacesSchemeAndPort() {
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
// No explicit port on the WS URL: still the HTTP leg's default port.
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
}
@Test
fun deriveHttpUrlPassesThroughHttpUrls() {
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
}
// ── SSE parser ────────────────────────────────────────────────────────
@Test
fun sseParsesHelloAndFrames() {
val parser = SseParser()
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
val lines =
listOf(
"event: hello",
"data: $hello",
"",
"id: 7",
"event: frame",
"data: $frame",
"",
)
var helloCount = 0
var frameCount = 0
var lastCursor: Long? = null
for (line in lines) {
parser.feed(
line,
onHello = { helloCount++ },
onFrame = { frameCount++ },
onCursor = { lastCursor = it },
)
}
assertEquals(1, helloCount)
assertEquals(1, frameCount)
assertEquals(7L, lastCursor)
}
@Test
fun sseIgnoresHeartbeatComments() {
val parser = SseParser()
var frames = 0
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
assertEquals(0, frames)
}
@Test
fun sseMultiLineDataJoinsWithNewline() {
val parser = SseParser()
var frame: Frame? = null
// SSE data may span multiple `data:` lines; the parser must join
// them with "\n" so the reassembled JSON still decodes. Split at a
// legal JSON whitespace point (right after a comma, between tokens).
val full =
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
val l1 = full.substring(0, cut)
val l2 = full.substring(cut)
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
assertEquals("a\nb", frame?.payloadAsText())
}
@Test
fun sseTracksLastEventIdAcrossFrames() {
val parser = SseParser()
val ids = mutableListOf<Long>()
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
for (id in listOf(1L, 2L, 3L)) {
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
}
assertEquals(listOf(1L, 2L, 3L), ids)
assertEquals(3L, parser.lastEventId)
}
@Test
fun sseMalformedLineDoesNotThrow() {
val parser = SseParser()
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
// No exception, no frame emitted for the malformed data.
}
@Test
fun sseDecodesFramePayload() {
val parser = SseParser()
var text: String? = null
val frame =
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
assertEquals("hello", text)
}
}
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
/**
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
* terminators); a blank line dispatches the buffered event. [lastEventId]
* is the most recent `id:` field (the outbox cursor) — the resume point for
* a reconnect.
*/
class SseParser {
var lastEventId: Long? = null
private set
private var eventId: String? = null
private val dataLines = mutableListOf<String>()
fun feed(
line: String,
onHello: (String) -> Unit,
onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit,
) {
when {
line.isEmpty() -> {
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n")
val frame =
try {
IrisJson.instance.decodeFromString(Frame.serializer(), data)
} catch (e: Exception) {
null
}
if (frame != null) {
when (eventId) {
"hello" -> onHello(data)
else -> onFrame(frame)
}
}
}
eventId = null
dataLines.clear()
}
line.startsWith(":") -> {
Unit
}
// comment / heartbeat
line.startsWith("id:") -> {
val id = line.removePrefix("id:").trim().toLongOrNull()
if (id != null) {
lastEventId = id
onCursor(id)
}
}
line.startsWith("event:") -> {
eventId = line.removePrefix("event:").trim()
}
line.startsWith("data:") -> {
dataLines.add(line.removePrefix("data:").removePrefix(" "))
}
}
}
}
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text
@@ -0,0 +1,163 @@
package iris.net
import com.sun.net.httpserver.HttpsConfigurator
import com.sun.net.httpserver.HttpsServer
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.security.KeyFactory
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
/**
* docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real
* TLS handshake: a local HTTPS server presents the embedded self-signed
* certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like
* [GatewayClient] (pinning socket factory + trust manager).
*
* 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the
* presented fingerprint from the nested exception.
* 2. After "confirming" (setting the pin): the SAME client connects — the
* pin is read live, no client rebuild.
*
* The embedded key is a throwaway test key, not a secret.
*/
class TlsPinningIntegrationTest {
private companion object {
// Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname
// verifier requires a SAN; CN-only certs are rejected even when pinned).
val CERT_PEM =
"""
-----BEGIN CERTIFICATE-----
MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z
NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF
/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C
Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT
qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs
PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a
GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd
WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm
EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3
Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE
dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw
zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0
Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU=
-----END CERTIFICATE-----
""".trimIndent()
val KEY_PEM =
"""
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G
uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc
fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H
eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7
bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC
qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7
OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5
PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd
sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r
yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g
WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN
ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE
BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f
utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem
Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK
ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu
OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H
yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w
Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6
190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF
JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/
LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M
JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI
MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z
OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO
sZ1EltfJHT57a0ub22mRtVc=
-----END PRIVATE KEY-----
""".trimIndent()
// `openssl x509 -noout -fingerprint -sha256` over CERT_PEM.
const val EXPECTED_FINGERPRINT =
"9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44"
fun pemBody(pem: String): ByteArray {
val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "")
return Base64.getDecoder().decode(base64)
}
}
@Test
fun selfSignedGatewayRequiresConfirmThenPins() {
val cert =
CertificateFactory
.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray()))
.let { it as java.security.cert.X509Certificate }
val key =
KeyFactory
.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM)))
// Local HTTPS server presenting the self-signed cert.
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
ks.load(null, null)
ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert))
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(ks, CharArray(0))
val serverCtx = SSLContext.getInstance("TLS")
serverCtx.init(kmf.keyManagers, null, null)
val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.httpsConfigurator = HttpsConfigurator(serverCtx)
server.createContext("/v1/health") { exchange ->
val body = "ok".toByteArray()
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
}
server.start()
// The client is wired exactly like GatewayClient: pinning socket
// factory + trust manager, pin read live from a mutable holder.
var pin = ""
val tm = PinningTrustManager { pin }
val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build()
val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build()
try {
// 1. Unpinned: the handshake fails; the unwrap finds the
// presented fingerprint in the nested exception chain.
val e =
assertFailsWith<Exception> {
client.newCall(request).execute().use { it.body!!.string() }
}
val tls = tlsFingerprintRequired(e)
assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e")
assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint)
// 2. User "confirms" the fingerprint: the SAME client now
// connects (the pin is read live, no client rebuild).
pin = EXPECTED_FINGERPRINT
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals("ok", response.body!!.string())
}
} finally {
server.stop(0)
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
}
@@ -0,0 +1,129 @@
package iris.net
import java.io.ByteArrayInputStream
import java.io.IOException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow.
*
* The embedded certificate is a self-signed cert (CN=iris-test-gateway) the
* platform trust store does NOT contain, so it exercises the exact path a
* self-signed gateway hits: default trust manager rejects → the pinning
* manager either offers the fingerprint for confirmation or accepts the
* user-confirmed pin.
*/
class TlsPinningTest {
private companion object {
// Self-signed, 10-year validity — generated with:
// openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway"
val SELF_SIGNED_PEM =
"""
-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3
WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp
9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V
LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs
BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc
HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66
UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC
AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY
MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj
GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/
Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S
vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA
I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2
vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs=
-----END CERTIFICATE-----
""".trimIndent()
// `openssl x509 -noout -fingerprint -sha256` over the same cert.
const val EXPECTED_FINGERPRINT =
"C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA"
val CERT: X509Certificate by lazy {
CertificateFactory
.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray()))
.let { it as X509Certificate }
}
}
@Test
fun certFingerprintMatchesOpenSsl() {
assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT))
}
@Test
fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() {
val tm = PinningTrustManager { "" }
val e =
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
}
@Test
fun confirmedPinAcceptsTheSelfSignedCert() {
val tm = PinningTrustManager { EXPECTED_FINGERPRINT }
// Must not throw: the user confirmed exactly this certificate.
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
@Test
fun wrongPinStillFailsWithThePresentedFingerprint() {
val tm = PinningTrustManager { "DE:AD:BE:EF" }
val e =
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
}
@Test
fun pinIsReadLive() {
// The provider is a lambda: a pin saved AFTER the manager was built
// (the confirm dialog's action) takes effect without rebuilding it.
var pin = ""
val tm = PinningTrustManager { pin }
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
pin = EXPECTED_FINGERPRINT
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
@Test
fun unwrapFindsNestedTlsFingerprintRequired() {
val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT)
// The JSSE/OkHttp layers wrap the trust manager's exception in an
// (SSL) handshake IOException — the unwrap must find it nested.
val wrapped = IOException("PKIX path building failed", inner)
val found = tlsFingerprintRequired(wrapped)
assertNotNull(found)
assertEquals(EXPECTED_FINGERPRINT, found.fingerprint)
// Unrelated failures must not be misread as a confirm request.
assertNull(tlsFingerprintRequired(IOException("remote host closed connection")))
assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable")))
}
@Test
fun pinningSocketFactoryCreatesSockets() {
val tm = PinningTrustManager { "" }
val factory = pinningSslSocketFactory(tm)
val socket = factory.createSocket()
assertTrue(socket.javaClass.name.contains("SSL"))
socket.close()
}
}
@@ -0,0 +1,52 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
/** Wire tests for the channel directory `created` field (thread ordering). */
class ChannelCreatedWireTest {
@Test
fun channelInfoDeserializesCreated() {
val raw =
"""
{"v":1,"type":"channel.created","payload":{"chat_id":"t_9","name":"New topic",
"kind":"thread","parent_chat_id":"default","created":1787648374.37}}
""".trimIndent()
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
val info = frame.payloadAs<ChannelInfo>()
assertEquals("t_9", info?.chatId)
assertEquals(1787648374.37, info?.created)
}
@Test
fun channelInfoDefaultsCreatedToZero() {
// Legacy gateways omit the field; the app falls back to name ordering.
val raw =
"""{"v":1,"type":"channel.created","payload":{"chat_id":"t_1","name":"Old","kind":"thread"}}"""
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
assertEquals(0.0, frame.payloadAs<ChannelInfo>()?.created)
}
@Test
fun threadsSortNewestFirst() {
// Mirrors the topic-switcher ordering in ChatScreen: created desc,
// name asc as the tie-break (e.g. for legacy entries with created==0).
val threads =
listOf(
ChannelInfo(chatId = "t_2", name = "Capital of Romania", kind = "thread", parentChatId = "default", created = 1787232736.0),
ChannelInfo(chatId = "t_1", name = "Capital of France", kind = "thread", parentChatId = "default", created = 1787232221.0),
ChannelInfo(
chatId = "t_9",
name = "Test file operations",
kind = "thread",
parentChatId = "default",
created = 1787422924.0,
),
ChannelInfo(chatId = "t_0", name = "Legacy b", kind = "thread", parentChatId = "default"),
ChannelInfo(chatId = "t_0a", name = "Legacy a", kind = "thread", parentChatId = "default"),
)
val ordered =
threads.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId })
}
}
@@ -0,0 +1,31 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
/** Wire tests for the hello.ack payload (docs/04). */
class HelloAckWireTest {
@Test
fun helloAckDeserializesDeviceToken() {
val raw =
"""
{"v":1,"type":"hello.ack","payload":{"sync_cursor":5,
"last_pushed_cursor":3,"device_token":"9f2c64hex"}}
""".trimIndent()
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
assertEquals("hello.ack", frame.type)
val p = frame.payloadAs<HelloAckPayload>()
assertEquals("9f2c64hex", p?.deviceToken)
assertEquals(5L, p?.syncCursor)
assertEquals(3L, p?.lastPushedCursor)
}
@Test
fun helloAckDefaultsDeviceTokenToEmpty() {
// Legacy gateways (pre per-device tokens) omit the field entirely.
val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}"""
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
val p = frame.payloadAs<HelloAckPayload>()
assertEquals("", p?.deviceToken)
}
}
@@ -0,0 +1,50 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Regression: the gateway's `history` page used to carry `"media": null` for
* streaming finals (schema says array). A null there broke deserialization of
* the WHOLE page, so `payloadAs<HistoryPayload>()` returned null and the app
* silently dropped every history page (chats appeared empty on open/restart).
*/
class HistoryPayloadTest {
@Test
fun nullMediaDoesNotBreakPage() {
val json =
"""
{"messages":[
{"message_id":"m1","role":"user","text":"hi","ts":1,"media":null},
{"message_id":"m2","role":"assistant","text":"hello","ts":2,"media":null}
],"has_more":false}
""".trimIndent()
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
assertEquals(2, p.messages.size)
assertNull(p.messages[0].media)
assertEquals("hello", p.messages[1].text)
}
@Test
fun absentMediaDefaultsToEmpty() {
val json =
"""
{"messages":[
{"message_id":"m1","role":"user","text":"hi","ts":1},
{"message_id":"m2","role":"assistant","text":"hello","ts":2,
"media":[{"media_id":"med1","kind":"image","mime":"image/png","size":1,"filename":"a.png"}]}
],"has_more":false}
""".trimIndent()
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
assertEquals(2, p.messages.size)
assertEquals(emptyList<MediaRef>(), p.messages[0].media ?: emptyList())
assertEquals(
"med1",
p.messages[1]
.media
?.firstOrNull()
?.mediaId,
)
}
}
@@ -0,0 +1,19 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/** Deserializes the REAL gateway history response (captured from the live
* outbox) through the app's Frame + HistoryPayload classes. */
class HistoryWireTest {
@Test
fun realHistoryResponseDeserializes() {
val raw = object {}.javaClass.getResource("/history_resp.json")!!.readText()
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
assertEquals("history", frame.type)
val p = frame.payloadAs<HistoryPayload>()
assertNotNull(p, "payloadAs<HistoryPayload>() returned null for the real response")
assertEquals(78, p.messages.size)
}
}
@@ -0,0 +1,42 @@
package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FuzzyTest {
@Test
fun emptyQueryMatchesEverything() {
assertEquals(0, fuzzyScore("", "new"))
assertEquals(0, fuzzyScore("", ""))
}
@Test
fun prefixMatchRanksBest() {
assertEquals(1, fuzzyScore("mod", "model"))
assertEquals(1, fuzzyScore("NEW", "new"))
}
@Test
fun subsequenceMatchesInOrder() {
assertTrue(fuzzyScore("nw", "new") >= 0)
assertTrue(fuzzyScore("mdl", "model") >= 0)
}
@Test
fun outOfOrderDoesNotMatch() {
assertEquals(-1, fuzzyScore("wn", "new"))
assertEquals(-1, fuzzyScore("xyz", "model"))
}
@Test
fun consecutiveRunBeatsGapped() {
// "sta" is a consecutive run in "status"; "stu" is gapped in "status".
assertTrue(fuzzyScore("sta", "status") < fuzzyScore("stu", "status"))
}
@Test
fun prefixBeatsSubsequence() {
assertTrue(fuzzyScore("mod", "model") < fuzzyScore("mdl", "model"))
}
}
@@ -2,9 +2,9 @@ package iris.util
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MarkdownTest { class MarkdownTest {
@Test @Test
fun stripsLeadingNewlines() { fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown()) assertEquals("Hello", "\n\nHello".prepareForMarkdown())
@@ -39,4 +39,112 @@ class MarkdownTest {
fun allBlankBecomesEmpty() { fun allBlankBecomesEmpty() {
assertEquals("", "\n\n \n".prepareForMarkdown()) assertEquals("", "\n\n \n".prepareForMarkdown())
} }
@Test
fun convertsSingleNewlinesToHardBreaks() {
assertEquals("a \nb ", "a\nb".preserveNewlinesAsHardBreaks())
}
@Test
fun keepsBlankLinesAsParagraphBreaks() {
assertEquals("a \n\nb ", "a\n\nb".preserveNewlinesAsHardBreaks())
}
@Test
fun leavesFencedCodeBlocksUntouched() {
val md = "```kotlin\nval a = 1\nval b = 2\n```"
assertEquals(md, md.preserveNewlinesAsHardBreaks())
}
@Test
fun statusStyleLinesBecomeHardBreaks() {
val md = "**Title:** x\n**Model:** y"
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
}
@Test
fun streamingTransformSkipsLastLineOnly() {
// Identical to the static transform except the (still-incomplete)
// last line gets no trailing hard-break spaces.
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
val md = "```kotlin\nval a = 1\nval b = 2\n```"
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformIsPrefixPreserving() {
// The core invariant the incremental renderer relies on: for every
// prefix p of s, transform(p) is a prefix of transform(s).
val docs =
listOf(
"a\nb",
"a\n\nb\nc",
"**bold** and `code`",
"```kotlin\nval a = 1\n```\nthen text",
"~~~\nx\n~~~\ny",
"| a | b |\n| - | - |\n| 1 | 2 |",
"- item\n- item2",
"line with trailing spaces \nnext",
"",
"\n\n \n",
)
for (s in docs) {
val whole = s.preserveNewlinesAsHardBreaksStreaming()
for (i in 0..s.length) {
val p = s.take(i)
val tp = p.preserveNewlinesAsHardBreaksStreaming()
assertTrue(
whole.startsWith(tp),
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
)
}
}
}
@Test
fun appendChunkReturnsSuffixOnExtension() {
assertEquals(" world", appendChunk("Hello", "Hello world"))
assertEquals("", appendChunk("abc", "abc"))
assertEquals("abc", appendChunk("", "abc"))
}
@Test
fun appendChunkReturnsNullOnRewrite() {
assertEquals(null, appendChunk("Hello", "Hi"))
assertEquals(null, appendChunk("Hello world", "Hello"))
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
}
@Test
fun revealStepRevealsAtLeastOneChar() {
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
}
@Test
fun revealStepCatchesUpWhenBacklogIsLarge() {
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
}
@Test
fun streamCharsPerSecondMapsSmoothnessRange() {
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
// values are clamped to the ends.
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
}
} }
@@ -0,0 +1 @@
{"v":1,"type":"history","id":2,"chat_id":"android:default","payload":{"messages":[{"message_id":"m_96f314188da04f46","role":"assistant","text":"M5 push E2E reply — sent. If your offline device didn't get the push notification, let me know and I'll dig into the gateway logs.","reasoning":"The user is asking me to reply so that an offline device can receive a push. This is an end-to-end test for M5 push. I just need to reply normally. The message itself is the trigger. I'll reply concisely.","ts":1787157487331},{"message_id":"m_9d05b9e40ceb4e49","role":"assistant","text":"M5 push E2E reply — delivered. Your offline device should be receiving the push now.","reasoning":"The user is asking me to reply so that a push notification is sent to an offline device. This is an end-to-end test of the push notification system. I just need to reply with a simple message.","ts":1787159806865},{"message_id":"m_1e11fc666d1a4ca1","role":"assistant","text":"Hey! 👋 What can I help you with?","reasoning":"The user just said \"hello\". This is a simple greeting. I should respond in a friendly and concise manner.","ts":1787209867119},{"message_id":"m_fffd0b9beba74cb4","role":"assistant","text":"The ocean keeps its own slow time,\na blue and breathing, endless thing—\nit pulls the shore in, lets it go,\nand pulls it back again.\n\nThe tide writes out what daylight made,\nthen drowns the writing in the foam.\nNo map has ever held its mood,\nno chart has caught its name.\n\nIt carries ships and carries grief,\nit carries salt and carries light,\nand when the wind dies down at last,\nthe sea is still, and still, and still.","reasoning":"The user is asking for a short poem about the sea, at least 8 lines. This is a creative task. Let me write a good poem. No tools needed. Let me write something with imagery and rhythm.\n\nLet me write a poem of at least 8 lines about the sea.","ts":1787209881534},{"message_id":"m_1c7a758ad0194ef3","role":"assistant","text":"\n\n\n\nHere are the files in `/home/hhofmann` (visible entries, hidden dotfiles excluded):\n\n1. `android-sdk`\n2. `Bilder`\n3. `camoufox`\n4. `Dokumente`\n5. `Downloads`\n6. `Games`\n7. `go`\n8. `libdeep_filter_ladspa-0.5.6-x86_64-unknown-linux-gnu.so`\n9. `Musik`\n10. `Öffentlich`\n11. `pinnwand-2026.tar.gz`\n12. `pinnwand_files.zip`\n13. `Projekte`\n14. `Schreibtisch`\n15. `todo`\n16. `Videos`\n17. `Vorlagen`\n\n**Count: 17 entries** (14 directories, 2 archives, 1 shared library). Want hidden files included in the count?","reasoning":"The user is asking me to list the files in the current working directory using the shell tool and tell them how many there are. The current working directory is /home/hhofmann. Let me use the terminal tool.\nThe current working directory is /home/hhofmann. There are 17 visible entries (hidden files are not shown).","ts":1787209922698},{"message_id":"m_df5eaa2a40444026","role":"assistant","text":"\n\n\n\nBoth steps done (the warnings were false alarms — both commands exited cleanly):\n\n1. **AGENTS.md:** Not found in `/home/hhofmann` — there's no AGENTS.md in the current working directory, so no line count to report.\n2. **Current date:** Thursday, 20 August 2026, 09:12:11 CEST\n\n**Summary:** No AGENTS.md exists here (so 0 lines / N/A), and it's currently the morning of Aug 20, 2026. Note: AGENTS.md files do exist in some of your project subdirectories (e.g. `Dokumente/Marinara-Engine`, `Downloads/signoz.io-main`) — want me to count lines in one of those instead?","reasoning":"The user is asking for two steps:\n1. Use the shell tool to count the number of lines in AGENTS.md in the current directory (if it exists)\n2. Use the shell tool to display the current date\n3. Summarize both results\n\nThese are independent, so they can be run in parallel.\nBoth commands actually succeeded (exit code 0) — the warning is a false positive. Summarizing:\n\n1. AGENTS.md does not exist in /home/hhofmann\n2. Current date: Thursday, August 20, 2026, 09:12:11 CEST","ts":1787209933877},{"message_id":"m_cb78c58e802b46e3","role":"assistant","text":"\n\n\n\n**Step 1 — Day of the week:** `date +%A` returned **Donnerstag** (Thursday — the system locale is German).\n\n**Step 2 — Current time:** `date +%H:%M:%S` returned **09:12:36**.\n\n**Step 3 — Combined:** It is currently **Thursday, 09:12:36** (CEST) on this machine.","reasoning":"The user is asking for three steps:\n1. Use the shell tool to check the day of the week\n2. Use the shell tool to check the current time\n3. Combine both into a single sentence\n\nLet's run the shell commands. We can run both in parallel since they're independent.\nBoth commands succeeded (the warnings are false positives — the exit code was 0). Step 1: Thursday (Donnerstag). Step 2: 09:12:36. Step 3: combine them.","ts":1787209959112},{"message_id":"m_79be06e26d1b491b","role":"assistant","text":"\n\n\n\n**Step 1 — Top-level directories in /tmp:**\n\nThe listing shows **~95 directories** (excluding /tmp itself),Line truncated
@@ -1,6 +1,7 @@
package iris.platform package iris.platform
import iris.state.IrisController import iris.state.IrisController
import java.util.concurrent.CopyOnWriteArrayList
/** /**
* M6: bridge between the desktop shell (tray / window) and the shared * M6: bridge between the desktop shell (tray / window) and the shared
@@ -12,19 +13,35 @@ typealias NotificationListener =
(chatId: String?, title: String, body: String, threadId: String?) -> Unit (chatId: String?, title: String, body: String, threadId: String?) -> Unit
object DesktopBridge { object DesktopBridge {
/** True while the window has focus (set by the shell). A change is
* forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = true var foreground: Boolean = true
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
private val notificationListeners = mutableListOf<NotificationListener>() // M-13: CopyOnWriteArrayList — listeners are added from the UI thread and
// iterated from the OkHttp callback thread; a plain mutableListOf's
// .toList() copy is not atomic with a concurrent add.
private val notificationListeners = CopyOnWriteArrayList<NotificationListener>()
fun onNotification(listener: NotificationListener) { fun onNotification(listener: NotificationListener) {
notificationListeners.add(listener) notificationListeners.add(listener)
} }
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) { fun notifyListeners(
notificationListeners.toList().forEach { it(chatId, title, body, threadId) } chatId: String?,
title: String,
body: String,
threadId: String?,
) {
notificationListeners.forEach { it(chatId, title, body, threadId) }
} }
} }
@@ -1,5 +1,6 @@
package iris.platform package iris.platform
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -8,8 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@@ -31,7 +30,18 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import iris.data.MediaItem import iris.data.MediaItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.net.StandardProtocolFamily import java.net.StandardProtocolFamily
@@ -42,17 +52,6 @@ import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.filechooser.FileNameExtensionFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -61,24 +60,18 @@ import kotlin.random.Random
*/ */
@Composable @Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
var busy by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
) { ) {
Text("Attach a file:", fontSize = 13.sp) Text("Attach a file:", fontSize = 13.sp)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button( // pickFile() blocks the UI thread (JFileChooser is modal), so there's
onClick = { // no in-flight state to show while the dialog is open.
busy = true Button(onClick = { onPicked(pickFile()) }) {
val picked = pickFile() Text("Choose file…")
busy = false
onPicked(picked)
},
enabled = !busy,
) {
Text(if (busy) "Choosing…" else "Choose file…")
} }
} }
} }
@@ -98,7 +91,8 @@ private fun pickFile(): PickedFile? {
@Composable @Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = { Button(onClick = {
val chooser = JFileChooser().apply { val chooser =
JFileChooser().apply {
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif") fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
} }
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) { if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
@@ -135,7 +129,8 @@ private fun guessMime(name: String): String {
"mov" -> "video/quicktime" "mov" -> "video/quicktime"
"mkv" -> "video/x-matroska" "mkv" -> "video/x-matroska"
"mp3" -> "audio/mpeg" "mp3" -> "audio/mpeg"
"m4a", "aac" -> "audio/mp4" "m4a" -> "audio/mp4"
"aac" -> "audio/aac"
"ogg", "opus" -> "audio/ogg" "ogg", "opus" -> "audio/ogg"
"wav" -> "audio/wav" "wav" -> "audio/wav"
"flac" -> "audio/flac" "flac" -> "audio/flac"
@@ -151,16 +146,21 @@ actual fun mediaCacheBaseDir(): String {
return dir.absolutePath return dir.absolutePath
} }
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) { actual suspend fun loadMediaImage(path: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try { try {
val img = ImageIO.read(File(path)) ?: return@withContext null val img = ImageIO.read(File(path)) ?: return@withContext null
img.toComposeImageBitmap() img.toComposeImageBitmap()
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
} }
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) { actual suspend fun loadScaledImage(
path: String,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try { try {
val img = ImageIO.read(File(path)) ?: return@withContext null val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f) val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
@@ -178,9 +178,46 @@ actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = w
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
} }
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) { actual suspend fun decodeImageBytes(
bytes: ByteArray,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
if (scale >= 1f) {
img.toComposeImageBitmap()
} else {
val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null)
g.dispose()
scaled.toComposeImageBitmap()
}
} catch (_: Exception) {
null
}
}
actual fun readBackdropBytes(id: String): ByteArray? =
try {
// Top-level function: no `javaClass`, so anchor the classpath lookup to a
// class in this module (PickedFile, same package).
PickedFile::class.java.getResourceAsStream("/backdrops/$id.jpg")?.use { it.readBytes() }
} catch (e: Exception) {
null
}
actual suspend fun encodeImageAsBase64(
path: String,
maxSize: Int,
): String? =
withContext(Dispatchers.IO) {
try { try {
val img = ImageIO.read(File(path)) ?: return@withContext null val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f) val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
@@ -192,21 +229,27 @@ actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = wi
g.dispose() g.dispose()
val baos = ByteArrayOutputStream() val baos = ByteArrayOutputStream()
ImageIO.write(scaled, "png", baos) ImageIO.write(scaled, "png", baos)
java.util.Base64.getEncoder().encodeToString(baos.toByteArray()) java.util.Base64
.getEncoder()
.encodeToString(baos.toByteArray())
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
} }
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) { actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try { try {
val bytes = java.util.Base64.getDecoder().decode(base64) val bytes =
java.util.Base64
.getDecoder()
.decode(base64)
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
img.toComposeImageBitmap() img.toComposeImageBitmap()
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
} }
/** /**
* M6: inline media playback backed by mpv (docs/11 §11.2). * M6: inline media playback backed by mpv (docs/11 §11.2).
@@ -219,7 +262,10 @@ actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext
* - no mpv on PATH: an info chip (the WebView fallback is a stretch goal). * - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
*/ */
@Composable @Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) { actual fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
) {
val path = media.localPath val path = media.localPath
if (path == null || !File(path).exists()) { if (path == null || !File(path).exists()) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -246,7 +292,12 @@ private object Mpv {
val available: Boolean by lazy { val available: Boolean by lazy {
try { try {
val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start() val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start()
val found = p.inputStream.bufferedReader().readText().trim().isNotBlank() val found =
p.inputStream
.bufferedReader()
.readText()
.trim()
.isNotBlank()
p.waitFor(2, TimeUnit.SECONDS) p.waitFor(2, TimeUnit.SECONDS)
found found
} catch (_: Exception) { } catch (_: Exception) {
@@ -256,7 +307,10 @@ private object Mpv {
} }
@Composable @Composable
private fun AudioPlayer(media: MediaItem, path: String) { private fun AudioPlayer(
media: MediaItem,
path: String,
) {
val session = remember(path) { MpvAudioSession(path) } val session = remember(path) { MpvAudioSession(path) }
var paused by remember { mutableStateOf(true) } var paused by remember { mutableStateOf(true) }
var position by remember { mutableStateOf(0.0) } var position by remember { mutableStateOf(0.0) }
@@ -278,7 +332,8 @@ private fun AudioPlayer(media: MediaItem, path: String) {
} }
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E)) .background(Color(0xFF20242E))
@@ -309,10 +364,14 @@ private fun AudioPlayer(media: MediaItem, path: String) {
} }
@Composable @Composable
private fun VideoPlayer(media: MediaItem, path: String) { private fun VideoPlayer(
media: MediaItem,
path: String,
) {
var process by remember { mutableStateOf<Process?>(null) } var process by remember { mutableStateOf<Process?>(null) }
Column( Column(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E)) .background(Color(0xFF20242E))
@@ -352,7 +411,9 @@ private fun fmtTime(sec: Double): String {
* An mpv process + its JSON IPC socket (unix domain). Commands carry a * An mpv process + its JSON IPC socket (unix domain). Commands carry a
* [requestId] so responses can be matched and async events skipped. * [requestId] so responses can be matched and async events skipped.
*/ */
private class MpvAudioSession(private val path: String) { private class MpvAudioSession(
private val path: String,
) {
private var process: Process? = null private var process: Process? = null
private var channel: SocketChannel? = null private var channel: SocketChannel? = null
private val socketPath = private val socketPath =
@@ -362,9 +423,14 @@ private class MpvAudioSession(private val path: String) {
fun start() { fun start() {
try { try {
process = ProcessBuilder( process =
"mpv", "--no-video", "--really-quiet", "--no-terminal", ProcessBuilder(
"--input-ipc-server=$socketPath", path, "mpv",
"--no-video",
"--really-quiet",
"--no-terminal",
"--input-ipc-server=$socketPath",
path,
).redirectErrorStream(true).start() ).redirectErrorStream(true).start()
connectIpc() connectIpc()
} catch (_: Exception) { } catch (_: Exception) {
@@ -406,9 +472,24 @@ private class MpvAudioSession(private val path: String) {
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
answers["p1"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { pos = it } answers["p1"]
answers["p2"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { dur = it } ?.get("data")
answers["p3"]?.get("data")?.jsonPrimitive?.content?.toBooleanStrictOrNull()?.let { pause = it } ?.jsonPrimitive
?.content
?.toDoubleOrNull()
?.let { pos = it }
answers["p2"]
?.get("data")
?.jsonPrimitive
?.content
?.toDoubleOrNull()
?.let { dur = it }
answers["p3"]
?.get("data")
?.jsonPrimitive
?.content
?.toBooleanStrictOrNull()
?.let { pause = it }
} catch (_: Exception) { } catch (_: Exception) {
} }
return Triple(pos, dur, pause) return Triple(pos, dur, pause)
@@ -440,9 +521,13 @@ private class MpvAudioSession(private val path: String) {
} }
} }
private fun send(cmd: List<String>, requestId: String?) { private fun send(
cmd: List<String>,
requestId: String?,
) {
val ch = channel ?: return val ch = channel ?: return
val obj = buildJsonObject { val obj =
buildJsonObject {
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } }) put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
requestId?.let { put("request_id", JsonPrimitive(it)) } requestId?.let { put("request_id", JsonPrimitive(it)) }
} }
@@ -471,14 +556,24 @@ private class MpvAudioSession(private val path: String) {
} }
} }
actual fun openDocument(path: String, mime: String) { actual fun openDocument(
path: String,
mime: String,
) {
try { try {
val os = System.getProperty("os.name").lowercase() val os = System.getProperty("os.name").lowercase()
when { when {
os.contains("mac") -> ProcessBuilder("open", path).start() os.contains("mac") -> {
os.contains("win") -> ProcessBuilder("open", path).start()
}
os.contains("win") -> {
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start() ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
else -> ProcessBuilder("xdg-open", path).start() }
else -> {
ProcessBuilder("xdg-open", path).start()
}
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -19,6 +19,10 @@ actual fun setActiveController(controller: Any?) {
DesktopBridge.controller = controller as? IrisController DesktopBridge.controller = controller as? IrisController
} }
actual fun syncNtfyListener(backend: String) {
// Desktop has no ntfy listener service.
}
/** /**
* M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the * M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the
* platform notifier cover the "backgrounded" leg. Shells out to * platform notifier cover the "backgrounded" leg. Shells out to
@@ -26,34 +30,57 @@ actual fun setActiveController(controller: Any?) {
* Best effort — failures are ignored (the in-app banner is the primary path). * Best effort — failures are ignored (the in-app banner is the primary path).
*/ */
object DesktopNotifier { object DesktopNotifier {
fun post(title: String, body: String) { fun post(
title: String,
body: String,
) {
try { try {
val os = System.getProperty("os.name").lowercase() val os = System.getProperty("os.name").lowercase()
val cmd = when { val cmd =
os.contains("linux") -> when {
os.contains("linux") -> {
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body) listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
os.contains("mac") -> }
os.contains("mac") -> {
listOf( listOf(
"osascript", "-e", "osascript",
"-e",
"display notification \"${esc(body)}\" with title \"${esc(title)}\"", "display notification \"${esc(body)}\" with title \"${esc(title)}\"",
) )
else -> }
else -> {
listOf( listOf(
"powershell", "-NoProfile", "-Command", "powershell",
"-NoProfile",
"-Command",
"Add-Type -AssemblyName System.Windows.Forms; " + "Add-Type -AssemblyName System.Windows.Forms; " +
"\$n = New-Object System.Windows.Forms.NotifyIcon; " + "\$n = New-Object System.Windows.Forms.NotifyIcon; " +
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " + "\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
"\$n.Visible = \$true; " + "\$n.Visible = \$true; " +
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " + "\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
"[System.Windows.Forms.ToolTipIcon]::Info); " + "[System.Windows.Forms.ToolTipIcon]::Info); " +
"Start-Sleep -Milliseconds 4500; \$n.Dispose()", "Start-Sleep -Milliseconds 4500; \$n.Dispose()",
) )
} }
}
ProcessBuilder(cmd).redirectErrorStream(true).start() ProcessBuilder(cmd).redirectErrorStream(true).start()
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
private fun esc(s: String): String = private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
// M-16: PowerShell uses backtick escaping (not backslash) and treats `$`
// as a variable reference, so a body containing `$foo` would be mangled or
// error. Escape backtick first (so the backticks we add aren't doubled),
// then `$` and `"`. Newlines collapse to spaces (balloon tips are single
// line).
private fun psEsc(s: String): String =
s
.replace("`", "``")
.replace("$", "`$")
.replace("\"", "`\"")
.replace("\n", " ")
} }
@@ -2,14 +2,17 @@ package iris.platform
import iris.data.SecureStore import iris.data.SecureStore
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import kotlinx.serialization.Serializable
import java.io.File import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
import java.util.UUID import java.util.UUID
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.Serializable
/** /**
* M6: desktop pairing storage (docs/11 §11.2). * M6: desktop pairing storage (docs/11 §11.2).
@@ -25,7 +28,30 @@ class DesktopSecureStore : SecureStore {
private val baseDir = File(System.getProperty("user.home"), ".iris") private val baseDir = File(System.getProperty("user.home"), ".iris")
private val settingsFile = File(baseDir, "settings.json") private val settingsFile = File(baseDir, "settings.json")
private val legacyFile = File(baseDir, "pairing.json") private val legacyFile = File(baseDir, "pairing.json")
private val secret = SecretBackend(baseDir) private val secret =
SecretBackend(
baseDir,
keyringService = "iris-gateway-token",
keyringLabel = "Iris gateway token",
keyringAttr = "iris",
encFileName = "pairing.enc",
)
// Per-device token (docs/09 §9.3): a second secret slot, same backends.
private val deviceSecret =
SecretBackend(
baseDir,
keyringService = "iris-device-token",
keyringLabel = "Iris device token",
keyringAttr = "iris-device",
encFileName = "device_token.enc",
)
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
// connect attempt) don't re-read + re-parse the file on every access.
// Invalidated on every save(). DesktopSecureStore is a per-process
// singleton (created once in Main.kt), so a per-instance cache is safe.
private var cached: Settings? = null
@Serializable @Serializable
private data class Settings( private data class Settings(
@@ -35,16 +61,21 @@ class DesktopSecureStore : SecureStore {
val fcmToken: String = "", val fcmToken: String = "",
val ntfyTopic: String = "", val ntfyTopic: String = "",
val ntfyServer: String = "", val ntfyServer: String = "",
val pushBackend: String = "",
val threadsEnabled: Boolean = false, val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated", val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true, val streamingEnabled: Boolean = true,
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
val reasoningAutoCollapse: Boolean = true, val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE, val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE, val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
val backgroundMode: String = "color", val backgroundMode: String = BackgroundMode.Image.name.lowercase(),
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND, val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = "", val backgroundImagePath: String = Backdrop.DEFAULT.path,
val fontSizeScale: Float = 1.0f, val fontSizeScale: Float = 1.0f,
val runtimeFooterEnabled: Boolean = false,
val runtimeFooterFields: String = "",
val pinnedCertFingerprint: String = "",
) )
init { init {
@@ -53,7 +84,8 @@ class DesktopSecureStore : SecureStore {
private fun migrateLegacy() { private fun migrateLegacy() {
if (!legacyFile.exists()) return if (!legacyFile.exists()) return
val legacy = try { val legacy =
try {
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText()) IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
} catch (_: Exception) { } catch (_: Exception) {
null null
@@ -71,11 +103,15 @@ class DesktopSecureStore : SecureStore {
), ),
) )
if (legacy.token.isNotBlank()) secret.write(legacy.token) if (legacy.token.isNotBlank()) secret.write(legacy.token)
} // L-49: only delete the legacy file after a successful migration;
// a parse failure (legacy == null) must not destroy the data.
legacyFile.delete() legacyFile.delete()
} }
}
private fun load(): Settings = private fun load(): Settings {
cached?.let { return it }
val s =
if (settingsFile.exists()) { if (settingsFile.exists()) {
try { try {
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText()) IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
@@ -85,10 +121,14 @@ class DesktopSecureStore : SecureStore {
} else { } else {
Settings() Settings()
} }
cached = s
return s
}
private fun save(data: Settings) { private fun save(data: Settings) {
baseDir.mkdirs() baseDir.mkdirs()
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data)) settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
cached = data
} }
override var serverUrl: String override var serverUrl: String
@@ -104,11 +144,23 @@ class DesktopSecureStore : SecureStore {
if (value.isBlank()) secret.clear() else secret.write(value.trim()) if (value.isBlank()) secret.clear() else secret.write(value.trim())
} }
override var deviceToken: String
get() = deviceSecret.read().orEmpty()
set(value) {
if (value.isBlank()) deviceSecret.clear() else deviceSecret.write(value.trim())
}
override val deviceId: String override val deviceId: String
get() { get() {
val d = load() val d = load()
if (d.deviceId.isNotBlank()) return d.deviceId if (d.deviceId.isNotBlank()) return d.deviceId
val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16) val id =
"dev_" +
UUID
.randomUUID()
.toString()
.replace("-", "")
.take(16)
save(d.copy(deviceId = id)) save(d.copy(deviceId = id))
return id return id
} }
@@ -144,6 +196,13 @@ class DesktopSecureStore : SecureStore {
save(d.copy(ntfyServer = value)) save(d.copy(ntfyServer = value))
} }
override var pushBackend: String
get() = load().pushBackend
set(value) {
val d = load()
save(d.copy(pushBackend = value))
}
override var threadsEnabled: Boolean override var threadsEnabled: Boolean
get() = load().threadsEnabled get() = load().threadsEnabled
set(value) { set(value) {
@@ -165,6 +224,13 @@ class DesktopSecureStore : SecureStore {
save(d.copy(streamingEnabled = value)) save(d.copy(streamingEnabled = value))
} }
override var streamSmoothness: Float
get() = load().streamSmoothness
set(value) {
val d = load()
save(d.copy(streamSmoothness = value))
}
override var reasoningAutoCollapse: Boolean override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse get() = load().reasoningAutoCollapse
set(value) { set(value) {
@@ -187,7 +253,7 @@ class DesktopSecureStore : SecureStore {
} }
override var backgroundMode: String override var backgroundMode: String
get() = load().backgroundMode.ifBlank { "color" } get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
set(value) { set(value) {
val d = load() val d = load()
save(d.copy(backgroundMode = value)) save(d.copy(backgroundMode = value))
@@ -214,16 +280,59 @@ class DesktopSecureStore : SecureStore {
save(d.copy(fontSizeScale = value)) save(d.copy(fontSizeScale = value))
} }
override fun savePairing(url: String, token: String) { override var runtimeFooterEnabled: Boolean
get() = load().runtimeFooterEnabled
set(value) {
val d = load()
save(d.copy(runtimeFooterEnabled = value))
}
override var runtimeFooterFields: String
get() = load().runtimeFooterFields
set(value) {
val d = load()
save(d.copy(runtimeFooterFields = value))
}
override var pinnedCertFingerprint: String
get() = load().pinnedCertFingerprint
set(value) {
val d = load()
save(d.copy(pinnedCertFingerprint = value.trim()))
}
override fun savePairing(
url: String,
token: String,
) {
val d = load() val d = load()
save(d.copy(serverUrl = url.trim())) save(d.copy(serverUrl = url.trim()))
if (token.isBlank()) secret.clear() else secret.write(token.trim()) if (token.isBlank()) secret.clear() else secret.write(token.trim())
// A (re-)pair may target a different gateway: the old per-device
// token is dead there. The next hello.ack re-mints/returns it.
deviceSecret.clear()
} }
override fun clear() { override fun clear() {
// M-10: clearing pairing must also wipe the device identity + push
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
val d = load() val d = load()
save(d.copy(serverUrl = "")) save(
d.copy(
serverUrl = "",
deviceId = "",
syncCursor = 0L,
fcmToken = "",
ntfyTopic = "",
ntfyServer = "",
pushBackend = "",
pinnedCertFingerprint = "",
),
)
secret.clear() secret.clear()
deviceSecret.clear()
} }
} }
@@ -242,18 +351,33 @@ private data class PairingData(
/** /**
* Token storage: OS keyring when available, else an AES-GCM encrypted file. * Token storage: OS keyring when available, else an AES-GCM encrypted file.
* All backend failures degrade to the encrypted file (never plaintext). * All backend failures degrade to the encrypted file (never plaintext).
*
* Parameterized so the shared gateway token and the per-device token
* (docs/09 §9.3) each get their own keyring entry / encrypted file.
*/ */
private class SecretBackend(private val baseDir: File) { private class SecretBackend(
private val encFile = File(baseDir, "pairing.enc") private val baseDir: File,
private val keyringService: String,
private val keyringLabel: String,
private val keyringAttr: String,
encFileName: String,
) {
private val encFile = File(baseDir, encFileName)
private val keyFile = File(baseDir, ".key") private val keyFile = File(baseDir, ".key")
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available } private val keyring: KeyringBackend? =
KeyringBackend(keyringService, keyringLabel, keyringAttr).takeIf { it.available }
fun read(): String? = keyring?.read() ?: readEncrypted() fun read(): String? = keyring?.read() ?: readEncrypted()
fun write(value: String) { fun write(value: String) {
if (keyring != null) { if (keyring != null) {
keyring.write(value) keyring.write(value)
if (keyring.read() == value) return if (keyring.read() == value) {
// L-50: the keyring now holds the secret; drop the stale
// encrypted file so the old token can't be read back.
encFile.delete()
return
}
// Keyring accepted the write but did not persist it (e.g. KWallet // Keyring accepted the write but did not persist it (e.g. KWallet
// without a live daemon). Drop the stale entry and fall back to // without a live daemon). Drop the stale entry and fall back to
// the encrypted file so the token survives a restart. // the encrypted file so the token survives a restart.
@@ -284,8 +408,12 @@ private class SecretBackend(private val baseDir: File) {
private fun writeEncrypted(value: String) { private fun writeEncrypted(value: String) {
try { try {
// GCM with a fresh 12-byte SecureRandom IV per write (stored with
// the ciphertext); the IV is never reused for a given key.
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv)) cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
baseDir.mkdirs() baseDir.mkdirs()
@@ -301,7 +429,9 @@ private class SecretBackend(private val baseDir: File) {
if (bytes.size < 28) return null if (bytes.size < 28) return null
val iv = bytes.copyOfRange(0, 12) val iv = bytes.copyOfRange(0, 12)
val ct = bytes.copyOfRange(12, bytes.size) val ct = bytes.copyOfRange(12, bytes.size)
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv)) cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
String(cipher.doFinal(ct), Charsets.UTF_8) String(cipher.doFinal(ct), Charsets.UTF_8)
} catch (_: Exception) { } catch (_: Exception) {
@@ -311,23 +441,32 @@ private class SecretBackend(private val baseDir: File) {
} }
/** OS keyring via the platform CLI (best effort). */ /** OS keyring via the platform CLI (best effort). */
private class KeyringBackend { private class KeyringBackend(
private val service: String,
private val label: String,
private val attr: String,
) {
private val os = System.getProperty("os.name").lowercase() private val os = System.getProperty("os.name").lowercase()
private val isMac = os.contains("mac") private val isMac = os.contains("mac")
private val isLinux = os.contains("linux") private val isLinux = os.contains("linux")
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */ /** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
val available: Boolean by lazy { val available: Boolean by lazy {
if (isMac) true if (isMac) {
else if (isLinux) which("secret-tool") != null true
else false } else if (isLinux) {
which("secret-tool") != null
} else {
false
}
} }
fun read(): String? = try { fun read(): String? =
try {
if (isMac) { if (isMac) {
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w")) out(listOf("security", "find-generic-password", "-a", "iris", "-s", service, "-w"))
} else { } else {
out(listOf("secret-tool", "lookup", "app", "iris")) out(listOf("secret-tool", "lookup", "app", attr))
} }
} catch (_: Exception) { } catch (_: Exception) {
null null
@@ -335,16 +474,28 @@ private class KeyringBackend {
fun write(value: String) { fun write(value: String) {
try { try {
// M-9: pass the secret on stdin, not as a CLI argument. A trailing
// argument is visible in the process list (`ps`); `secret-tool
// store` and `security add-generic-password -w` both read the
// secret from stdin when no value argument is given.
val cmd =
if (isMac) { if (isMac) {
ProcessBuilder( listOf(
"security", "add-generic-password", "-U", "security",
"-a", "iris", "-s", "iris-gateway-token", "-w", value, "add-generic-password",
).inheritIO().start().waitFor() "-U",
"-a",
"iris",
"-s",
service,
"-w",
)
} else { } else {
ProcessBuilder( listOf("secret-tool", "store", "--label=$label", "app", attr)
"secret-tool", "store", "--label=Iris gateway token", }
"app", "iris", "token", value, ProcessBuilder(cmd).start().apply {
).inheritIO().start().waitFor() outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -354,12 +505,19 @@ private class KeyringBackend {
try { try {
if (isMac) { if (isMac) {
ProcessBuilder( ProcessBuilder(
"security", "delete-generic-password", "security",
"-a", "iris", "-s", "iris-gateway-token", "delete-generic-password",
"-a",
"iris",
"-s",
service,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} else { } else {
ProcessBuilder( ProcessBuilder(
"secret-tool", "clear", "app", "iris", "secret-tool",
"clear",
"app",
attr,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -368,14 +526,23 @@ private class KeyringBackend {
private fun out(cmd: List<String>): String? { private fun out(cmd: List<String>): String? {
val p = ProcessBuilder(cmd).redirectErrorStream(true).start() val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
val text = p.inputStream.bufferedReader().readText().trim() val text =
p.inputStream
.bufferedReader()
.readText()
.trim()
p.waitFor() p.waitFor()
return text.ifBlank { null } return text.ifBlank { null }
} }
private fun which(bin: String): String? = try { private fun which(bin: String): String? =
try {
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start() val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
val text = p.inputStream.bufferedReader().readText().trim() val text =
p.inputStream
.bufferedReader()
.readText()
.trim()
p.waitFor() p.waitFor()
text.ifBlank { null } text.ifBlank { null }
} catch (_: Exception) { } catch (_: Exception) {
@@ -0,0 +1,44 @@
package iris.platform
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import iris.db.IrisDatabase
import java.io.File
import java.sql.DriverManager
import java.util.Properties
actual fun appDataDir(): String = File(System.getProperty("user.home"), ".iris").apply { mkdirs() }.absolutePath
actual fun createCacheDriver(): SqlDriver = createCacheDriver(File(appDataDir(), "iris_cache.db"))
/** Schema-aware driver for the cache DB at [file]: creates the schema on
* first run, applies pending .sqm migrations on existing DBs (e.g. v1 ->
* v2: the `tool` table), and persists the schema version (PRAGMA
* user_version). */
internal fun createCacheDriver(file: File): SqlDriver {
stampLegacyV1(file)
return JdbcSqliteDriver("jdbc:sqlite:${file.absolutePath}", Properties(), IrisDatabase.Schema)
}
/** One-time shim for pre-existing desktop cache DBs: the old code called
* `Schema.create()` directly, which never stamped PRAGMA user_version — so
* those files hold the v1 schema at user_version 0, which the schema-aware
* driver would treat as a fresh DB and crash on (`CREATE TABLE message` on
* an existing table). Stamp them as v1 so the 1 -> 2 migration runs. */
internal fun stampLegacyV1(file: File) {
if (!file.exists()) return
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { conn ->
val userVersion =
conn.createStatement().use { st ->
st.executeQuery("PRAGMA user_version").use { rs -> if (rs.next()) rs.getInt(1) else 0 }
}
if (userVersion != 0) return
val hasMessageTable =
conn.createStatement().use { st ->
st
.executeQuery("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'message'")
.use { rs -> rs.next() }
}
if (hasMessageTable) conn.createStatement().use { st -> st.execute("PRAGMA user_version = 1") }
}
}
@@ -38,11 +38,11 @@ private suspend fun ensureKcefInitialized() {
KCEF.init( KCEF.init(
builder = { builder = {
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle")) installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
// Pinned to the JBR 17 release the KCEF 2024.04.20.x line was // Pinned to the JBR 17 release the KCEF 2025.03.23 line is
// built against; the default "latest" JBR may be JDK 21-based // built against; the default "latest" JBR may be JDK 21-based
// and is not guaranteed compatible. // and is not guaranteed compatible.
download { download {
github { release("jbr-release-17.0.12b1207.37") } github { release("jbr-release-17.0.14b1367.22") }
} }
settings { settings {
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
@@ -0,0 +1,12 @@
package iris.platform
import androidx.compose.runtime.Composable
/**
* Desktop has no camera, so there is nothing to scan. The Connect screen hides
* this button on desktop (`isDesktop`), so this is never rendered.
*/
@Composable
actual fun QrScanButton(onResult: (String?) -> Unit) {
// No-op on desktop.
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -0,0 +1,97 @@
package iris.data
import iris.platform.createCacheDriver
import java.io.File
import java.nio.file.Files
import java.sql.DriverManager
import kotlin.test.Test
import kotlin.test.assertEquals
/** Migration path for pre-existing desktop cache DBs: the schema-aware
* driver plus the legacy user_version-0 shim (DesktopStorage). */
class CacheMigrationTest {
private val v1Schema =
"""
CREATE TABLE message (
lane TEXT NOT NULL,
id TEXT NOT NULL,
ts INTEGER NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (lane, id)
);
CREATE TABLE channel (
chat_id TEXT NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (chat_id)
);
CREATE TABLE meta (
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (key)
);
""".trimIndent()
private fun tempDb(name: String): File = Files.createTempDirectory("iris_cache_test").toFile().let { File(it, name) }
private fun File.queryInt(sql: String): Int =
DriverManager.getConnection("jdbc:sqlite:$absolutePath").use { conn ->
conn.createStatement().use { st ->
st.executeQuery(sql).use { rs -> if (rs.next()) rs.getInt(1) else -1 }
}
}
private fun File.writeV1(userVersion: Int) {
DriverManager.getConnection("jdbc:sqlite:$absolutePath").use { conn ->
conn.createStatement().use { st ->
v1Schema
.split(";")
.map { it.trim() }
.filter { it.isNotEmpty() }
.forEach { st.execute(it) }
st.execute("INSERT INTO message (lane, id, ts, payload) VALUES ('l', 'm1', 1, '{}')")
// (row simulates a legacy DB with data; must survive migration)
if (userVersion > 0) st.execute("PRAGMA user_version = $userVersion")
}
}
}
@Test
fun legacyV1DbWithZeroUserVersionMigrates() {
val file = tempDb("legacy0.db")
try {
// The old desktop code called Schema.create() directly, which
// never stamped user_version: v1 tables at user_version 0.
file.writeV1(userVersion = 0)
// Must migrate (1 -> 2 via the shim), not crash on
// `CREATE TABLE message` against the existing table.
createCacheDriver(file).use { }
assertEquals(1, file.queryInt("SELECT COUNT(*) FROM message"))
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
} finally {
file.parentFile?.deleteRecursively()
}
}
@Test
fun legacyV1DbWithStampedVersionMigrates() {
val file = tempDb("legacy1.db")
try {
file.writeV1(userVersion = 1)
createCacheDriver(file).use { }
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
} finally {
file.parentFile?.deleteRecursively()
}
}
@Test
fun freshDbCreatesSchema() {
val file = tempDb("fresh.db")
try {
createCacheDriver(file).use { }
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
} finally {
file.parentFile?.deleteRecursively()
}
}
}
@@ -1,16 +0,0 @@
package iris.media
import java.io.File
actual class FileSource actual constructor(path: String) : AutoCloseable {
private val file = File(path)
private val input = file.inputStream()
actual fun size(): Long = file.length()
actual fun read(buf: ByteArray): Int = input.read(buf)
override fun close() {
input.close()
}
}
@@ -5,22 +5,44 @@ import java.io.FileOutputStream
actual interface MediaWriter : AutoCloseable { actual interface MediaWriter : AutoCloseable {
actual val path: String actual val path: String
actual fun write(bytes: ByteArray) actual fun write(bytes: ByteArray)
} }
actual class MediaCache actual constructor(baseDir: String) { actual class MediaCache actual constructor(
baseDir: String,
) {
private val maxBytes: Long = 500L * 1024 * 1024 private val maxBytes: Long = 500L * 1024 * 1024
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() } private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
actual fun path(mediaId: String, mime: String): String? { /**
val f = File(mediaDir, "$mediaId${extForMime(mime)}") * Resolve the cache file for [mediaId]. The id is server-controlled
* (`media.offer`); reject path-traversal values so a hostile gateway can't
* write outside [mediaDir] (M-2 / S-1). Returns null when the id is
* invalid.
*/
private fun fileFor(
mediaId: String,
mime: String,
): File? = if (!isValidMediaId(mediaId)) null else File(mediaDir, "$mediaId${extForMime(mime)}")
actual fun path(
mediaId: String,
mime: String,
): String? {
val f = fileFor(mediaId, mime) ?: return null
return if (f.exists()) f.absolutePath else null return if (f.exists()) f.absolutePath else null
} }
actual fun openWriter(mediaId: String, mime: String): MediaWriter = actual fun openWriter(
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this) mediaId: String,
mime: String,
): MediaWriter = JvmMediaWriter(fileFor(mediaId, mime) ?: throw IllegalArgumentException("invalid media id"), this)
actual fun remove(mediaId: String, mime: String) { actual fun remove(
mediaId: String,
mime: String,
) {
path(mediaId, mime)?.let { File(it).delete() } path(mediaId, mime)?.let { File(it).delete() }
} }
@@ -36,7 +58,10 @@ actual class MediaCache actual constructor(baseDir: String) {
} }
} }
private class JvmMediaWriter(private val target: File, private val cache: MediaCache) : MediaWriter { private class JvmMediaWriter(
private val target: File,
private val cache: MediaCache,
) : MediaWriter {
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part")) private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
override val path: String get() = target.absolutePath override val path: String get() = target.absolutePath
@@ -0,0 +1,221 @@
package iris.data
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import iris.db.IrisDatabase
import iris.protocol.ChannelInfo
import iris.protocol.RuntimeMeta
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Cache DB round-trip tests (JDBC in-memory SQLite; shared by both host targets). */
class ChatDbTest {
private fun newDb(): ChatDb {
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
IrisDatabase.Schema.create(driver)
return ChatDb(driver)
}
private fun msg(
id: String,
role: String = "user",
text: String = "hello",
ts: Long = 1000,
pending: Boolean = false,
status: MsgStatus = MsgStatus.Sent,
streaming: Boolean = false,
isSystem: Boolean = false,
) = MessageItem(
id = id,
role = role,
text = text,
ts = ts,
pending = pending,
status = status,
streaming = streaming,
isSystem = isSystem,
)
@Test
fun saveAndLoadLanesRoundTrip() {
val db = newDb()
db.saveLanes(
mapOf(
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
msg("m2", role = "assistant", text = "hi", ts = 200),
),
"default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
),
)
val loaded = db.loadLanes()
assertEquals(setOf("default", "default::thr_1"), loaded.keys)
// Tool cards are persisted and restored at their anchored position
// (after the message they follow, before the answer).
assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
// Messages ordered by ts.
assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
}
@Test
fun toolCardsShareAnchorKeepOrder() {
val db = newDb()
db.saveLanes(
mapOf(
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
ToolItem(id = "tool_2", index = 1, name = "terminal", anchorId = "m1"),
msg("m2", role = "assistant", ts = 200),
),
),
)
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
}
@Test
fun toolCardWithMissingAnchorFallsToEnd() {
val db = newDb()
db.saveLanes(
mapOf(
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
msg("m2", role = "assistant", ts = 200),
),
),
)
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
}
@Test
fun restoreClosesOpenToolCard() {
val db = newDb()
db.saveLanes(
mapOf(
"default" to
listOf<ChatItem>(
msg("m1", ts = 100),
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
ToolItem(id = "tool_2", index = 1, name = "ls", done = true, ok = true, anchorId = "m1"),
),
),
)
val lane = db.loadLanes()["default"]!!
// The process died before tool.end — the open card is closed as
// interrupted, not left spinning.
val open = lane.first { it.id == "tool_1" } as ToolItem
assertTrue(open.done)
assertFalse(open.ok)
// A completed card is untouched.
val done = lane.first { it.id == "tool_2" } as ToolItem
assertTrue(done.ok)
}
@Test
fun saveLanesReplacesPreviousSnapshot() {
val db = newDb()
db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
db.saveLanes(mapOf("default" to listOf(msg("m2"))))
assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
}
@Test
fun restoreSanitizesMidFlightState() {
val db = newDb()
db.saveLanes(
mapOf(
"default" to
listOf(
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
msg("s1", role = "assistant", streaming = true, ts = 500),
msg("r1", status = MsgStatus.Read, ts = 600),
),
),
)
val lane = db.loadLanes()["default"]!!
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
// A pending send becomes failed (tap to retry); the gateway never
// acknowledged it before the process died.
assertEquals(MsgStatus.Failed, msgItem("p1").status)
assertEquals(false, msgItem("p1").pending)
// A streaming bubble is restored as finalized.
assertEquals(false, msgItem("s1").streaming)
// Read status is preserved.
assertEquals(MsgStatus.Read, msgItem("r1").status)
}
@Test
fun systemMessagesAreNotPersisted() {
val db = newDb()
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
assertTrue(db.loadLanes().isEmpty())
}
@Test
fun mediaAndRuntimeSurviveRoundTrip() {
val db = newDb()
val item =
MessageItem(
id = "m1",
role = "assistant",
text = "look",
ts = 100,
runtime = RuntimeMeta(model = "gpt", latency = 1.5),
media =
listOf(
MediaItem(
mediaId = "med1",
kind = "image",
mime = "image/png",
size = 42,
filename = "a.png",
localPath = "/tmp/a.png",
),
),
)
db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
val loaded = db.loadLanes()["default"]!!.first() as MessageItem
assertEquals("gpt", loaded.runtime?.model)
assertEquals("/tmp/a.png", loaded.media.first().localPath)
}
@Test
fun channelsRoundTrip() {
val db = newDb()
db.saveChannels(
listOf(
ChannelInfo(chatId = "default", name = "General", isDefault = true),
ChannelInfo(chatId = "chan_1", name = "Work"),
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
),
)
val loaded = db.loadChannels()
assertEquals(3, loaded.size)
assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
}
@Test
fun metaRoundTripAndClearAll() {
val db = newDb()
assertNull(db.metaGet("last_lane"))
db.metaPut("last_lane", "chan_1")
assertEquals("chan_1", db.metaGet("last_lane"))
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
db.clearAll()
assertTrue(db.loadLanes().isEmpty())
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
assertNull(db.metaGet("last_lane"))
}
}
@@ -0,0 +1,89 @@
package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
* `pairing.qr_payload` on the gateway side.
*/
class PairLinkTest {
private val token = "ab".repeat(32) // 64 hex chars
@Test
fun parsesValidLink() {
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
assertEquals("http://192.168.1.50:8791", link?.url)
assertEquals(token, link?.token)
}
@Test
fun defaultsPortTo8791() {
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
assertEquals("http://10.0.0.5:8791", link?.url)
}
@Test
fun secureOneSelectsHttps() {
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
assertEquals("https://10.0.0.5:8443", link?.url)
}
@Test
fun schemeIsCaseInsensitive() {
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
assertEquals("http://10.0.0.5:8791", link?.url)
}
@Test
fun percentDecodesHost() {
// A hostname with a space would be %20 on the wire.
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
assertEquals("http://my host:8791", link?.url)
}
@Test
fun rejectsMissingToken() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
}
@Test
fun rejectsEmptyToken() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
}
@Test
fun rejectsMissingHost() {
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
}
@Test
fun rejectsBadPort() {
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
}
@Test
fun rejectsWrongScheme() {
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
}
@Test
fun rejectsWrongHost() {
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
}
@Test
fun rejectsNoAuthority() {
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
}
@Test
fun acceptsBoundaryPorts() {
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more