17 Commits
Author SHA1 Message Date
ARIA d717c10256 README: rewrite for users — what it is, feature list, build-from-source (gateway/Android/desktop/pairing), contributing, Apache-2.0 license; add LICENSE file 2026-08-21 01:17:23 +02:00
ARIA 2f0cdbba17 HTML artifacts (Android): enable DOM storage + pin a real base URL so localStorage/sessionStorage work — the library defaults domStorageEnabled off and a null base URL leaves the page on the opaque about:blank origin where the storage APIs throw, so interactive artifacts that persist state appeared broken; basic JS (DOM/event handlers) already ran 2026-08-21 01:05:31 +02:00
ARIA 0951ebbbce android gateway: raise MAX_MESSAGE_LENGTH to 1M so long replies (and complete HTML artifacts) aren't chunked across fence-reopened messages — the stream consumer defaults to 4096 when the adapter sets no cap, and WS has no message-size limit 2026-08-21 00:43:42 +02:00
ARIA a8c4418aff HTML artifacts: render agent-sent HTML/CSS/JS code blocks in an in-app WebView (Android platform WebView, desktop JCEF/KCEF) via a full-screen preview; artifact card with Preview button + code toggle, shown only once the message stops streaming; webview-multiplatform 1.9.40 + KCEF deps, detection + unit tests 2026-08-21 00:43:39 +02:00
ARIA 8100730cba User bubbles: move timestamp + delivery checkmarks to their own bottom-right line (Telegram look, matching agent bubbles) instead of inline at the end of the text 2026-08-21 00:01:13 +02:00
ARIA bff363d579 Chat bubbles: render markdown (bold/italic/tables/syntax-highlighted code) for agent + user messages; tap-to-copy inline code with flash, code-block copy button; preserve user newlines; removed stale HANDOFF doc 2026-08-20 23:29:43 +02:00
ARIA 5b9427b10d Channel badges: flat Material star (default) + heart (favorite) icons instead of text glyphs, so favorites no longer look like the default channel 2026-08-20 21:07:52 +02:00
ARIA f5e39c0752 Channel context menu: hide Delete for the default channel (it can be renamed but not deleted; server already enforces this) 2026-08-20 21:00:36 +02:00
ARIA b681e547e7 Channel context menu: long-press/right-click a row → rename, favorite/unfavorite, add/change/remove icon (color or image), delete; removed star/delete row buttons; favorite + icon/color synced across devices 2026-08-20 20:55:24 +02:00
ARIA 0d99ee5cf5 Back-to-bottom button: chevron FAB appears when scrolled up, tap glides to latest; new messages no longer yank the view while reading history; lane switch always lands on latest 2026-08-20 20:15:12 +02:00
ARIA 8b40eb6e4c App icon: winged-bubble adaptive icon (Android) + window/jpackage icon (desktop); concept art in app/icons/ 2026-08-20 19:48:57 +02:00
ARIA ccc40213ca Compact single-row header: tappable status bubble (green/yellow-pulsing/red) + search, status toast on tap; removed overflow menu (rename/forget) 2026-08-20 19:33:45 +02:00
ARIA c668b209b2 User theme: customizable bubble colors + background (color/image) in Settings → Appearance
- UserTheme model (ARGB ints) + LocalUserTheme, persisted via SecureStore
- HSV color picker dialog (Apply keeps open, OK applies+closes, Cancel)
- Background: solid color or image (SAF on Android, JFileChooser on desktop)
- Reasoning block: 0.6 black overlay on agent bubble color (adapts automatically)
- Contrast-aware text on bubbles + pinned contentColor on root Surface
2026-08-20 19:14:35 +02:00
ARIA de3b5fd956 Added official hermes-gateway status messages (new comment category instead of tool calls and messages) 2026-08-20 18:11:36 +02:00
ARIA 6f4bf3cb7f Long-press message selection + delete (message.delete frame, outbox removal, all-device sync) 2026-08-20 16:51:19 +02:00
ARIA 60ec2b44a7 Auto-threading, history pagination, streaming toggle + tool/reasoning display settings
- Auto-threading (Telegram topic-mode workflow): message.send {auto_thread}
  mints a fresh AI-named thread (instant derived title, LLM upgrade via
  channel.renamed); channel.created {auto:true}; the app jumps into the new
  thread and relocates the optimistic pending bubble.
- history frame: paged full message history for initial channel open /
  scroll-up pagination (reconstructed from the outbox log).
- Streaming on/off: gateway side (display.platforms.android.streaming) plus a
  per-device app toggle (Settings → Streaming); reasoning/model/tokens carried
  on message frames.
- Context menu: long-press (touch) / right-click (desktop) thread affordances
  via a KMP rightClick expect/actual.
- Settings → Reasoning: auto-collapse long reasoning blocks (default on).
- Tool detail: the gateway now always supplies full tool data — it forces
  verbose tool progress (full args → tool.start.args) and captures each
  completed call via the post_tool_call hook (output/duration/ok → tool.end).
  The app reveals the full call + output on expand (Truncated) and
  auto-expands cards in Everything mode.
2026-08-20 16:26:52 +02:00
ARIA 1d67de5f12 Composer auto-grow (1→5 lines) + in-field attach; settings screen + persisted prefs
- Composer input starts at one line and auto-grows up to 5 lines (then scrolls),
  measured via onTextLayout; attach (paperclip) moved inside the field, right of
  the text, per the Telegram reference.

- New SettingsScreen (drawer/rail entry) for Threads + Tool detail; both now
  persist via SecureStore (Android prefs / desktop JSON).

- Add AGENTS.md; rename reference screenshot to telegram_screenshot_reference.jpg.
2026-08-20 13:12:24 +02:00
143 changed files with 3387 additions and 17552 deletions

No files matched your search

-79
View File
@@ -1,79 +0,0 @@
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 gateway-plugin/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
@@ -1,226 +0,0 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.2.0)"
required: true
type: string
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 gateway-plugin/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=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
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=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
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=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
# 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
@@ -1,19 +0,0 @@
# 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
@@ -1,28 +0,0 @@
{
"ignore": [
"gateway-plugin/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"
]
}
}
}
+8 -10
View File
@@ -3,7 +3,6 @@
## 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.
- **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/android` → `<repo>/gateway-plugin` (already set up on this machine).
## Layout
@@ -15,27 +14,26 @@
## Commands
- `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 `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_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).
- 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).
- 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 <IRIS_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
- Kotlin tests: `cd app && ./gradlew :shared:testDebugUnitTest` / `:shared:desktopTest` (no test sources exist yet).
- 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`.
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
## Environment / pairing quirks
- 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 `IRIS_WS_HOST` to the gateway's LAN IP.
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
- `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.
- **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 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## 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 `IRIS_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 `ANDROID_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.
- 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`.
-490
View File
@@ -1,490 +0,0 @@
# 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
- `gateway-plugin/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": ["gateway-plugin/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 gateway-plugin/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`
Manual trigger: **repo → Actions → Release → Run workflow**, enter a
`version` (e.g. `0.2.0`) and 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:
version:
description: "Release version (e.g. 0.2.0)"
required: true
type: string
changelog:
description: "Release notes (markdown, shown on the release page)"
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 gateway-plugin/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=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
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=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
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=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH")
TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# Re-run safety: drop a previous release (and its tag) for this version.
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -n "$OLD_ID" ]; then
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
fi
# Gitea creates the tag at the default branch HEAD automatically.
RELEASE_ID=$(curl -sf -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")"
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/attachments" > /dev/null
done
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
---
## 4. EDITS to existing Gradle files
### 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`).
+9 -45
View File
@@ -1,24 +1,19 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.application")
kotlin("android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "dev.iris.app"
compileSdk = 37
compileSdk = 34
defaultConfig {
applicationId = "dev.iris.app"
minSdk = 29
targetSdk = 34
// Play Store requires an incrementing versionCode per upload; CI can
// pass -PappVersionCode=<n>. Local builds keep the default.
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
// CI passes -PappVersion=<version> (release workflow); local builds
// keep the default.
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
@@ -27,54 +22,23 @@ 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 {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// targetSdk is deliberately pinned to 34 (a stable API level): the
// 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)
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":shared"))
implementation(platform("androidx.compose:compose-bom:2026.08.00"))
implementation(platform("androidx.compose:compose-bom:2024.09.02"))
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("androidx.core:core-splashscreen:1.2.0")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.core:core-splashscreen:1.0.1")
}
// M5: apply the google-services plugin only when a Firebase project is
@@ -8,16 +8,7 @@
<!-- 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_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
android:label="Iris"
android:icon="@mipmap/ic_launcher"
@@ -47,22 +38,8 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="iris" android:host="chat" />
</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>
<!-- 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). -->
<provider
android:name="androidx.core.content.FileProvider"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

@@ -15,17 +15,12 @@ import iris.IrisApp
import iris.platform.AndroidEnv
import iris.platform.AndroidSecureStore
import iris.platform.AppBridge
import iris.platform.syncNtfyListener
import iris.util.PairLink
import iris.platform.NtfyListenerService
class MainActivity : ComponentActivity() {
private val deepLinkChatId = 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 =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
@@ -36,19 +31,11 @@ class MainActivity : ComponentActivity() {
val store = AndroidSecureStore(applicationContext)
handleDeepLink(intent)
requestNotificationPermission()
// M5: the ntfy listener (and its permanent notification) only runs
// when the paired gateway pushes via ntfy; unknown ("") = not yet.
syncNtfyListener(store.pushBackend)
startNtfyListener(store)
setContent {
val chatId by deepLinkChatId
val threadId by deepLinkThreadId
val pair by pendingPair
IrisApp(
store = store,
deepLinkChatId = chatId,
deepLinkThreadId = threadId,
deepLinkPair = pair,
)
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
}
}
@@ -60,10 +47,6 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
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() {
@@ -75,19 +58,9 @@ class MainActivity : ComponentActivity() {
* extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) {
val data = intent?.data
// 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")
val chatId = intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull()
val threadId =
intent?.getStringExtra("thread_id")
val threadId = intent?.getStringExtra("thread_id")
?: data?.getQueryParameter("thread")
if (!chatId.isNullOrBlank()) {
deepLinkChatId.value = chatId
@@ -104,4 +77,9 @@ class MainActivity : ComponentActivity() {
}
}
}
private fun startNtfyListener(store: AndroidSecureStore) {
if (store.ntfyTopic.isBlank()) return
ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java))
}
}
@@ -0,0 +1,5 @@
<?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>
@@ -0,0 +1,5 @@
<?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>
+9 -10
View File
@@ -7,20 +7,19 @@ buildscript {
mavenCentral()
}
dependencies {
classpath("com.google.gms:google-services:4.5.0")
classpath("com.google.gms:google-services:4.4.2")
}
}
plugins {
kotlin("multiplatform") version "2.4.10" apply false
kotlin("jvm") version "2.4.10" apply false
kotlin("plugin.serialization") version "2.4.10" apply false
id("com.android.application") version "9.3.1" apply false
id("com.android.kotlin.multiplatform.library") version "9.3.1" apply false
id("org.jetbrains.compose") version "1.11.1" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
id("app.cash.sqldelight") version "2.3.2" apply false
kotlin("multiplatform") version "2.1.0" apply false
kotlin("android") version "2.1.0" apply false
kotlin("jvm") version "2.1.0" apply false
kotlin("plugin.serialization") version "2.1.0" apply false
id("com.android.application") version "8.7.3" apply false
id("com.android.library") version "8.7.3" apply false
id("org.jetbrains.compose") version "1.7.3" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
}
tasks.register("clean") {
+20 -42
View File
@@ -6,24 +6,20 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose")
}
val composeVersion = "1.11.1"
val composeVersion = "1.7.3"
val os = OperatingSystem.current()
val arch = System.getProperty("os.arch") ?: "amd64"
// 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"
val desktopTarget =
when {
val desktopTarget = when {
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
os.isWindows -> "windows-x64"
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
}
}
dependencies {
implementation(project(":shared"))
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
// M6: window-state persistence (window.json).
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
compose.desktop {
@@ -49,21 +45,19 @@ afterEvaluate {
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
val fatJar =
tasks.register<Jar>("fatJar") {
val fatJar = tasks.register<Jar>("fatJar") {
archiveClassifier.set("all")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
from(sourceSets.main.get().output)
from({
configurations.runtimeClasspath
.get()
configurations.runtimeClasspath.get()
.filter { it.name.endsWith(".jar") }
.map { zipTree(it) }
}) {
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
}
}
}
// 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
@@ -79,39 +73,23 @@ tasks.register<Exec>("jpackage") {
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
val inputDir = jpackageInputDir.get().asFile
val destDir =
layout.buildDirectory
.dir("jpackage")
.get()
.asFile
val destDir = layout.buildDirectory.dir("jpackage").get().asFile
inputs.dir(inputDir)
outputs.dir(destDir)
commandLine(
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
"--name",
"iris",
"--app-version",
appVersion,
"--vendor",
"Iris",
"--type",
type,
"--input",
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",
"--name", "iris",
"--app-version", "0.1.0",
"--vendor", "Iris",
"--type", type,
"--input", 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).
"--java-options",
"--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--java-options",
"--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
"--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
)
}
-9
View File
@@ -1,13 +1,4 @@
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.configuration-cache=true
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+29 -76
View File
@@ -1,56 +1,28 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.kotlin.multiplatform.library")
id("com.android.library")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
id("app.cash.sqldelight")
}
val composeVersion = "1.11.1"
// material3 and material-icons-extended are published on their own cadence and
// lag the core artifacts; use each one's latest stable release.
val material3Version = "1.9.0"
val materialIconsVersion = "1.7.3"
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
val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0"
// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1);
// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
val webviewVersion = "2.0.3"
val kcefVersion = "2025.03.23"
// 0.34.0+ moved to Compose 1.8+ (0.33.0 was the last 1.7.x / Kotlin 2.1.x
// release). Provides GFM tables, bold/italic/underscore, and the -code module
// for language-aware syntax highlighting (Highlights).
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"
val webviewVersion = "1.9.40"
val kcefVersion = "2024.04.20.3"
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
// and the -code module for language-aware syntax highlighting (Highlights).
val markdownVersion = "0.33.0"
kotlin {
android {
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)
}
}
androidTarget()
jvm("desktop")
sourceSets {
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
@@ -58,20 +30,15 @@ kotlin {
val jvmMain by creating { dependsOn(commonMain.get()) }
val androidMain 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 {
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$material3Version")
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
// Full Material icon set (Icons.Filled.ContentCopy for the code
// copy buttons); -core only ships a small subset.
implementation("org.jetbrains.compose.material:material-icons-extended:$materialIconsVersion")
implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based
@@ -86,55 +53,41 @@ kotlin {
// M9: HTML artifact previews — WebView composable (platform WebView
// on Android, KCEF/JCEF on desktop).
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 {
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
// webview library exposes it transitively, but we reference KCEF
// directly (init + progress) so declare it explicitly.
desktopMain.dependencies {
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).
androidMain.dependencies {
// M7: EncryptedSharedPreferences for the pairing token
// (docs/09 §9.7; AndroidSecureStore).
implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.media3:media3-exoplayer:1.11.0")
implementation("androidx.media3:media3-ui:1.11.0")
implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-ui:1.3.1")
// SAF picker (rememberLauncherForActivityResult).
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")
implementation("androidx.activity:activity-compose:1.9.2")
// M5: FCM push (inert without a Firebase project / google-services.json;
// the ntfy listener is the fallback). The google-services plugin is
// applied conditionally in the app module.
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")
implementation("com.google.firebase:firebase-messaging:23.1.2")
}
}
}
sqldelight {
databases {
create("IrisDatabase") {
packageName.set("iris.db")
android {
namespace = "iris.shared"
compileSdk = 34
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -40,16 +40,16 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.media3.common.MediaItem as M3MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import iris.data.MediaItem
import iris.media.extForMime
import iris.protocol.KIND_VIDEO
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import androidx.media3.common.MediaItem as M3MediaItem
/** App context holder (set in MainActivity.onCreate). */
object AndroidEnv {
@@ -59,8 +59,7 @@ object AndroidEnv {
@Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
onPicked(null)
return@rememberLauncherForActivityResult
@@ -86,8 +85,7 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
onPicked(null)
return@rememberLauncherForActivityResult
@@ -109,10 +107,7 @@ actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
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 ->
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && c.moveToFirst()) return c.getString(idx)
@@ -124,20 +119,15 @@ actual fun mediaCacheBaseDir(): String =
AndroidEnv.context.externalCacheDir?.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 {
BitmapFactory.decodeFile(path)?.asImageBitmap()
} catch (e: Exception) {
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 {
// inJustDecodeBounds fills [bounds] and returns null by design — only
// the outWidth/outHeight are meaningful here.
@@ -151,40 +141,9 @@ actual suspend fun loadScaledImage(
} catch (e: Exception) {
null
}
}
}
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) {
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
@@ -200,23 +159,19 @@ actual suspend fun encodeImageAsBase64(
} catch (e: Exception) {
null
}
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
withContext(Dispatchers.IO) {
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
}
@Composable
actual fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
) {
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
val path = media.localPath
if (path == null) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
@@ -236,10 +191,7 @@ actual fun MediaPlayerView(
}
@Composable
private fun VideoPlayerView(
path: String,
modifier: Modifier,
) {
private fun VideoPlayerView(path: String, modifier: Modifier) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
@@ -253,22 +205,16 @@ private fun VideoPlayerView(
},
update = {},
onRelease = { view -> view.player?.release() },
modifier =
modifier
modifier = modifier
.fillMaxWidth()
.height(220.dp),
)
}
@Composable
private fun AudioPlayerView(
path: String,
filename: String,
modifier: Modifier,
) {
private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) {
val context = LocalContext.current
val player =
remember {
val player = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(M3MediaItem.fromUri(path.toUri()))
prepare()
@@ -291,8 +237,7 @@ private fun AudioPlayerView(
}
Row(
modifier =
modifier
modifier = modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -325,21 +270,17 @@ private fun fmt(seconds: Float): String {
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
try {
val file = File(path)
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)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} 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,8 +1,6 @@
package iris.platform
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import iris.state.IrisController
@@ -13,33 +11,6 @@ actual fun setActiveController(controller: Any?) {
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(
chatId: String?,
chatName: String?,
@@ -48,7 +19,7 @@ actual fun postSystemNotification(
threadId: String?,
) {
val context = AndroidEnv.context
val id = chatId ?: "default"
val id = chatId ?: "android:default"
// POST_NOTIFICATIONS is a runtime permission on API 33+.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
@@ -6,8 +6,6 @@ import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import iris.data.SecureStore
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import java.util.UUID
@@ -17,17 +15,13 @@ import java.util.UUID
* values are migrated on first run (read old key, write encrypted, delete
* 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 plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
private val prefs: SharedPreferences =
EncryptedSharedPreferences.create(
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
appContext,
SECURE_PREFS_NAME,
MasterKey
.Builder(appContext)
MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
@@ -56,8 +50,7 @@ class AndroidSecureStore(
}
if (migrated) editor.apply()
// The plain store must not keep a copy of any value.
plainPrefs
.edit()
plainPrefs.edit()
.remove(KEY_URL)
.remove(KEY_TOKEN)
.remove(KEY_DEVICE_ID)
@@ -80,12 +73,7 @@ class AndroidSecureStore(
get() {
var id = prefs.getString(KEY_DEVICE_ID, null)
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()
}
return id
@@ -110,10 +98,6 @@ class AndroidSecureStore(
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
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
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
@@ -139,11 +123,7 @@ class AndroidSecureStore(
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
override var backgroundMode: String
get() =
prefs
.getString(KEY_BACKGROUND_MODE, BackgroundMode.Image.name.lowercase())
.orEmpty()
.ifBlank { BackgroundMode.Image.name.lowercase() }
get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" }
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
override var backgroundColor: Int
@@ -151,49 +131,16 @@ class AndroidSecureStore(
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
override var backgroundImagePath: String
get() =
prefs
.getString(KEY_BACKGROUND_IMAGE_PATH, Backdrop.DEFAULT.path)
.orEmpty()
.ifBlank { Backdrop.DEFAULT.path }
get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty()
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
override var fontSizeScale: Float
get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f)
set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply()
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,
) {
override fun savePairing(url: String, token: String) {
serverUrl = url
this.token = token
}
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.
prefs
.edit()
.remove(KEY_URL)
.remove(KEY_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)
.apply()
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
}
private companion object {
@@ -206,7 +153,6 @@ class AndroidSecureStore(
const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_PUSH_BACKEND = "push_backend"
const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled"
@@ -216,8 +162,5 @@ class AndroidSecureStore(
const val KEY_BACKGROUND_MODE = "background_mode"
const val KEY_BACKGROUND_COLOR = "background_color"
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
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"
}
}
@@ -1,12 +0,0 @@
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,10 +22,7 @@ import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable
actual fun PlatformWebView(
html: String,
modifier: Modifier,
) {
actual fun PlatformWebView(html: String, modifier: Modifier) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier)
@@ -36,18 +33,16 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) {
val callback =
dispatcher?.let { d ->
val c =
object : OnBackPressedCallback(true) {
if (dispatcher != null) {
val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
currentOnBack.value()
}
}
d.addCallback(c)
c
dispatcher.addCallback(callback)
onDispose { callback.remove() }
}
onDispose { callback?.remove() }
onDispose { }
}
return this
}
@@ -14,14 +14,7 @@ object AppBridge {
@Volatile
var controller: IrisController? = null
/** True while the launcher activity is resumed (set by MainActivity).
* A change is forwarded to the controller (M8: unread clear on focus). */
/** True while the launcher activity is resumed (set by MainActivity). */
@Volatile
var foreground: Boolean = false
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
}
@@ -4,59 +4,39 @@ import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import iris.net.GatewayClient
/**
* M5: FCM handler (docs/08 §8.1).
*
* - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: posts a system notification from the data payload.
* When the app is foregrounded the SSE path already delivered the frame
* (in-app banner), so we only post a notification when backgrounded.
* - [onMessageReceived]: the data payload drives a silent sync. When the app
* is foregrounded the WS path already delivered the frame (in-app banner),
* so we only post a system notification when backgrounded.
*
* Inert without a Firebase project (no google-services.json): the service is
* declared in the manifest but never receives messages, and the app falls
* back to the ntfy listener.
*/
class IrisFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
val store = AndroidSecureStore(applicationContext)
store.fcmToken = token
// Push the rotation to the server if we're connected (the ntfy topic
// rides along so a wiped registry recovers both push tokens).
// Push the rotation to the server if we're connected.
AppBridge.controller?.client?.sendFrame(
iris.protocol.fcmRegisterFrame(
fcmToken = token,
ntfyTopic = store.ntfyTopic.ifBlank { null },
),
iris.protocol.fcmRegisterFrame(fcmToken = token),
)
}
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 chatId = data["chat_id"] ?: "default"
val chatId = data["chat_id"] ?: "android:default"
val threadId = data["thread_id"]
val title = data["title"] ?: "Iris"
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)
!= PackageManager.PERMISSION_GRANTED
) {
@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
/**
@@ -20,22 +21,15 @@ object IrisNotifications {
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000
// L-31: notification channel ids are capped at 64 chars (Android limit) and
// 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,
) {
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val id = channelIdFor(chatId)
val id = CHANNEL_PREFIX + chatId
val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "Iris messages for $name" },
.apply { description = "Iris messages for $name" }
)
}
}
@@ -49,18 +43,14 @@ object IrisNotifications {
threadId: String?,
) {
ensureChannel(context, chatId, chatName)
// L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
// 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 {
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
val intent = Intent(ACTION_OPEN_CHAT).apply {
setPackage(context.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("chat_id", chatId)
if (threadId != null) putExtra("thread_id", threadId)
}
val pi =
PendingIntent.getActivity(
val pi = PendingIntent.getActivity(
context,
id,
intent,
@@ -69,8 +59,7 @@ object IrisNotifications {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(
id,
NotificationCompat
.Builder(context, channelIdFor(chatId))
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
@@ -11,13 +11,11 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -30,31 +28,18 @@ import java.util.concurrent.TimeUnit
*
* 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
* `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
* body is the short preview. When the app is foregrounded the SSE path
* 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.
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
* is the short preview. When the app is foregrounded the WS path already
* delivered the frame, so the service skips posting to avoid a duplicate.
*/
class NtfyListenerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null
private val client =
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)
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
.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())
streamJob?.cancel()
streamJob = scope.launch { stream() }
@@ -75,56 +60,24 @@ class NtfyListenerService : Service() {
if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic"
val request =
Request
.Builder()
val request = Request.Builder()
.url(url)
.header("Accept", "text/event-stream")
.build()
var backoff = 1_000L
while (true) {
try {
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
IrisLog.w("ntfy stream HTTP ${resp.code}")
} else {
if (!resp.isSuccessful) return
val body = resp.body ?: return
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) {
val source = body.source()
var data: String? = null
var title: String? = null
var msgBody: String? = null
while (true) {
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: break
when {
line.startsWith("X-Data:") -> {
data = line.removePrefix("X-Data:").trim()
}
line.startsWith("X-Title:") -> {
title = line.removePrefix("X-Title:").trim()
}
line.startsWith("data:") -> {
msgBody = line.removePrefix("data:").trim()
}
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
line.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
@@ -135,19 +88,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?,
) {
val data =
try {
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
val data = try {
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
} catch (_: Exception) {
null
}
val chatId = data?.str("chat_id") ?: "default"
val chatId = data?.str("chat_id") ?: "android:default"
val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the
// X-Title, then a generic label.
@@ -173,12 +126,11 @@ class NtfyListenerService : Service() {
LISTENER_CHANNEL,
"Iris push listener",
NotificationManager.IMPORTANCE_MIN,
),
)
)
}
}
return NotificationCompat
.Builder(context, LISTENER_CHANNEL)
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris")
.setContentText("Listening for messages")
@@ -194,4 +146,5 @@ class NtfyListenerService : Service() {
}
/** Read a string field from a JSON object (null when absent / not a string). */
private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
private fun JsonObject?.str(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.content
@@ -1,49 +0,0 @@
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
}
@@ -1,136 +0,0 @@
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,12 +10,9 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
@@ -27,21 +24,19 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage
import iris.util.PairLink
/**
* Root composable shared by the Android and Desktop shells.
*
* Routes between the Connect screen (unpaired / auth failed) and the main
* app (paired), which hosts the channel list, chat, search, settings, and
* media (docs/10-android-app.md).
* M1: routes between the Connect screen (unpaired / auth failed) and the
* Chat screen (paired). Later milestones add the channel list, search,
* settings, and media (docs/10-android-app.md).
*/
@Composable
fun IrisApp(
store: SecureStore,
deepLinkChatId: String? = null,
deepLinkThreadId: String? = null,
deepLinkPair: PairLink? = null,
) {
val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) {
@@ -60,21 +55,9 @@ fun IrisApp(
}
val state by controller.client.state.collectAsState()
val theme by controller.theme.collectAsState()
val fontScale by controller.fontSizeScale.collectAsState()
val baseDensity = LocalDensity.current
IrisTheme {
// Font-size setting (Settings → Appearance): a multiplier on top of the
// system font scale. fontScale affects sp text only — dp layout is
// untouched, so the UI keeps its proportions at any size.
CompositionLocalProvider(
LocalUserTheme provides theme,
LocalDensity provides
Density(
density = baseDensity.density,
fontScale = baseDensity.fontScale * fontScale,
),
) {
CompositionLocalProvider(LocalUserTheme provides theme) {
// The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the
// Surface's own colorScheme.surface fill and never show through.
@@ -99,34 +82,20 @@ fun IrisApp(
)
}
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) {
GatewayClient.State.Disconnected -> {
key(deepLinkPair) {
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
}
}
is GatewayClient.State.AuthFailed -> {
key(deepLinkPair) {
GatewayClient.State.Disconnected ->
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
is GatewayClient.State.AuthFailed ->
ConnectScreen(
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
prefillUrl = store.serverUrl,
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
}
}
// Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state
// 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
// artifact card in the chat); covers everything while open.
@@ -26,32 +26,15 @@ class ChannelStore {
_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. */
fun onFrame(frame: Frame) {
when (frame.type) {
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> {
upsert(frame)
}
TYPE_CHANNEL_DELETED -> {
remove(frame)
}
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame)
TYPE_CHANNEL_DELETED -> remove(frame)
TYPE_CHANNEL_LIST -> {
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
}
else -> {
Unit
}
else -> Unit
}
}
@@ -65,58 +48,7 @@ class ChannelStore {
private fun remove(frame: Frame) {
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
// 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 })
_channels.value = _channels.value.filter { it.chatId != p.chatId }
}
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
@@ -130,14 +62,18 @@ class ChannelStore {
// ── UI helpers ────────────────────────────────────────────────────────
/** Channels (default + user channels) for the drawer/rail. */
fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" }
fun channelsForDrawer(): List<ChannelInfo> =
_channels.value.filter { it.kind != "thread" }
/** Threads under a channel, for the topic switcher. */
fun threadsFor(chatId: String): List<ChannelInfo> = _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
fun threadsFor(chatId: String): List<ChannelInfo> =
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
fun byId(chatId: String): ChannelInfo? = _channels.value.firstOrNull { it.chatId == chatId }
fun byId(chatId: String): ChannelInfo? =
_channels.value.firstOrNull { it.chatId == chatId }
fun defaultChannel(): ChannelInfo? = _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
fun defaultChannel(): ChannelInfo? =
_channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
fun clear() {
_channels.value = emptyList()
@@ -1,203 +0,0 @@
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,24 +8,17 @@ import iris.protocol.MessagePayload
import iris.protocol.MessageStartPayload
import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload
import iris.protocol.PickerChoice
import iris.protocol.PickerChoicePayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.RuntimeMeta
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
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_PROGRESS
import iris.protocol.TYPE_TOOL_START
import iris.protocol.TodoItem
import iris.protocol.TodoUpdatePayload
import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload
@@ -33,7 +26,6 @@ import iris.util.nowMillis
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlin.random.Random
@@ -61,9 +53,7 @@ enum class MsgStatus {
/** A chat message (user / assistant / commentary / streaming bubble).
* [isSystem] marks a locally generated, centered notice (gateway restart /
* online) — not a user or agent turn, not selectable or deletable.
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
@Serializable
* online) — not a user or agent turn, not selectable or deletable. */
data class MessageItem(
override val id: String,
val role: String,
@@ -76,7 +66,6 @@ data class MessageItem(
val streaming: Boolean = false,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val media: List<MediaItem> = emptyList(),
val isSystem: Boolean = false,
) : ChatItem
@@ -86,7 +75,6 @@ data class MessageItem(
* has been pulled to the local cache (outbound) — inbound attachments the
* app itself uploaded carry no local path (the agent reads the server copy).
*/
@Serializable
data class MediaItem(
val mediaId: String,
val kind: String,
@@ -96,42 +84,18 @@ data class MediaItem(
val localPath: String? = null,
)
/** 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
/** A structured tool-activity card (spinner until [done]). */
data class ToolItem(
override val id: String,
val index: Int,
val name: String,
val preview: String? = 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 done: Boolean = false,
val ok: Boolean = true,
val duration: Double? = 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
class ChatStore {
@@ -143,25 +107,8 @@ class ChatStore {
private val _currentLane = MutableStateFlow(DEFAULT_LANE)
val currentLane: StateFlow<String> = _currentLane.asStateFlow()
/** The agent's live todo list per lane (todo.update; last-write-wins).
* 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()
private var localSeq = 0
private var toolSeq = 0
/** When false, `message.start`/`message.update` frames are ignored and each
* reply materializes as a single final message on `message.stop`
@@ -170,20 +117,18 @@ class ChatStore {
var streamingEnabled: Boolean = true
companion object {
const val DEFAULT_LANE = "default"
const val DEFAULT_LANE = "android:default"
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
fun randomId(prefix: String): String =
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
}
// ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
* thread lane can never collide with a chat id (chat ids are direct,
* e.g. `chan_1`, and never contain `:`). */
fun laneKey(
chatId: String,
threadId: String?,
): String = if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
fun laneKey(chatId: String, threadId: String?): String =
if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
/** Split a lane key back into (chatId, threadId). */
fun parseLane(key: String): Pair<String, String?> {
@@ -200,45 +145,18 @@ class ChatStore {
return laneKey(chatId, frame.threadId)
}
private fun updateLane(
lane: String,
transform: (List<ChatItem>) -> List<ChatItem>,
) {
synchronized(lock) {
private fun updateLane(lane: String, transform: (List<ChatItem>) -> List<ChatItem>) {
val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty())
_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 add: show the user's message immediately (pending) in [lane]. */
fun addPending(
text: String,
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")
fun addPending(text: String, lane: String, media: List<MediaItem> = emptyList()): String {
localSeq++
val id = "local_$localSeq"
updateLane(lane) {
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
}
@@ -248,14 +166,9 @@ class ChatStore {
/** Append a locally generated, centered system notice (gateway restart /
* online) to [lane]. Not a user or agent turn: it is not selectable,
* deletable, or echoed to the server. */
fun addSystemMessage(
lane: String,
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")
fun addSystemMessage(lane: String, text: String) {
localSeq++
val id = "sys_$localSeq"
updateLane(lane) {
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
}
@@ -274,20 +187,15 @@ class ChatStore {
TYPE_TOOL_START -> onToolStart(lane, frame)
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
TYPE_TOOL_END -> onToolEnd(lane, frame)
TYPE_TODO_UPDATE -> onTodoUpdate(lane, frame)
TYPE_COMMENTARY -> onCommentary(lane, frame)
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
else -> Unit
}
}
// ── 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
// Auto-threaded send: the gateway created a fresh thread for this
// message; the optimistic bubble is still in the parent flat lane.
@@ -297,8 +205,7 @@ class ChatStore {
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
val cur = list[byId] as? MessageItem ?: return@updateLane list
val updated =
cur.copy(
val updated = cur.copy(
text = p.text,
reasoning = p.reasoning,
pending = false,
@@ -306,58 +213,34 @@ class ChatStore {
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
model = p.model,
tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
ts = p.ts ?: cur.ts,
media = mergeMedia(cur.media, p.media),
)
list.toMutableList().also { it[byId] = updated }
} else if (p.role == ROLE_USER) {
// Replace the matching optimistic bubble (server echo). Also
// matches a FAILED bubble: the send may have arrived after
// 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)
// Replace the matching optimistic pending bubble (server echo).
val pendingIdx = list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (pendingIdx >= 0) {
list.toMutableList().also {
it[pendingIdx] =
MessageItem(
id = p.messageId,
role = p.role,
text = p.text,
ts = p.ts ?: 0,
reasoning = p.reasoning,
it[pendingIdx] = MessageItem(
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),
)
}
} else {
list +
MessageItem(
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,
list + MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
media = p.media.map { it.toMediaItem() },
)
}
} else {
list +
MessageItem(
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,
list + MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
media = p.media.map { it.toMediaItem() },
)
}
@@ -371,76 +254,50 @@ class ChatStore {
* 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.
*/
private fun relocatePendingToThreadLane(
lane: String,
p: MessagePayload,
) {
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) {
val (chatId, threadId) = parseLane(lane)
if (threadId == null) return
val flatLane = chatId
synchronized(lock) {
val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty()
val idx =
flatList.indexOfLast {
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
(it.pending || it.status == MsgStatus.Failed)
val idx = flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (idx < 0) return@synchronized
if (idx < 0) return
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
}
}
/** 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
val byId = existing.associateBy { it.mediaId }
return incoming.map { ref ->
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()
}
}
private fun MediaRef.toMediaItem() = MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
private fun MediaRef.toMediaItem() =
MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
// ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(
lane: String,
frame: Frame,
) {
private fun onMessageStart(lane: String, frame: Frame) {
if (!streamingEnabled) return
val p = frame.payloadAs<MessageStartPayload>() ?: return
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) {
list
} else {
list +
MessageItem(
id = p.messageId,
role = p.role,
text = "",
ts = 0,
streaming = true,
if (list.any { it.id == p.messageId }) list
else list + MessageItem(
id = p.messageId, role = p.role, text = "", ts = 0, streaming = true,
)
}
}
}
// ── 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
updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId }
@@ -452,36 +309,25 @@ class ChatStore {
// ── 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
updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one.
list +
MessageItem(
id = p.messageId,
role = ROLE_ASSISTANT,
text = p.finalText,
ts = p.ts ?: 0,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime,
list + MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
ts = p.ts ?: 0, reasoning = p.reasoning,
model = p.model, tokens = p.tokens,
)
} else {
val cur = list[idx] as? MessageItem ?: return@updateLane list
list.toMutableList().also {
it[idx] =
cur.copy(
it[idx] = cur.copy(
text = p.finalText,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
runtime = p.runtime ?: cur.runtime,
streaming = false,
ts = p.ts ?: cur.ts,
)
@@ -492,41 +338,21 @@ class ChatStore {
// ── 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
// Process-unique id: the in-memory seq resets on every ChatStore
// 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")
toolSeq++
val id = "tool_$toolSeq"
updateLane(lane) { list ->
// Anchor the card to the message it follows: the last non-streaming
// message (a live streaming bubble is the answer that arrives AFTER
// 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,
list + ToolItem(
id = id, index = p.index, name = p.name,
preview = p.preview, args = p.args,
)
}
}
// ── 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
updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
@@ -538,70 +364,41 @@ class ChatStore {
// ── 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
updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return@updateLane list
val cur = list[idx] as ToolItem
list.toMutableList().also {
it[idx] =
cur.copy(
done = true,
ok = p.ok,
duration = p.duration,
it[idx] = cur.copy(
done = true, ok = p.ok, duration = p.duration,
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) ──────────────────────────────────
private fun onCommentary(
lane: String,
frame: Frame,
) {
private fun onCommentary(lane: String, frame: Frame) {
val p = frame.payloadAs<CommentaryPayload>() ?: return
// Gateway lifecycle notices (restart / shutdown / online) are the
// controller's business: it renders the "restarting" / "online" pair
// as centered system notices on the down/up state transitions (gated
// on the gateway's status{restarting} frame). The server also emits
// these as commentary frames (and the sync catch-up can replay them
// out of order), so drop them here to prevent duplicates.
// Gateway lifecycle notices (restart / shutdown / online) are rendered
// as a centered system notice by the controller, on the down/up state
// transition. The server also emits the "restarting" notice as a
// commentary frame (and the sync catch-up can replay it *after* the
// local "online" notice), so drop it here: the app is the source of
// truth for these notices, which keeps the order (restarting → online)
// and prevents duplicates.
if (isGatewayLifecycleNotice(p.text)) return
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) {
list
} else {
list +
MessageItem(
id = p.messageId,
role = ROLE_ASSISTANT,
text = p.text,
ts = 0,
isCommentary = true,
if (list.any { it.id == p.messageId }) list
else 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). */
private fun isGatewayLifecycleNotice(text: String): Boolean {
@@ -618,24 +415,16 @@ class ChatStore {
* falling back to the lane's last assistant message). The item has no local
* 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
updateLane(lane) { list ->
// Already attached (offer replayed)? Skip.
if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list
val item =
MediaItem(
mediaId = p.mediaId,
kind = p.kind,
mime = p.mime,
size = p.size,
filename = p.filename,
val item = MediaItem(
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 }
} else {
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
@@ -647,132 +436,43 @@ class ChatStore {
}
/** Record a pulled file's local path for [mediaId] (all lanes). */
fun setMediaLocalPath(
mediaId: String,
localPath: String,
) {
synchronized(lock) {
mapLanes { list ->
list.map { item ->
fun setMediaLocalPath(mediaId: String, localPath: String) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
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
},
)
} else {
item
})
} else item
}
if (updated != list) {
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). */
fun markRead(messageId: String) {
synchronized(lock) {
mapLanes { list ->
list.map { item ->
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read
) {
item.copy(pending = false, status = MsgStatus.Read)
} else {
item
} else item
}
}
}
}
}
/** 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 (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/**
@@ -783,10 +483,17 @@ class ChatStore {
*/
fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return
synchronized(lock) {
mapLanes { list -> list.filterNot { it.id in messageIds } }
val map = _lanes.value.toMutableMap()
var changed = false
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
@@ -796,46 +503,48 @@ class ChatStore {
* message.stop) will never arrive to close them.
*/
fun finalizeInterrupted() {
synchronized(lock) {
mapLanes { list ->
list.map { item ->
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
when (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 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). */
fun failPending() {
synchronized(lock) {
mapLanes { list ->
list.map { item ->
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
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. */
fun rearmForRetry(
lane: String,
messageId: String,
) {
fun rearmForRetry(lane: String, messageId: String) {
updateLane(lane) { list ->
list.map { item ->
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
item.copy(pending = true, status = MsgStatus.Pending)
} else {
item
}
} else item
}
}
}
@@ -844,96 +553,24 @@ class ChatStore {
* Load a history page into [lane] (oldest → newest). The history is 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
* streaming bubbles, commentary) that are not part of the history. Tool
* cards sort at their anchor message's position, so a history refresh
* 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
* streaming bubbles, commentary) that are not part of the history. 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.
*/
fun loadHistory(
lane: String,
messages: List<MessageItem>,
) {
fun loadHistory(lane: String, messages: List<MessageItem>) {
updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet()
// A local FAILED bubble whose text+media matches a history user
// 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
val preserved = list.filter { item ->
item !is MessageItem || item.id !in historyIds
}
(messages + preserved).sortedBy { item ->
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
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
}
}
}
fun clear() {
synchronized(lock) {
_lanes.value = emptyMap()
_unread.value = emptyMap()
_todos.value = emptyMap()
}
}
}
@@ -2,14 +2,14 @@ package iris.data
/**
* Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
* encrypted file on desktop).
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
* SharedPreferences for M1 dev, file on desktop).
*/
interface SecureStore {
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
/** ws(s)://host:port/ws */
var serverUrl: String
/** IRIS_TOKEN presented in the auth header. */
/** ANDROID_TOKEN presented in the hello frame. */
var token: String
/** Stable app-generated device id (persisted). */
@@ -30,11 +30,6 @@ interface SecureStore {
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
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. */
var threadsEnabled: Boolean
@@ -62,23 +57,6 @@ interface SecureStore {
/** UI setting: background image path (empty when unused). */
var backgroundImagePath: String
/** UI setting: text size multiplier (1.0 = default; applied on top of the
* system font scale). */
var fontSizeScale: Float
/** 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 savePairing(url: String, token: String)
fun clear()
}
@@ -0,0 +1,10 @@
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,17 +6,15 @@ import iris.protocol.KIND_IMAGE
import iris.protocol.KIND_VIDEO
/** 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("video/") -> KIND_VIDEO
mime.startsWith("audio/") -> KIND_AUDIO
else -> KIND_DOCUMENT
}
}
/** 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/png" -> ".png"
mime == "image/webp" -> ".webp"
@@ -36,13 +34,4 @@ fun extForMime(mime: String): String =
mime == "application/zip" -> ".zip"
mime == "text/plain" -> ".txt"
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,48 +1,66 @@
package iris.net
import iris.data.SecureStore
import iris.media.FileSource
import iris.media.Sha256
import iris.protocol.ChannelInfo
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaPullEndPayload
import iris.protocol.MediaUploadAckPayload
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.pingFrame
import iris.protocol.syncFrame
import iris.util.IrisLog
import iris.util.nowMillis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeMark
import kotlin.time.TimeSource
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 kotlin.random.Random
/**
* HTTP client for the hermes iris gateway (docs/19).
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
*
* 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)
* - connect + hello (real auth leg), hello.ack
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - events: server frames on [events]
* - correlation: the POST body carries the synchronous reply (e.g. read
* receipt, errors); everything else arrives on the event stream, and the
* app reconciles by frame id (docs/19 §19.7)
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
* - events: server frames (minus hello.ack) on [events]
* - request/response correlation by id (M2+ consumers)
*/
class GatewayClient(
private val scope: CoroutineScope,
@@ -50,23 +68,10 @@ class GatewayClient(
) {
sealed interface State {
data object Disconnected : State
data object Connecting : 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 class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
data object Reconnecting : State
data class AuthFailed(
val message: String,
) : State
data class AuthFailed(val message: String) : State
}
private val _state = MutableStateFlow<State>(State.Disconnected)
@@ -79,71 +84,36 @@ class GatewayClient(
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
val events: SharedFlow<Frame> = _events.asSharedFlow()
private val client: OkHttpClient =
OkHttpClient
.Builder()
private val client: OkHttpClient = OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.build()
private var connectJob: Job? = null
private var socket: WebSocket? = null
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
// 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
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
// the resume cursor (SSE id / outbox high-water mark), updated from the
// SSE/poll callback threads.
private var http: HttpGateway? = null
// M4: binary frames (media upload chunks / pull stream) have no per-frame
// id, so at most one binary session is active per socket. The gateway
// allows one upload per connection; pull is request/response.
private sealed interface BinarySession {
data class Pulling(
val requestId: Int,
val chunks: Channel<ByteArray>,
val end: CompletableDeferred<Frame>,
) : BinarySession
}
@Volatile
private var httpCursor: Long = 0
private var sseFailures = 0
private var usingLongPoll = false
private var binarySession: BinarySession? = null
// Last hello.ack payload — used to restore State.Connected after a
// 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.
// M4: only one pull may be in flight at a time (binarySession is a single
// slot). Serialize concurrent offers so their byte streams don't interleave.
private val pullMutex = Mutex()
// ── Lifecycle ─────────────────────────────────────────────────────────
@@ -156,34 +126,13 @@ class GatewayClient(
connectJob = scope.launch { connectLoop() }
}
/**
* 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.
*/
/** Stop the connect loop and close the socket. */
fun stop() {
connectJob?.cancel()
connectJob = null
socket?.close(1000, "client shutdown")
socket = null
_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). */
@@ -201,258 +150,172 @@ class GatewayClient(
return
}
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
val gw = httpGateway() ?: continue
// Health probe: if the gateway is alive, open the SSE receive loop
// (which delivers the hello.ack). Otherwise back off and retry.
val healthOk =
try {
gw.health()
} catch (e: Exception) {
false
val dial = dial(url, token)
when (val result = dial.result) {
is DialResult.AuthFailed -> {
_state.value = State.AuthFailed(result.message)
dial.socket.close(1000, "auth failed")
return
}
if (!healthOk) {
attempt++
backoffOrWake(backoffMs(attempt))
continue
}
attempt = 0
DialResult.Connected -> {
hasConnected = true
sseFailures = 0
usingLongPoll = false
httpCursor = store.syncCursor
lastFrameMs = nowMs()
// Provisional Connected state (previous caps/channels) until the
// SSE hello arrives with the real ones.
val prev = _state.value
_state.value =
State.Connected(
caps = (prev as? State.Connected)?.caps ?: ServerCaps(),
channels = (prev as? State.Connected)?.channels ?: emptyList(),
lastPushedCursor = (prev as? State.Connected)?.lastPushedCursor ?: 0,
attempt = 0
lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await()
if (!currentCoroutineContext().isActive) return
// socket dropped -> loop again (Reconnecting)
}
is DialResult.Failed -> {
attempt++
delay(backoffMs(attempt))
}
}
}
}
// ── Dial (one connect + hello) ────────────────────────────────────────
private sealed interface DialResult {
data object Connected : DialResult
data class AuthFailed(val message: String) : DialResult
data class Failed(val message: String) : DialResult
}
private data class Dial(
val result: DialResult,
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) {
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 auth failure: don't redial with the same bad token.
if (_state.value is State.AuthFailed) return
}
}
// ── HTTP receive leg ──────────────────────────────────────────────────
private suspend fun dial(url: String, token: String): Dial {
val closed = CompletableDeferred<Unit>()
val helloAck = CompletableDeferred<HelloAckPayload>()
val authError = CompletableDeferred<String>()
val fail = CompletableDeferred<String>()
/** Lazily build the HTTP client from the stored URL. Synchronized: two
* threads racing the check-then-create would leak a gateway (and its
* OkHttp clients). */
private fun httpGateway(): HttpGateway? =
synchronized(this) {
val url = store.serverUrl.trim()
val token = store.token
if (url.isBlank() || token.isBlank()) return@synchronized null
http
?: HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
token,
store.deviceId,
val request = Request.Builder().url(url).build()
val ws = client.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
helloFrame(
token = token,
deviceId = store.deviceId,
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
).also { http = it }
}
/**
* The HTTP receive loop: SSE by default; after two consecutive SSE open
* failures (buffering proxy) it switches to long-poll until the next full
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
*/
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
} catch (e: Exception) {
markStreamLost()
IrisLog.w("http poll failed: ${e.message}")
delay(backoff)
backoff = minOf(backoff * 2, 15_000)
}
} else {
try {
gw.events(
cursor = httpCursor,
onHello = { onHttpHello(it) },
onFrame = { emitHttpFrame(it) },
onCursor = { if (it > httpCursor) httpCursor = it },
// A keep-alive comment proves the stream is alive —
// count it toward liveness so an idle-but-healthy
// stream isn't force-reconnected by the stale
// watchdog every 3 minutes.
onKeepAlive = { lastFrameMs = nowMs() },
fcmToken = store.fcmToken.ifBlank { null },
ntfyTopic = store.ntfyTopic.ifBlank { null },
).toWire(),
)
// 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) {
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)
override fun onMessage(webSocket: WebSocket, text: String) {
lastLiveness = TimeSource.Monotonic.markNow()
val frame = try {
IrisJson.instance.decodeFromString(Frame.serializer(), text)
} catch (_: Exception) {
return
}
when (frame.type) {
TYPE_HELLO_ACK -> {
val ack = frame.payloadAs<HelloAckPayload>()
if (ack != null) helloAck.complete(ack)
}
TYPE_ERROR -> {
val err = frame.payloadAs<ErrorPayload>()
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed")
// M7: post-connect error frames are app events, not
// auth failures — let the controller react.
_events.tryEmit(frame)
}
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
}
}
}
}
}
}
/** The SSE `event: hello` (the HTTP hello.ack). */
private fun onHttpHello(ack: HelloAckPayload) {
lastAck = ack
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
_state.value = connected
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
val winner = CompletableDeferred<DialResult>()
helloAck.invokeOnCompletion { e ->
if (e == null) {
val ack = helloAck.getCompleted()
_state.value = State.Connected(ack.serverCaps, ack.channels)
// M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor
if (local < ack.syncCursor) {
val id = nextId()
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
val id = nextRequestId++
ws.send(syncFrame(id, local).toWire())
}
// 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})")
winner.complete(DialResult.Connected)
}
}
/** 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)")
authError.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
}
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")
return Dial(result, ws, closed)
}
// ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back).
* [mediaRefs] reference completed uploads (POST /v1/media refs).
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs).
* [autoThread] asks the gateway to mint a fresh thread for the message
* (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. */
* (auto-threading, docs/06 §6.3). */
fun sendMessage(
chatId: String,
text: String,
threadId: String? = null,
mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
onResult: ((Int) -> Unit)? = null,
) {
if (_state.value !is State.Connected) {
onResult?.invoke(0)
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)
}
val ws = socket ?: return
val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
}
// ── Media upload / pull ───────────────────────────────────────────────
// ── M4: media upload / pull ───────────────────────────────────────────
/**
* Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
* Returns the server's media_ref (for message.send media_refs) on success.
* Upload a local file as media (docs/07 §7.2): media.upload.start,
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's
* media_ref (for message.send media_refs) on success.
*/
suspend fun uploadMedia(
path: String,
@@ -461,135 +324,163 @@ class GatewayClient(
filename: String,
mediaRef: String,
): Result<String> {
val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
return http.uploadMedia(path, mime, kind, filename, mediaRef)
val ws = socket ?: return Result.failure(IllegalStateException("not connected"))
val source = FileSource(path)
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 via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
* chunk is handed to [onChunk] (write to cache).
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until
* media.pull.end. Each 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 {
val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected"))
val id = nextRequestId++
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 {
/** No frames (or keep-alive comments) from the receive stream for
* this long (gateway still
* healthy) = stale stream: force a fresh (re)connect. 3 min keeps an
* 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
/** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */
const val UPLOAD_CHUNK_BYTES = 256 * 1024
const val UPLOAD_TIMEOUT_MS = 120_000L
const val PULL_TIMEOUT_MS = 300_000L
}
/**
* Send an arbitrary frame with a fresh request id (fire-and-forget). The
* server replies (or broadcasts) a frame carrying the same id; the app
* reconciles from [events]. Returns the id used, or -1 if not connected.
* Send an arbitrary frame with a fresh request id (fire-and-forget).
* The server replies (or broadcasts) a frame carrying the same id; the
* app reconciles from [events]. Returns the id used, or -1 if not connected.
*/
fun sendFrame(frame: Frame): Int {
if (_state.value !is State.Connected) return -1
val id = nextId()
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)
}
val ws = socket ?: return -1
val id = nextRequestId++
ws.send(frame.copy(id = id).toWire())
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) ──────────────────────────────
/**
* Real connection test: health probe + SSE open. The auth leg is proven
* 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).
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
* Exercises the auth leg, not just TCP (docs/10 §10.8).
*/
suspend fun testHello(
url: String,
token: String,
): Result<Unit> {
val gw =
HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
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 {
suspend fun testHello(url: String, token: String): Result<Unit> {
val dial = dial(url, token)
return when (val result = dial.result) {
DialResult.Connected -> {
dial.socket.close(1000, "test complete")
Result.success(Unit)
}
} catch (e: HttpGateway.HttpAuthException) {
Result.failure(IllegalStateException("unauthorized — check the pairing token"))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(IllegalStateException("connection failed: ${e.message}"))
} finally {
job.cancel()
is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message))
is DialResult.Failed -> Result.failure(IllegalStateException(result.message))
}
}
// ── Heartbeat job ─────────────────────────────────────────────────────
fun startHeartbeat() {
scope.launch {
while (isActive) {
delay(20_000)
if (_state.value is State.Connected) {
ping()
reapStale()
}
}
} catch (e: Exception) {
Result.failure(e)
}
}
// ── 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 {
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500)
}
private fun nowMs(): Long = nowMillis()
}
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
@@ -1,499 +0,0 @@
package iris.net
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,
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) }
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()
@@ -35,49 +35,21 @@ expect fun mediaCacheBaseDir(): String
expect suspend fun loadMediaImage(path: String): ImageBitmap?
/** 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?
/**
* 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?
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
/**
* 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
* 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. */
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable
expect fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
)
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
/** 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
* foreground (to decide between an in-app banner and a system notification)
* and to post a system notification when a `notification` frame arrives while
* the app is backgrounded but the gateway connection is still live.
* the app is backgrounded but the WS is still live.
*/
/** True when the app's UI is visible (Android: activity resumed). */
@@ -29,12 +29,3 @@ expect fun postSystemNotification(
* FCM / ntfy services can reach it). No-op on desktop.
*/
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)
@@ -1,13 +0,0 @@
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)
@@ -1,13 +0,0 @@
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,16 +12,15 @@ import kotlinx.serialization.json.put
/**
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
* See docs/04-wire-protocol.md. Defines all frame types and payloads:
* hello/hello.ack, message (send + streaming), tool cards, commentary,
* reasoning, channels, media, notifications, sync, and error frames.
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
* progress/end, commentary, reasoning (on message / message.stop).
*/
const val PROTOCOL_VERSION = 1
object IrisJson {
val instance: Json =
Json {
val instance: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
isLenient = true
@@ -30,10 +29,13 @@ object IrisJson {
// ── Frame type constants ────────────────────────────────────────────────
const val TYPE_HELLO = "hello"
const val TYPE_HELLO_ACK = "hello.ack"
const val TYPE_MESSAGE = "message"
const val TYPE_MESSAGE_SEND = "message.send"
const val TYPE_ERROR = "error"
const val TYPE_PING = "ping"
const val TYPE_PONG = "pong"
const val TYPE_TYPING = "typing"
// M2 — streaming / tools / commentary
@@ -47,16 +49,15 @@ const val TYPE_MESSAGE_DELETED = "message.deleted"
const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress"
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"
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
// M4 — media (upload / offer / pull)
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_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
const val TYPE_NOTIFICATION = "notification"
@@ -70,7 +71,6 @@ const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
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_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -78,22 +78,24 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted"
const val TYPE_CHANNEL_LIST = "channel.list"
const val TYPE_SEARCH = "search"
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_DONE = "sync.done"
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)
const val KIND_IMAGE = "image"
const val KIND_AUDIO = "audio"
const val KIND_VIDEO = "video"
const val KIND_DOCUMENT = "document"
const val KIND_VOICE = "voice"
// ── Roles ───────────────────────────────────────────────────────────────
@@ -109,11 +111,6 @@ data class Frame(
val type: String,
@SerialName("chat_id") val chatId: 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()),
) {
/** Payload as a JSON object (the wire format); parse per-type with
@@ -126,6 +123,18 @@ 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) ───────────────────────────────────────────
@Serializable
@@ -135,8 +144,6 @@ data class ServerCaps(
val tools: Boolean = false,
val media: 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",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false,
@@ -161,10 +168,6 @@ data class ChannelInfo(
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
* auto-generated name-hash color. */
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
@@ -172,30 +175,10 @@ data class HelloAckPayload(
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
@SerialName("sync_cursor") val syncCursor: Long = 0,
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,
)
// ── 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
data class MessagePayload(
@SerialName("message_id") val messageId: String,
@@ -205,7 +188,6 @@ data class MessagePayload(
@SerialName("reply_to") val replyTo: String? = null,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null,
val media: List<MediaRef> = emptyList(),
)
@@ -222,6 +204,21 @@ data class MediaRef(
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
data class MediaUploadAckPayload(
val ok: Boolean,
@@ -238,6 +235,14 @@ data class MediaOfferPayload(
@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) ────────────────────────────────
@Serializable
@@ -259,7 +264,6 @@ data class MessageStopPayload(
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null,
)
@@ -271,9 +275,6 @@ data class ToolStartPayload(
val name: String,
val preview: String? = 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
@@ -292,30 +293,6 @@ data class ToolEndPayload(
@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) ────────────────────────────────
@Serializable
@@ -336,18 +313,16 @@ data class MessageSendPayload(
@SerialName("auto_thread") val autoThread: Boolean = false,
)
// ── typing / error ──────────────────────────────────────────────────────
// ── typing / error / ping ───────────────────────────────────────────────
@Serializable
data class TypingPayload(
val on: Boolean,
)
data class TypingPayload(val on: Boolean)
@Serializable
data class ErrorPayload(
val code: String,
val message: String,
)
data class ErrorPayload(val code: String, val message: String)
@Serializable
data class PingPayload(val ts: Long? = null)
// ── M3: channel directory (app -> server requests) ──────────────────────
@@ -359,19 +334,10 @@ data class ChannelCreatePayload(
)
@Serializable
data class ChannelRenamePayload(
val name: String,
)
data class ChannelRenamePayload(val name: String)
@Serializable
data class ChannelFavoritePayload(
val on: Boolean,
)
@Serializable
data class ChannelSetAutomationPayload(
val on: Boolean,
)
data class ChannelFavoritePayload(val on: Boolean)
@Serializable
data class ChannelIconPayload(
@@ -382,14 +348,10 @@ data class ChannelIconPayload(
)
@Serializable
data class ChannelListPayload(
val channels: List<ChannelInfo> = emptyList(),
)
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
@Serializable
data class ChannelDeletedPayload(
@SerialName("chat_id") val chatId: String,
)
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String)
// ── M3: search ──────────────────────────────────────────────────────────
@@ -419,56 +381,13 @@ data class SearchResultsPayload(
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) ───────────────────────────────────────
@Serializable
data class SyncPayload(
val cursor: Long,
)
data class SyncPayload(val cursor: Long)
@Serializable
data class SyncDonePayload(
val cursor: Long,
)
data class SyncDonePayload(val cursor: Long)
// ── history (full message history for a chat/thread) ────────────────────
@@ -481,12 +400,8 @@ data class HistoryMessage(
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null,
// 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,
val media: List<MediaRef> = emptyList(),
)
@Serializable
@@ -507,9 +422,12 @@ data class MessageDeletedPayload(
// ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */
const val NOTIF_MESSAGE = "message"
const val NOTIF_APPROVAL = "approval"
const val NOTIF_CLARIFY = "clarify"
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). */
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
@@ -538,12 +456,31 @@ data class ReadReceiptPayload(
)
@Serializable
data class StatusPayload(
val state: String,
)
data class StatusPayload(val state: String)
// ── 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(
id: Int,
chatId: String,
@@ -557,107 +494,68 @@ fun messageSendFrame(
type = TYPE_MESSAGE_SEND,
chatId = chatId,
threadId = threadId,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
),
)
fun pingFrame(): Frame =
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── 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(
id = id,
type = TYPE_CHANNEL_CREATE,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
ChannelCreatePayload.serializer(),
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(
id = id,
type = TYPE_CHANNEL_RENAME,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
ChannelRenamePayload.serializer(),
ChannelRenamePayload(name = name),
),
)
fun channelSetDefaultFrame(
id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelSetDefaultFrame(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(
id = id,
type = TYPE_CHANNEL_FAVORITE,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
ChannelFavoritePayload.serializer(),
ChannelFavoritePayload(on = on),
),
)
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 =
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_ICON,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
ChannelIconPayload.serializer(),
ChannelIconPayload(icon = icon, color = color),
),
)
fun channelDeleteFrame(
id: Int,
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
fun channelDeleteFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
fun channelListFrame(id: Int): Frame = Frame(id = id, type = TYPE_CHANNEL_LIST)
fun channelListFrame(id: Int): Frame =
Frame(id = id, type = TYPE_CHANNEL_LIST)
fun searchFrame(
id: Int,
@@ -671,44 +569,17 @@ fun searchFrame(
type = TYPE_SEARCH,
chatId = chatId,
threadId = threadId,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
SearchPayload.serializer(),
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
),
)
/** 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 =
fun syncFrame(id: Int, cursor: Long): Frame =
Frame(
id = id,
type = TYPE_SYNC,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
SyncPayload.serializer(),
SyncPayload(cursor = cursor),
),
@@ -729,8 +600,7 @@ fun historyFrame(
type = TYPE_HISTORY,
chatId = chatId,
threadId = threadId,
payload =
buildJsonObject {
payload = buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit)
},
@@ -749,22 +619,56 @@ fun messageDeleteFrame(
type = TYPE_MESSAGE_DELETE,
chatId = chatId,
threadId = threadId,
payload =
buildJsonObject {
payload = buildJsonObject {
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
},
)
// ── M5 frame builders ───────────────────────────────────────────────────
// ── M4 frame builders ────────────────────────────────────────────────────
fun fcmRegisterFrame(
fcmToken: String? = null,
ntfyTopic: String? = null,
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 ───────────────────────────────────────────────────
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
Frame(
type = TYPE_FCM_REGISTER,
payload =
IrisJson.instance.encodeToJsonElement(
payload = IrisJson.instance.encodeToJsonElement(
FcmRegisterPayload.serializer(),
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
),
File diff suppressed because it is too large. Load diff
@@ -157,9 +157,7 @@ fun MarkdownText(
ordered = base,
bullet = base,
list = base,
textLink = TextLinkStyles(
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
),
link = base.copy(textDecoration = TextDecoration.Underline),
table = base.copy(fontSize = fontSize * 0.95f),
),
components = markdownComponents(
@@ -209,7 +207,7 @@ internal fun CodeBlockWithCopy(
) {
val clipboard = LocalClipboardManager.current
Box {
MarkdownHighlightedCode(code = code, language = language, highlightsBuilder = highlights, style = style)
MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style)
if (code.isNotBlank()) {
IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) },
File diff suppressed because it is too large. Load diff
@@ -27,11 +27,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import iris.platform.QrScanButton
import iris.platform.isDesktop
import iris.state.IrisController
import iris.ui.theme.IrisColors
import iris.util.PairLink
import kotlinx.coroutines.launch
/**
@@ -46,16 +43,13 @@ fun ConnectScreen(
initialError: String? = null,
) {
val scope = rememberCoroutineScope()
// 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 url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) }
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
@@ -72,8 +66,7 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(32.dp))
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface)
@@ -83,8 +76,7 @@ fun ConnectScreen(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
// Example LAN URL; https:// works too for TLS gateways.
placeholder = { Text("http://192.168.1.10:8791") },
placeholder = { Text("ws://192.168.1.10:8790/ws") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
@@ -94,25 +86,11 @@ fun ConnectScreen(
value = token,
onValueChange = { token = it },
label = { Text("Pairing token") },
placeholder = { Text("IRIS_TOKEN (64 hex)") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
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))
Button(
@@ -146,10 +124,11 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp))
Text(
"Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
"hermes gateway setup on the gateway host.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -45,12 +44,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
@@ -60,34 +56,24 @@ import kotlin.math.roundToInt
* Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time.
*/
/** Which color the picker dialog is editing. */
private enum class PickerTarget { UserBubble, AgentBubble, Background }
@Composable
fun SettingsScreen(
controller: IrisController,
onBack: () -> Unit,
) {
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.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 fontSizeScale by controller.fontSizeScale.collectAsState()
val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -162,39 +148,6 @@ fun SettingsScreen(
)
}
}
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(
"Appearance",
@@ -265,36 +218,14 @@ fun SettingsScreen(
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
}
} 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))
Row(verticalAlignment = Alignment.CenterVertically) {
BackgroundPreview(theme.backgroundImagePath)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
if (theme.backgroundImagePath.isNullOrBlank()) {
"No image selected"
} else {
Backdrop.fromPath(theme.backgroundImagePath)?.name
?: fileNameOf(theme.backgroundImagePath!!)
},
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
else fileNameOf(theme.backgroundImagePath!!),
fontSize = 12.sp,
color = IrisColors.textDim,
)
@@ -328,46 +259,6 @@ fun SettingsScreen(
}
}
}
SettingsCard {
Text("🔤 Font size", fontSize = 14.sp)
Text(
"Scale the text across the app",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Aa", fontSize = 12.sp, color = IrisColors.textDim)
Slider(
value = fontSizeScale,
onValueChange = { controller.setFontSizeScale(it) },
valueRange = IrisController.FONT_SCALE_MIN..IrisController.FONT_SCALE_MAX,
modifier = Modifier.weight(1f),
)
Text("Aa", fontSize = 18.sp, color = IrisColors.textDim)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"The quick brown fox jumps over the lazy dog",
fontSize = 13.sp,
color = IrisColors.textSecondary,
modifier = Modifier.weight(1f),
)
Text(
"${(fontSizeScale * 100).roundToInt()}%",
fontSize = 12.sp,
color = IrisColors.textDim,
)
if (fontSizeScale != 1.0f) {
TextButton(onClick = { controller.setFontSizeScale(1.0f) }) {
Text("Reset", fontSize = 12.sp)
}
}
}
}
Text(
"Tools",
@@ -392,29 +283,10 @@ fun SettingsScreen(
}
}
}
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)
}
}
}
when (pickerTarget) {
PickerTarget.UserBubble -> {
ColorPickerDialog(
PickerTarget.UserBubble -> ColorPickerDialog(
title = "Your bubble",
initialArgb = theme.userBubbleColor,
onApply = { controller.setUserBubbleColor(it) },
@@ -424,10 +296,7 @@ fun SettingsScreen(
},
onDismiss = { pickerTarget = null },
)
}
PickerTarget.AgentBubble -> {
ColorPickerDialog(
PickerTarget.AgentBubble -> ColorPickerDialog(
title = "Agent bubble",
initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) },
@@ -437,10 +306,7 @@ fun SettingsScreen(
},
onDismiss = { pickerTarget = null },
)
}
PickerTarget.Background -> {
ColorPickerDialog(
PickerTarget.Background -> ColorPickerDialog(
title = "Background",
initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) },
@@ -450,51 +316,17 @@ fun SettingsScreen(
},
onDismiss = { pickerTarget = null },
)
}
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")
}
},
)
null -> Unit
}
}
}
/** Material-style back arrow (vector, matches the app's icon weight). */
@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)) {
val s = this.size.width / 24f
val path =
Path().apply {
val path = Path().apply {
moveTo(20f * s, 11f * s)
lineTo(7.83f * s, 11f * s)
lineTo(13.42f * s, 5.41f * s)
@@ -514,8 +346,7 @@ private fun BackArrow(
@Composable
private fun SettingsCard(content: @Composable () -> Unit) {
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(IrisColors.panel)
@@ -527,13 +358,9 @@ private fun SettingsCard(content: @Composable () -> Unit) {
/** Clickable rounded chip showing a color; opens the picker on tap. */
@Composable
private fun ColorSwatch(
color: Color,
onClick: () -> Unit,
) {
private fun ColorSwatch(color: Color, onClick: () -> Unit) {
Box(
modifier =
Modifier
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
@@ -547,21 +374,10 @@ private fun ColorSwatch(
private fun BackgroundPreview(path: String?) {
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(path) {
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)
}
}
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
}
Box(
modifier =
Modifier
modifier = Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel)
@@ -580,43 +396,6 @@ 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).
* [onApply] applies the color and keeps the dialog open for further tweaks;
@@ -641,8 +420,7 @@ private fun ColorPickerDialog(
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
@@ -685,8 +463,7 @@ private fun argbToHsv(argb: Int): FloatArray {
val delta = max - min
var hue = 0f
if (delta != 0f) {
hue =
when (max) {
hue = when (max) {
r -> ((g - b) / delta) % 6f
g -> (b - r) / delta + 2f
else -> (r - g) / delta + 4f
@@ -699,15 +476,5 @@ private fun argbToHsv(argb: Int): FloatArray {
}
/** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String = 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
}
private fun fileNameOf(path: String): String =
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
@@ -1,41 +0,0 @@
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,9 +11,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
/**
* Centralized palette (M7). Dark is the only theme; every screen reads colors
@@ -35,6 +33,8 @@ object IrisColors {
val textDim = Color(0xFF8A93A6)
// Bubbles
val bubbleUser = primary
val bubbleAssistant = Color(0xFF2A2E3B)
val bubbleCommentary = Color(0xFF23262F)
// Panels, chips, rows
@@ -45,6 +45,7 @@ object IrisColors {
val divider = Color(0xFF2A2E3B)
// Status
val statusGrey = Color(0xFF9E9E9E)
val statusAmber = Color(0xFFFFC107)
val statusGreen = Color(0xFF4CAF50)
val statusRed = Color(0xFFF44336)
@@ -53,8 +54,7 @@ object IrisColors {
val errorText = Color(0xFFFF8A80)
}
private val IrisDark =
darkColorScheme(
private val IrisDark = darkColorScheme(
background = IrisColors.background,
surface = IrisColors.surface,
onBackground = IrisColors.onBackground,
@@ -63,7 +63,7 @@ private val IrisDark =
primary = IrisColors.primary,
onPrimary = Color.White,
error = IrisColors.statusRed,
)
)
@Composable
fun IrisTheme(content: @Composable () -> Unit) {
@@ -85,11 +85,9 @@ fun avatarColor(name: String): Color {
data class UserTheme(
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
// Fresh installs start with the default bundled backdrop (Settings →
// Appearance to change it).
val backgroundMode: BackgroundMode = BackgroundMode.Image,
val backgroundMode: BackgroundMode = BackgroundMode.Color,
val backgroundColor: Int = DEFAULT_BACKGROUND,
val backgroundImagePath: String? = Backdrop.DEFAULT.path,
val backgroundImagePath: String? = null,
) {
val userBubble: Color get() = Color(userBubbleColor)
val agentBubble: Color get() = Color(agentBubbleColor)
@@ -116,10 +114,8 @@ fun Color.contrastText(): Color {
/**
* Decodes the theme's background image (image mode only), scaled to at most
* 2048 px on the longest edge. Bundled backdrops (`backdrop://<id>`) are read
* 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.
* 2048 px on the longest edge. Null in color mode or on decode failure — the
* caller then falls back to the solid background color.
*/
@Composable
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
@@ -127,18 +123,7 @@ fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
var image by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(wantImage, path) {
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)
}
}
image = if (wantImage) loadScaledImage(path!!, 2048) else null
}
return image
}
@@ -1,29 +0,0 @@
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
}
@@ -1,13 +0,0 @@
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,13 +14,12 @@ fun String.prepareForMarkdown(): String {
}
/**
* Converts single newlines into markdown hard breaks (two trailing spaces) so
* they render as line breaks instead of being collapsed into spaces by the
* parser (GFM treats a lone newline inside a paragraph as a space). Applied to
* both user input (lazy line breaks) and agent replies (models write status
* lines and wrapped text expecting a break per line). Lines inside fenced code
* blocks (``` / ~~~) are left untouched so the code content is not altered;
* blank lines are kept as-is since they already produce paragraph breaks.
* Converts the single newlines a user typed into markdown hard breaks (two
* trailing spaces) so they render as line breaks instead of being collapsed
* into spaces by the parser (GFM treats a lone newline inside a paragraph as a
* space). Lines inside fenced code 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 {
val lines = split("\n")
@@ -1,97 +0,0 @@
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,3 +11,9 @@ expect fun localDayKey(epochMillis: Long): String
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
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() }
}
@@ -1,77 +0,0 @@
-- 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;
@@ -1,9 +0,0 @@
-- 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)
);
@@ -1,155 +0,0 @@
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),
),
)
}
@@ -1,108 +0,0 @@
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)
}
}
@@ -1,70 +0,0 @@
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)
}
}
@@ -1,203 +0,0 @@
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
@@ -1,50 +0,0 @@
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,
)
}
}
@@ -1,19 +0,0 @@
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)
}
}
@@ -1,42 +0,0 @@
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"))
}
}
@@ -39,26 +39,4 @@ class MarkdownTest {
fun allBlankBecomesEmpty() {
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())
}
}
@@ -1 +0,0 @@
{"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,7 +1,6 @@
package iris.platform
import iris.state.IrisController
import java.util.concurrent.CopyOnWriteArrayList
/**
* M6: bridge between the desktop shell (tray / window) and the shared
@@ -13,35 +12,19 @@ typealias NotificationListener =
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
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
var foreground: Boolean = true
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
@Volatile
var controller: IrisController? = null
// 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>()
private val notificationListeners = mutableListOf<NotificationListener>()
fun onNotification(listener: NotificationListener) {
notificationListeners.add(listener)
}
fun notifyListeners(
chatId: String?,
title: String,
body: String,
threadId: String?,
) {
notificationListeners.forEach { it(chatId, title, body, threadId) }
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) {
notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
}
}
@@ -1,6 +1,5 @@
package iris.platform
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -9,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.IconButton
import androidx.compose.material3.Slider
@@ -30,18 +31,7 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.StandardProtocolFamily
@@ -52,6 +42,17 @@ import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO
import javax.swing.JFileChooser
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
/**
@@ -60,18 +61,24 @@ import kotlin.random.Random
*/
@Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
var busy by remember { mutableStateOf(false) }
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text("Attach a file:", fontSize = 13.sp)
Spacer(modifier = Modifier.height(8.dp))
// pickFile() blocks the UI thread (JFileChooser is modal), so there's
// no in-flight state to show while the dialog is open.
Button(onClick = { onPicked(pickFile()) }) {
Text("Choose file…")
Button(
onClick = {
busy = true
val picked = pickFile()
busy = false
onPicked(picked)
},
enabled = !busy,
) {
Text(if (busy) "Choosing…" else "Choose file…")
}
}
}
@@ -91,8 +98,7 @@ private fun pickFile(): PickedFile? {
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = {
val chooser =
JFileChooser().apply {
val chooser = JFileChooser().apply {
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
}
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
@@ -129,8 +135,7 @@ private fun guessMime(name: String): String {
"mov" -> "video/quicktime"
"mkv" -> "video/x-matroska"
"mp3" -> "audio/mpeg"
"m4a" -> "audio/mp4"
"aac" -> "audio/aac"
"m4a", "aac" -> "audio/mp4"
"ogg", "opus" -> "audio/ogg"
"wav" -> "audio/wav"
"flac" -> "audio/flac"
@@ -146,21 +151,16 @@ actual fun mediaCacheBaseDir(): String {
return dir.absolutePath
}
actual suspend fun loadMediaImage(path: String): ImageBitmap? =
withContext(Dispatchers.IO) {
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
img.toComposeImageBitmap()
} catch (_: Exception) {
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 {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
@@ -178,46 +178,9 @@ actual suspend fun loadScaledImage(
} catch (_: Exception) {
null
}
}
}
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) {
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
@@ -229,27 +192,21 @@ actual suspend fun encodeImageAsBase64(
g.dispose()
val baos = ByteArrayOutputStream()
ImageIO.write(scaled, "png", baos)
java.util.Base64
.getEncoder()
.encodeToString(baos.toByteArray())
java.util.Base64.getEncoder().encodeToString(baos.toByteArray())
} catch (_: Exception) {
null
}
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
withContext(Dispatchers.IO) {
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
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
img.toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
}
/**
* M6: inline media playback backed by mpv (docs/11 §11.2).
@@ -262,10 +219,7 @@ actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
* - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
*/
@Composable
actual fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
) {
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
val path = media.localPath
if (path == null || !File(path).exists()) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -292,12 +246,7 @@ private object Mpv {
val available: Boolean by lazy {
try {
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)
found
} catch (_: Exception) {
@@ -307,10 +256,7 @@ private object Mpv {
}
@Composable
private fun AudioPlayer(
media: MediaItem,
path: String,
) {
private fun AudioPlayer(media: MediaItem, path: String) {
val session = remember(path) { MpvAudioSession(path) }
var paused by remember { mutableStateOf(true) }
var position by remember { mutableStateOf(0.0) }
@@ -332,8 +278,7 @@ private fun AudioPlayer(
}
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E))
@@ -364,14 +309,10 @@ private fun AudioPlayer(
}
@Composable
private fun VideoPlayer(
media: MediaItem,
path: String,
) {
private fun VideoPlayer(media: MediaItem, path: String) {
var process by remember { mutableStateOf<Process?>(null) }
Column(
modifier =
Modifier
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E))
@@ -411,9 +352,7 @@ private fun fmtTime(sec: Double): String {
* An mpv process + its JSON IPC socket (unix domain). Commands carry a
* [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 channel: SocketChannel? = null
private val socketPath =
@@ -423,14 +362,9 @@ private class MpvAudioSession(
fun start() {
try {
process =
ProcessBuilder(
"mpv",
"--no-video",
"--really-quiet",
"--no-terminal",
"--input-ipc-server=$socketPath",
path,
process = ProcessBuilder(
"mpv", "--no-video", "--really-quiet", "--no-terminal",
"--input-ipc-server=$socketPath", path,
).redirectErrorStream(true).start()
connectIpc()
} catch (_: Exception) {
@@ -472,24 +406,9 @@ private class MpvAudioSession(
} catch (_: Exception) {
}
}
answers["p1"]
?.get("data")
?.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 }
answers["p1"]?.get("data")?.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) {
}
return Triple(pos, dur, pause)
@@ -521,13 +440,9 @@ private class MpvAudioSession(
}
}
private fun send(
cmd: List<String>,
requestId: String?,
) {
private fun send(cmd: List<String>, requestId: String?) {
val ch = channel ?: return
val obj =
buildJsonObject {
val obj = buildJsonObject {
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
requestId?.let { put("request_id", JsonPrimitive(it)) }
}
@@ -556,24 +471,14 @@ private class MpvAudioSession(
}
}
actual fun openDocument(
path: String,
mime: String,
) {
actual fun openDocument(path: String, mime: String) {
try {
val os = System.getProperty("os.name").lowercase()
when {
os.contains("mac") -> {
ProcessBuilder("open", path).start()
}
os.contains("win") -> {
os.contains("mac") -> ProcessBuilder("open", path).start()
os.contains("win") ->
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
}
else -> {
ProcessBuilder("xdg-open", path).start()
}
else -> ProcessBuilder("xdg-open", path).start()
}
} catch (_: Exception) {
}
@@ -19,10 +19,6 @@ actual fun setActiveController(controller: Any?) {
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
* platform notifier cover the "backgrounded" leg. Shells out to
@@ -30,57 +26,34 @@ actual fun syncNtfyListener(backend: String) {
* Best effort — failures are ignored (the in-app banner is the primary path).
*/
object DesktopNotifier {
fun post(
title: String,
body: String,
) {
fun post(title: String, body: String) {
try {
val os = System.getProperty("os.name").lowercase()
val cmd =
when {
os.contains("linux") -> {
val cmd = when {
os.contains("linux") ->
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
}
os.contains("mac") -> {
os.contains("mac") ->
listOf(
"osascript",
"-e",
"osascript", "-e",
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
)
}
else -> {
else ->
listOf(
"powershell",
"-NoProfile",
"-Command",
"powershell", "-NoProfile", "-Command",
"Add-Type -AssemblyName System.Windows.Forms; " +
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
"\$n.Visible = \$true; " +
"\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
"[System.Windows.Forms.ToolTipIcon]::Info); " +
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
)
}
}
ProcessBuilder(cmd).redirectErrorStream(true).start()
} catch (_: Exception) {
}
}
private fun esc(s: String): String = 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", " ")
private fun esc(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
}
@@ -2,16 +2,14 @@ package iris.platform
import iris.data.SecureStore
import iris.protocol.IrisJson
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import kotlinx.serialization.Serializable
import java.io.File
import java.security.SecureRandom
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.Serializable
/**
* M6: desktop pairing storage (docs/11 §11.2).
@@ -29,12 +27,6 @@ class DesktopSecureStore : SecureStore {
private val legacyFile = File(baseDir, "pairing.json")
private val secret = SecretBackend(baseDir)
// 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
private data class Settings(
val serverUrl: String = "",
@@ -43,19 +35,15 @@ class DesktopSecureStore : SecureStore {
val fcmToken: String = "",
val ntfyTopic: String = "",
val ntfyServer: String = "",
val pushBackend: String = "",
val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true,
val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
val backgroundMode: String = BackgroundMode.Image.name.lowercase(),
val backgroundMode: String = "color",
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = Backdrop.DEFAULT.path,
val fontSizeScale: Float = 1.0f,
val runtimeFooterEnabled: Boolean = false,
val runtimeFooterFields: String = "",
val backgroundImagePath: String = "",
)
init {
@@ -64,8 +52,7 @@ class DesktopSecureStore : SecureStore {
private fun migrateLegacy() {
if (!legacyFile.exists()) return
val legacy =
try {
val legacy = try {
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
} catch (_: Exception) {
null
@@ -83,15 +70,11 @@ class DesktopSecureStore : SecureStore {
),
)
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()
}
}
private fun load(): Settings {
cached?.let { return it }
val s =
private fun load(): Settings =
if (settingsFile.exists()) {
try {
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
@@ -101,14 +84,10 @@ class DesktopSecureStore : SecureStore {
} else {
Settings()
}
cached = s
return s
}
private fun save(data: Settings) {
baseDir.mkdirs()
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
cached = data
}
override var serverUrl: String
@@ -128,13 +107,7 @@ class DesktopSecureStore : SecureStore {
get() {
val d = load()
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))
return id
}
@@ -170,13 +143,6 @@ class DesktopSecureStore : SecureStore {
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
get() = load().threadsEnabled
set(value) {
@@ -220,7 +186,7 @@ class DesktopSecureStore : SecureStore {
}
override var backgroundMode: String
get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
get() = load().backgroundMode.ifBlank { "color" }
set(value) {
val d = load()
save(d.copy(backgroundMode = value))
@@ -240,53 +206,15 @@ class DesktopSecureStore : SecureStore {
save(d.copy(backgroundImagePath = value))
}
override var fontSizeScale: Float
get() = load().fontSizeScale
set(value) {
val d = load()
save(d.copy(fontSizeScale = value))
}
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 fun savePairing(
url: String,
token: String,
) {
override fun savePairing(url: String, token: String) {
val d = load()
save(d.copy(serverUrl = url.trim()))
if (token.isBlank()) secret.clear() else secret.write(token.trim())
}
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()
save(
d.copy(
serverUrl = "",
deviceId = "",
syncCursor = 0L,
fcmToken = "",
ntfyTopic = "",
ntfyServer = "",
pushBackend = "",
),
)
save(d.copy(serverUrl = ""))
secret.clear()
}
}
@@ -307,9 +235,7 @@ private data class PairingData(
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
* All backend failures degrade to the encrypted file (never plaintext).
*/
private class SecretBackend(
private val baseDir: File,
) {
private class SecretBackend(private val baseDir: File) {
private val encFile = File(baseDir, "pairing.enc")
private val keyFile = File(baseDir, ".key")
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
@@ -319,12 +245,7 @@ private class SecretBackend(
fun write(value: String) {
if (keyring != null) {
keyring.write(value)
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
}
if (keyring.read() == value) return
// Keyring accepted the write but did not persist it (e.g. KWallet
// without a live daemon). Drop the stale entry and fall back to
// the encrypted file so the token survives a restart.
@@ -355,12 +276,8 @@ private class SecretBackend(
private fun writeEncrypted(value: String) {
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 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))
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
baseDir.mkdirs()
@@ -376,9 +293,7 @@ private class SecretBackend(
if (bytes.size < 28) return null
val iv = bytes.copyOfRange(0, 12)
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")
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
String(cipher.doFinal(ct), Charsets.UTF_8)
} catch (_: Exception) {
@@ -395,17 +310,12 @@ private class KeyringBackend {
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
val available: Boolean by lazy {
if (isMac) {
true
} else if (isLinux) {
which("secret-tool") != null
} else {
false
}
if (isMac) true
else if (isLinux) which("secret-tool") != null
else false
}
fun read(): String? =
try {
fun read(): String? = try {
if (isMac) {
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
} else {
@@ -417,28 +327,16 @@ private class KeyringBackend {
fun write(value: String) {
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) {
listOf(
"security",
"add-generic-password",
"-U",
"-a",
"iris",
"-s",
"iris-gateway-token",
"-w",
)
ProcessBuilder(
"security", "add-generic-password", "-U",
"-a", "iris", "-s", "iris-gateway-token", "-w", value,
).inheritIO().start().waitFor()
} else {
listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris")
}
ProcessBuilder(cmd).start().apply {
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
waitFor()
ProcessBuilder(
"secret-tool", "store", "--label=Iris gateway token",
"app", "iris", "token", value,
).inheritIO().start().waitFor()
}
} catch (_: Exception) {
}
@@ -448,19 +346,12 @@ private class KeyringBackend {
try {
if (isMac) {
ProcessBuilder(
"security",
"delete-generic-password",
"-a",
"iris",
"-s",
"iris-gateway-token",
"security", "delete-generic-password",
"-a", "iris", "-s", "iris-gateway-token",
).inheritIO().start().waitFor()
} else {
ProcessBuilder(
"secret-tool",
"clear",
"app",
"iris",
"secret-tool", "clear", "app", "iris",
).inheritIO().start().waitFor()
}
} catch (_: Exception) {
@@ -469,23 +360,14 @@ private class KeyringBackend {
private fun out(cmd: List<String>): String? {
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
val text =
p.inputStream
.bufferedReader()
.readText()
.trim()
val text = p.inputStream.bufferedReader().readText().trim()
p.waitFor()
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 text =
p.inputStream
.bufferedReader()
.readText()
.trim()
val text = p.inputStream.bufferedReader().readText().trim()
p.waitFor()
text.ifBlank { null }
} catch (_: Exception) {
@@ -1,44 +0,0 @@
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(
builder = {
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
// Pinned to the JBR 17 release the KCEF 2025.03.23 line is
// Pinned to the JBR 17 release the KCEF 2024.04.20.x line was
// built against; the default "latest" JBR may be JDK 21-based
// and is not guaranteed compatible.
download {
github { release("jbr-release-17.0.14b1367.22") }
github { release("jbr-release-17.0.12b1207.37") }
}
settings {
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
@@ -1,12 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

@@ -1,97 +0,0 @@
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()
}
}
}
@@ -0,0 +1,16 @@
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,44 +5,22 @@ import java.io.FileOutputStream
actual interface MediaWriter : AutoCloseable {
actual val path: String
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 mediaDir: File = File(baseDir, "media").apply { mkdirs() }
/**
* 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
actual fun path(mediaId: String, mime: String): String? {
val f = File(mediaDir, "$mediaId${extForMime(mime)}")
return if (f.exists()) f.absolutePath else null
}
actual fun openWriter(
mediaId: String,
mime: String,
): MediaWriter = JvmMediaWriter(fileFor(mediaId, mime) ?: throw IllegalArgumentException("invalid media id"), this)
actual fun openWriter(mediaId: String, mime: String): MediaWriter =
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this)
actual fun remove(
mediaId: String,
mime: String,
) {
actual fun remove(mediaId: String, mime: String) {
path(mediaId, mime)?.let { File(it).delete() }
}
@@ -58,10 +36,7 @@ actual class MediaCache actual constructor(
}
}
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"))
override val path: String get() = target.absolutePath
@@ -1,221 +0,0 @@
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"))
}
}
@@ -1,89 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

+5 -5
View File
@@ -46,7 +46,7 @@ Everything in the feature checklist below.
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
| Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message |
| Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble |
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=iris:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=android:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
| Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle |
| Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview |
| Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service |
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
| Decision | Choice |
|---|---|
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
| Push backend | **Both** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) |
| Push backend | **Both** — FCM primary, ntfy fallback (`ANDROID_PUSH_BACKEND`) |
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
@@ -66,7 +66,7 @@ Everything in the feature checklist below.
1. **`hermes-agent/` is a read-only research reference.** It lives next to this
folder for study only. It is **git-ignored** and must **never** be committed,
pushed, or included in any artifact. Our plugin is *installed* into a live
hermes home (`~/.hermes/plugins/iris`); we never edit hermes core files.
hermes home (`~/.hermes/plugins/android`); we never edit hermes core files.
2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start`
to install, launch, and debug the app on-device throughout the build.
@@ -88,6 +88,6 @@ Everything in the feature checklist below.
## Naming
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
- hermes platform name: **`iris`** (the plugin registers `Platform("iris")`).
- hermes platform name: **`android`** (the plugin registers `Platform("android")`).
- WS default port: **8790** (configurable).
- Default chat id: **`default`** (the home channel).
- Default chat id: **`android:default`** (the home channel).
+5 -5
View File
@@ -12,7 +12,7 @@
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
│ │ │ IrisAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
│ │ │ • media cache │ │ WSS │ │
@@ -40,8 +40,8 @@
## Process model
- **One `hermes gateway` process** hosts the agent core, the session store, the
cron scheduler, *and* our `iris` platform plugin. The plugin's WebSocket
server runs on the gateway's asyncio loop (started in `IrisAdapter.connect()`).
cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket
server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`).
- **The app is a client.** It *initiates* the WS connection to the gateway
(outbound), so no inbound port is needed on the phone. For LAN/remote access
the user points the app at the gateway's LAN IP / Tailscale name / a WSS
@@ -56,7 +56,7 @@ serves Telegram/Discord/… and cron) and the **`tui_gateway`** JSON-RPC backend
(used by the TUI and the existing Electron desktop app). We deliberately use the
**messaging gateway** because:
1. **Cron delivery is native.** Cron jobs resolve `deliver=iris:<chat>[:<thread>]`
1. **Cron delivery is native.** Cron jobs resolve `deliver=android:<chat>[:<thread>]`
through the platform registry and call our adapter's `send()`. No bridging.
2. **`send_message` tool routing** works out of the box (plugin
`parse_target_ref_fn`).
@@ -86,7 +86,7 @@ protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
1. App sends `message.send {text}` (or `/cmd`).
2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) →
`IrisAdapter.handle_message(event)`.
`AndroidAdapter.handle_message(event)`.
3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent.
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
`adapter.send()` (first) / `adapter.edit_message()` (updates) →
+7 -7
View File
@@ -13,10 +13,10 @@ iris_x_hermes/
│
├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored)
│
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/iris
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/android
│ ├── plugin.yaml # manifest (kind: platform, env vars, home channel)
│ ├── __init__.py
│ ├── adapter.py # IrisAdapter(BasePlatformAdapter) + register(ctx)
│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx)
│ ├── ws_server.py # websockets server, connection registry, framing
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
│ ├── media.py # inbound cache + outbound chunked streaming
@@ -50,9 +50,9 @@ iris_x_hermes/
## Module responsibilities
### `gateway-plugin/` (Python)
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
- **`plugin.yaml`** — manifest: `name: android-platform`, `kind: platform`,
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`.
The heart of the plugin. See `03-gateway-plugin.md`.
- **`ws_server.py`** — `websockets` server, per-device connection registry,
frame encode/decode, heartbeat, broadcast routing to all connected devices.
@@ -62,7 +62,7 @@ iris_x_hermes/
`media.offer`/`media.pull` chunked streaming.
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
`NtfyBackend` (reuses hermes ntfy publish). Selected by `IRIS_PUSH_BACKEND`.
`NtfyBackend` (reuses hermes ntfy publish). Selected by `ANDROID_PUSH_BACKEND`.
- **`pairing.py`** — token generation/verification (constant-time), device
registry (SQLite), QR payload.
- **`search.py`** — FTS5 query bridge over the hermes session store.
@@ -83,7 +83,7 @@ Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
## Build systems
- **Python plugin:** no build step (pure Python, stdlib + hermes core deps).
Installed by copying/symlinking into `~/.hermes/plugins/iris`. Tested with
Installed by copying/symlinking into `~/.hermes/plugins/android`. Tested with
hermes's `scripts/run_tests.sh`.
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
@@ -124,7 +124,7 @@ keystore.jks
## Install layout (runtime)
- **Plugin:** `~/.hermes/plugins/iris/` ← copy of `gateway-plugin/`
- **Plugin:** `~/.hermes/plugins/android/` ← copy of `gateway-plugin/`
(or a symlink for dev). Discovered by hermes's `PluginManager`.
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
+34 -41
View File
@@ -1,6 +1,6 @@
# 03 — Gateway Plugin (Python)
The plugin is a **community-style hermes platform plugin** named `iris`.
The plugin is a **community-style hermes platform plugin** named `android`.
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
@@ -11,7 +11,7 @@ and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
## 3.1 `plugin.yaml` (manifest)
```yaml
name: iris-platform
name: android-platform
label: Android
kind: platform
version: 0.1.0
@@ -22,63 +22,63 @@ description: >
channels/threads, media, FTS5 search, and FCM/ntfy push.
author: <you>
requires_env:
- name: IRIS_TOKEN
- name: ANDROID_TOKEN
description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token"
password: true
optional_env:
- name: IRIS_WS_HOST
- name: ANDROID_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: IRIS_WS_PORT
- name: ANDROID_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
password: false
- name: ANDROID_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default default)"
description: "Default chat id for cron/notification delivery (default android:default)"
prompt: "Home channel"
password: false
- name: IRIS_ALLOWED_USERS
- name: ANDROID_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids"
password: false
- name: IRIS_ALLOW_ALL_USERS
- name: ANDROID_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)"
password: false
- name: IRIS_PUSH_BACKEND
- name: ANDROID_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend"
password: false
- name: IRIS_FCM_SERVICE_ACCOUNT
- name: ANDROID_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path"
password: true
- name: IRIS_FCM_SERVER_KEY
- name: ANDROID_FCM_SERVER_KEY
description: "Legacy FCM server key (fallback if no service account)"
prompt: "FCM server key"
password: true
- name: NTFY_TOPIC
description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
prompt: "ntfy topic"
password: false
- name: NTFY_SERVER_URL
description: "ntfy server URL (default https://ntfy.sh)"
prompt: "ntfy server URL"
password: false
- name: IRIS_WS_CERT
- name: ANDROID_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: IRIS_WS_KEY
- name: ANDROID_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
```
Behavioral (non-secret) settings live in `config.yaml` under
`gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
`gateway.platforms.android.extra` (host, port, home_channel, outbox retention,
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
only.)
@@ -87,21 +87,21 @@ only.)
```python
def register(ctx):
ctx.register_platform(
name="iris",
label="Iris",
adapter_factory=lambda cfg: IrisAdapter(cfg),
name="android",
label="Android",
adapter_factory=lambda cfg: AndroidAdapter(cfg),
check_fn=check_requirements, # passive: websockets importable + token set
validate_config=validate_config, # host/port/token present
is_connected=is_connected,
required_env=["IRIS_TOKEN"],
required_env=["ANDROID_TOKEN"],
install_hint="No extra packages needed (websockets + httpx are core deps)",
setup_fn=interactive_setup, # hermes gateway setup flow
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
allowed_users_env="IRIS_ALLOWED_USERS",
allow_all_env="IRIS_ALLOW_ALL_USERS",
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
allowed_users_env="ANDROID_ALLOWED_USERS",
allow_all_env="ANDROID_ALLOW_ALL_USERS",
max_message_length=0, # 0 = no limit (WS has none)
emoji="📱",
pii_safe=False,
@@ -123,29 +123,26 @@ Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
`ensure_deps_fn`.
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
`IRIS_TOKEN` is set. Never installs.
`ANDROID_TOKEN` is set. Never installs.
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
(host/port/home_channel/push_backend) + a `home_channel` key
`{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
`{"chat_id": "android:default", "name": "Default"}` so `hermes gateway status`
and cron home-channel resolution work without instantiating the adapter.
- **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
`ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional
`:t_<n>` thread suffix; friendly names resolve via the channel directory.
Returns `(chat_id, thread_id)` or `None`.
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `None`.
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
push backend + credentials, prints a QR code (pairing) and the app URL.
## 3.3 `IrisAdapter(BasePlatformAdapter)`
## 3.3 `AndroidAdapter(BasePlatformAdapter)`
Constructor: `super().__init__(config=config, platform=Platform("iris"))`.
Constructor: `super().__init__(config=config, platform=Platform("android"))`.
Reads `config.extra` (env overrides win). Initializes: WS server (not started
until `connect()`), connection registry, outbox (SQLite under
`get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
`get_hermes_home()/"android"`), push backend, pairing store, channel directory.
### Lifecycle
- **`connect(*, is_reconnect=False) -> bool`**
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`)
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", key)`)
so two profiles can't bind the same port/identity.
- Start the `websockets` server on `host:port` (TLS if cert/key set).
- `_mark_connected()`; return True.
@@ -153,7 +150,6 @@ until `connect()`), connection registry, outbox (SQLite under
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
### Inbound (app → agent)
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
@@ -175,7 +171,6 @@ until `connect()`), connection registry, outbox (SQLite under
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
### Outbound (agent → app)
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
- If **any** device is connected: broadcast `message` frame to all.
@@ -200,9 +195,7 @@ until `connect()`), connection registry, outbox (SQLite under
channel directory, return it (used by cron "continuable" threads).
### Streaming hooks
The main gateway drives delivery through the **legacy callback path**:
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
`edit_message()` (updates) → `message.start` / `message.update`.
- `tool_progress_callback` → progress queue → `send_progress_messages` →
@@ -237,7 +230,7 @@ verified empirically in M2 (see `13-testing.md`).
`message.update` (coalesce to latest) under pressure, never drop
`message`/`tool.end`/`notification`.
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
## 3.5 State & storage (all under `get_hermes_home()/"android"`)
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
> Never hardcode `~/.hermes`.
@@ -252,9 +245,9 @@ verified empirically in M2 (see `13-testing.md`).
## 3.6 Config resolution
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
`IRIS_FCM_SERVER_KEY`, `IRIS_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
- **Secrets (`.env`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`.
- Env vars override `config.yaml` (hermes convention). Read secrets with the
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more