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

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.AndroidEnv
import iris.platform.AndroidSecureStore import iris.platform.AndroidSecureStore
import iris.platform.AppBridge import iris.platform.AppBridge
import iris.platform.syncNtfyListener import iris.platform.NtfyListenerService
import iris.util.PairLink
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val deepLinkChatId = mutableStateOf<String?>(null) private val deepLinkChatId = mutableStateOf<String?>(null)
private val deepLinkThreadId = mutableStateOf<String?>(null) private val deepLinkThreadId = mutableStateOf<String?>(null)
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
// screen with.
private val pendingPair = mutableStateOf<PairLink?>(null)
private val notificationPermission = private val notificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ } registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
@@ -36,19 +31,11 @@ class MainActivity : ComponentActivity() {
val store = AndroidSecureStore(applicationContext) val store = AndroidSecureStore(applicationContext)
handleDeepLink(intent) handleDeepLink(intent)
requestNotificationPermission() requestNotificationPermission()
// M5: the ntfy listener (and its permanent notification) only runs startNtfyListener(store)
// when the paired gateway pushes via ntfy; unknown ("") = not yet.
syncNtfyListener(store.pushBackend)
setContent { setContent {
val chatId by deepLinkChatId val chatId by deepLinkChatId
val threadId by deepLinkThreadId val threadId by deepLinkThreadId
val pair by pendingPair IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
IrisApp(
store = store,
deepLinkChatId = chatId,
deepLinkThreadId = threadId,
deepLinkPair = pair,
)
} }
} }
@@ -60,10 +47,6 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
AppBridge.foreground = true AppBridge.foreground = true
// Wake the connect loop's backoff: after a background stint the
// network is usually back, so re-probe immediately instead of making
// the user wait out the (up to 30 s) backoff with a Connecting banner.
AppBridge.controller?.client?.poke()
} }
override fun onPause() { override fun onPause() {
@@ -75,20 +58,10 @@ class MainActivity : ComponentActivity() {
* extras or an iris://chat/<id>?thread=<tid> URI). */ * extras or an iris://chat/<id>?thread=<tid> URI). */
private fun handleDeepLink(intent: Intent?) { private fun handleDeepLink(intent: Intent?) {
val data = intent?.data val data = intent?.data
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… — val chatId = intent?.getStringExtra("chat_id")
// the whole payload is in the query string; parse it with the shared ?: data?.pathSegments?.firstOrNull()
// PairLink parser. val threadId = intent?.getStringExtra("thread_id")
if (data?.scheme == "iris" && data.host == "pair") { ?: data?.getQueryParameter("thread")
val link = PairLink.parse(data.toString())
if (link != null) pendingPair.value = link
return
}
val chatId =
intent?.getStringExtra("chat_id")
?: data?.pathSegments?.firstOrNull()
val threadId =
intent?.getStringExtra("thread_id")
?: data?.getQueryParameter("thread")
if (!chatId.isNullOrBlank()) { if (!chatId.isNullOrBlank()) {
deepLinkChatId.value = chatId deepLinkChatId.value = chatId
deepLinkThreadId.value = threadId deepLinkThreadId.value = threadId
@@ -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() mavenCentral()
} }
dependencies { dependencies {
classpath("com.google.gms:google-services:4.5.0") classpath("com.google.gms:google-services:4.4.2")
} }
} }
plugins { plugins {
kotlin("multiplatform") version "2.4.10" apply false kotlin("multiplatform") version "2.1.0" apply false
kotlin("jvm") version "2.4.10" apply false kotlin("android") version "2.1.0" apply false
kotlin("plugin.serialization") version "2.4.10" apply false kotlin("jvm") version "2.1.0" apply false
id("com.android.application") version "9.3.1" apply false kotlin("plugin.serialization") version "2.1.0" apply false
id("com.android.kotlin.multiplatform.library") version "9.3.1" apply false id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.compose") version "1.11.1" apply false id("com.android.library") version "8.7.3" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false id("org.jetbrains.compose") version "1.7.3" apply false
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight"). id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
id("app.cash.sqldelight") version "2.3.2" apply false
} }
tasks.register("clean") { tasks.register("clean") {
+32 -62
View File
@@ -6,32 +6,20 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
val composeVersion = "1.11.1" val composeVersion = "1.7.3"
val os = OperatingSystem.current() val os = OperatingSystem.current()
val arch = System.getProperty("os.arch") ?: "amd64" val arch = System.getProperty("os.arch") ?: "amd64"
// The repo-root VERSION file is the single source of truth (bump it to cut val desktopTarget = when {
// a release); CI can still override with -PappVersion. jpackage requires a os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
// plain semver (no leading "v"). os.isWindows -> "windows-x64"
val appVersion = else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
(project.findProperty("appVersion") as? String) }
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
val desktopTarget =
when {
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
os.isWindows -> "windows-x64"
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
}
dependencies { dependencies {
implementation(project(":shared")) implementation(project(":shared"))
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion") implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
// M6: window-state persistence (window.json). // M6: window-state persistence (window.json).
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
} }
compose.desktop { compose.desktop {
@@ -57,21 +45,19 @@ afterEvaluate {
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input") val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
val fatJar = val fatJar = tasks.register<Jar>("fatJar") {
tasks.register<Jar>("fatJar") { archiveClassifier.set("all")
archiveClassifier.set("all") duplicatesStrategy = DuplicatesStrategy.EXCLUDE
duplicatesStrategy = DuplicatesStrategy.EXCLUDE manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
manifest { attributes("Main-Class" to "iris.desktop.MainKt") } from(sourceSets.main.get().output)
from(sourceSets.main.get().output) from({
from({ configurations.runtimeClasspath.get()
configurations.runtimeClasspath .filter { it.name.endsWith(".jar") }
.get() .map { zipTree(it) }
.filter { it.name.endsWith(".jar") } }) {
.map { zipTree(it) } exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
}) {
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
}
} }
}
// Stage the fat jar into the jpackage input dir. A dedicated Sync task keeps // Stage the fat jar into the jpackage input dir. A dedicated Sync task keeps
// the Exec task free of task references (configuration-cache compatible) and // the Exec task free of task references (configuration-cache compatible) and
@@ -87,39 +73,23 @@ tasks.register<Exec>("jpackage") {
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage") val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
val type = (project.findProperty("jpackageType") as? String) ?: "app-image" val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
val inputDir = jpackageInputDir.get().asFile val inputDir = jpackageInputDir.get().asFile
val destDir = val destDir = layout.buildDirectory.dir("jpackage").get().asFile
layout.buildDirectory
.dir("jpackage")
.get()
.asFile
inputs.dir(inputDir) inputs.dir(inputDir)
outputs.dir(destDir) outputs.dir(destDir)
commandLine( commandLine(
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage", if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
"--name", "--name", "iris",
"iris", "--app-version", "0.1.0",
"--app-version", "--vendor", "Iris",
appVersion, "--type", type,
"--vendor", "--input", inputDir.absolutePath,
"Iris", "--main-jar", "desktopApp-all.jar",
"--type", "--main-class", "iris.desktop.MainKt",
type, "--icon", file("src/main/resources/icon.png").absolutePath,
"--input", "--dest", destDir.absolutePath,
inputDir.absolutePath, "--java-options", "-Xmx1g",
"--main-jar",
"desktopApp-all.jar",
"--main-class",
"iris.desktop.MainKt",
"--icon",
file("src/main/resources/icon.png").absolutePath,
"--dest",
destDir.absolutePath,
"--java-options",
"-Xmx1g",
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above). // M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
"--java-options", "--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
"--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/java.awt.peer=ALL-UNNAMED",
) )
} }
@@ -19,12 +19,9 @@ import iris.IrisApp
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.DesktopBridge import iris.platform.DesktopBridge
import iris.platform.DesktopSecureStore import iris.platform.DesktopSecureStore
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.Color import java.awt.Color
import java.awt.Graphics2D import java.awt.Graphics2D
import javax.imageio.ImageIO
import java.awt.RenderingHints import java.awt.RenderingHints
import java.awt.SystemTray import java.awt.SystemTray
import java.awt.event.WindowEvent import java.awt.event.WindowEvent
@@ -32,7 +29,10 @@ import java.awt.event.WindowFocusListener
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.File import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.imageio.ImageIO import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/** /**
* M6: desktop shell (docs/11 §11.2/§11.3). * M6: desktop shell (docs/11 §11.2/§11.3).
@@ -48,7 +48,6 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso
// Window/taskbar icon (src/main/resources/icon.png). // Window/taskbar icon (src/main/resources/icon.png).
private class IrisDesktop private class IrisDesktop
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap() private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range. // Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
@@ -88,37 +87,15 @@ fun main() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(2_000) delay(2_000)
val state = val state = DesktopBridge.controller?.client?.state?.value
DesktopBridge.controller val (color, tooltip) = when (state) {
?.client null,
?.state is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline"
?.value is GatewayClient.State.Connecting,
val (color, tooltip) = is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…"
when (state) { is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected"
null, is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed"
is GatewayClient.State.Disconnected, }
-> {
TRAY_OFFLINE to "Iris — offline"
}
is GatewayClient.State.Connecting,
is GatewayClient.State.Reconnecting,
-> {
TRAY_CONNECTING to "Iris — connecting…"
}
is GatewayClient.State.Connected -> {
TRAY_CONNECTED to "Iris — connected"
}
is GatewayClient.State.AuthFailed -> {
TRAY_AUTH_FAILED to "Iris — auth failed"
}
is GatewayClient.State.TlsConfirmRequired -> {
TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation"
}
}
trayColor = color trayColor = color
trayTooltip = tooltip trayTooltip = tooltip
} }
@@ -149,17 +126,15 @@ fun main() {
state = loadWindowState(), state = loadWindowState(),
) { ) {
composeWindow = window composeWindow = window
window.addWindowFocusListener( window.addWindowFocusListener(object : WindowFocusListener {
object : WindowFocusListener { override fun windowGainedFocus(e: WindowEvent) {
override fun windowGainedFocus(e: WindowEvent) { DesktopBridge.foreground = true
DesktopBridge.foreground = true }
}
override fun windowLostFocus(e: WindowEvent) { override fun windowLostFocus(e: WindowEvent) {
DesktopBridge.foreground = false DesktopBridge.foreground = false
} }
}, })
)
IrisApp(store) IrisApp(store)
} }
} }
-9
View File
@@ -1,13 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
# Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
# unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget).
#
# The JDK-21 home is machine-specific, so it is NOT hardcoded here (a
# committed path would break every other checkout). Set it per machine via
# one of:
# - ~/.gradle/gradle.properties -> org.gradle.java.home=/path/to/jdk21
# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+30 -129
View File
@@ -1,124 +1,44 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
kotlin("multiplatform") kotlin("multiplatform")
kotlin("plugin.serialization") kotlin("plugin.serialization")
id("com.android.kotlin.multiplatform.library") id("com.android.library")
id("org.jetbrains.compose") id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
id("app.cash.sqldelight")
} }
val composeVersion = "1.11.1" val composeVersion = "1.7.3"
// material3 and material-icons-extended are published on their own cadence and val coroutinesVersion = "1.9.0"
// lag the core artifacts; use each one's latest stable release. val serializationVersion = "1.7.3"
val material3Version = "1.9.0" val okhttpVersion = "4.12.0"
val materialIconsVersion = "1.7.3" // Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1);
val coroutinesVersion = "1.11.0" // 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the
val serializationVersion = "1.11.0"
val okhttpVersion = "5.5.0"
// 2.0.x is the first line built against Compose 1.8+ (1.9.40 was the last
// 1.7.x-compatible release). HTML artifact previews (M9): Android uses the
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use). // platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
val webviewVersion = "2.0.3" val webviewVersion = "1.9.40"
val kcefVersion = "2025.03.23" val kcefVersion = "2024.04.20.3"
// 0.34.0+ moved to Compose 1.8+ (0.33.0 was the last 1.7.x / Kotlin 2.1.x // Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
// release). Provides GFM tables, bold/italic/underscore, and the -code module // (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
// for language-aware syntax highlighting (Highlights). // and the -code module for language-aware syntax highlighting (Highlights).
val markdownVersion = "0.44.0" val markdownVersion = "0.33.0"
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
val sqldelightVersion = "2.3.2"
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
val cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.1"
// ── App version (generated) ─────────────────────────────────────────────
// The repo-root VERSION file is the single source of truth (bump it to cut
// a release; CI can still override with -PappVersion). Generate AppVersion.kt
// into commonMain so both targets can display it in Settings and send it to
// the gateway (X-Iris-App-Version header).
//
// A real task with the VERSION file as a DECLARED input: on a
// configuration-cache hit the script body does not re-run, so only the task
// (keyed on the file's content) can regenerate AppVersion.kt after a bump.
// The doLast reads everything from the task's own inputs/outputs (which are
// configuration-cache serializable) — it must not reference script-scope
// vals, because a .kts script lambda captures the script object and the
// configuration cache rejects that.
val generatedVersionDir = layout.buildDirectory.dir("generated/app-version")
val generateAppVersion by tasks.registering {
inputs.file(project.file("../../VERSION"))
inputs.property("appVersionOverride", (project.findProperty("appVersion") as? String).orEmpty())
val outFile = generatedVersionDir.map { it.file("AppVersion.kt") }
outputs.file(outFile)
doLast {
val override = inputs.properties["appVersionOverride"] as? String ?: ""
val versionFile = inputs.files.singleFile
val version =
override.ifBlank {
versionFile.takeIf { it.exists() }?.readText()?.trim() ?: "0.1.0"
}
val f = outFile.get().asFile
f.parentFile?.mkdirs()
f.writeText(
"""
|package iris
|
|/** Generated from the repo-root VERSION file - do not edit. */
|object AppVersion {
| const val VERSION = "$version"
|}
|
""".trimMargin(),
)
}
}
kotlin { kotlin {
android { androidTarget()
namespace = "iris.shared" jvm("desktop")
compileSdk = 37
minSdk = 29
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
withHostTest { }
}
jvm("desktop") {
// Desktop runs on the Java 21 runtime (see gradle.properties
// org.gradle.java.home); target 21 so the Markdown renderer 0.44.0
// (Java-21 bytecode) loads. Android keeps its JVM 17 target above.
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets { sourceSets {
// AppVersion.kt is generated from the repo-root VERSION file (see
// generateAppVersion above) into commonMain so both targets can read it.
commonMain {
kotlin.srcDir(generatedVersionDir)
}
// Both targets are JVM-based (androidTarget + jvm("desktop")), so // Both targets are JVM-based (androidTarget + jvm("desktop")), so
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain. // shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
val jvmMain by creating { dependsOn(commonMain.get()) } val jvmMain by creating { dependsOn(commonMain.get()) }
val androidMain by getting { dependsOn(jvmMain) } val androidMain by getting { dependsOn(jvmMain) }
val desktopMain by getting { dependsOn(jvmMain) } val desktopMain by getting { dependsOn(jvmMain) }
// Host tests shared by the android (withHostTest) and desktop targets
// (e.g. the SQLDelight cache DB tests, which need the JDBC driver).
val jvmTest by creating { dependsOn(commonTest.get()) }
val androidHostTest by getting { dependsOn(jvmTest) }
val desktopTest by getting { dependsOn(jvmTest) }
commonMain.dependencies { commonMain.dependencies {
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion") implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion") implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$material3Version") implementation("org.jetbrains.compose.material3:material3:$composeVersion")
implementation("org.jetbrains.compose.ui:ui:$composeVersion") implementation("org.jetbrains.compose.ui:ui:$composeVersion")
// Full Material icon set (Icons.Filled.ContentCopy for the code // Full Material icon set (Icons.Filled.ContentCopy for the code
// copy buttons); -core only ships a small subset. // copy buttons); -core only ships a small subset.
implementation("org.jetbrains.compose.material:material-icons-extended:$materialIconsVersion") implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based // OkHttp is a JVM library; both targets here are JVM-based
@@ -133,60 +53,41 @@ kotlin {
// M9: HTML artifact previews — WebView composable (platform WebView // M9: HTML artifact previews — WebView composable (platform WebView
// on Android, KCEF/JCEF on desktop). // on Android, KCEF/JCEF on desktop).
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion") implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
// Local cache DB runtime (generated code from commonMain/sqldelight).
implementation("app.cash.sqldelight:runtime:$sqldelightVersion")
} }
commonTest.dependencies { commonTest.dependencies {
implementation(kotlin("test")) implementation(kotlin("test"))
} }
jvmTest.dependencies {
implementation(kotlin("test"))
// JDBC SQLite driver: in-memory DB for the cache tests.
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
}
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The // M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
// webview library exposes it transitively, but we reference KCEF // webview library exposes it transitively, but we reference KCEF
// directly (init + progress) so declare it explicitly. // directly (init + progress) so declare it explicitly.
desktopMain.dependencies { desktopMain.dependencies {
implementation("dev.datlag:kcef:$kcefVersion") implementation("dev.datlag:kcef:$kcefVersion")
// Local cache DB: JDBC SQLite driver (file in ~/.iris).
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
} }
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only). // M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
androidMain.dependencies { androidMain.dependencies {
// M7: EncryptedSharedPreferences for the pairing token // M7: EncryptedSharedPreferences for the pairing token
// (docs/09 §9.7; AndroidSecureStore). // (docs/09 §9.7; AndroidSecureStore).
implementation("androidx.security:security-crypto:1.1.0") implementation("androidx.security:security-crypto:1.1.0")
implementation("androidx.media3:media3-exoplayer:1.11.0") implementation("androidx.media3:media3-exoplayer:1.3.1")
implementation("androidx.media3:media3-ui:1.11.0") implementation("androidx.media3:media3-ui:1.3.1")
// SAF picker (rememberLauncherForActivityResult). // SAF picker (rememberLauncherForActivityResult).
implementation("androidx.activity:activity-compose:1.13.0") implementation("androidx.activity:activity-compose:1.9.2")
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
// uses the camera2 CameraX backend and ML Kit's barcode model.
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
// M5: FCM push (inert without a Firebase project / google-services.json; // M5: FCM push (inert without a Firebase project / google-services.json;
// the ntfy listener is the fallback). The google-services plugin is // the ntfy listener is the fallback). The google-services plugin is
// applied conditionally in the app module. // applied conditionally in the app module.
implementation("com.google.firebase:firebase-messaging:25.1.2") implementation("com.google.firebase:firebase-messaging:23.1.2")
// Local cache DB: Android SQLite driver (app database dir).
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
} }
} }
} }
sqldelight { android {
databases { namespace = "iris.shared"
create("IrisDatabase") { compileSdk = 34
packageName.set("iris.db") defaultConfig {
} minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
} }
} }
// Every Kotlin compile depends on the generated AppVersion.kt being current.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(generateAppVersion)
}
@@ -40,16 +40,16 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.MediaItem as M3MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import iris.data.MediaItem import iris.data.MediaItem
import iris.media.extForMime import iris.media.extForMime
import iris.protocol.KIND_VIDEO import iris.protocol.KIND_VIDEO
import java.io.File
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import androidx.media3.common.MediaItem as M3MediaItem
/** App context holder (set in MainActivity.onCreate). */ /** App context holder (set in MainActivity.onCreate). */
object AndroidEnv { object AndroidEnv {
@@ -59,25 +59,24 @@ object AndroidEnv {
@Composable @Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val launcher = val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) {
if (uri == null) { onPicked(null)
onPicked(null) return@rememberLauncherForActivityResult
return@rememberLauncherForActivityResult
}
try {
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val name = queryDisplayName(context, uri) ?: "upload"
val dir = File(context.cacheDir, "uploads").apply { mkdirs() }
val target = File(dir, "${System.currentTimeMillis()}${extForMime(mime)}")
context.contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
} catch (e: Exception) {
onPicked(null)
}
} }
try {
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val name = queryDisplayName(context, uri) ?: "upload"
val dir = File(context.cacheDir, "uploads").apply { mkdirs() }
val target = File(dir, "${System.currentTimeMillis()}${extForMime(mime)}")
context.contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
} catch (e: Exception) {
onPicked(null)
}
}
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Button(onClick = { launcher.launch("*/*") }) { Text("Pick a file") } Button(onClick = { launcher.launch("*/*") }) { Text("Pick a file") }
} }
@@ -86,33 +85,29 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
@Composable @Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val launcher = val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri == null) {
if (uri == null) { onPicked(null)
onPicked(null) return@rememberLauncherForActivityResult
return@rememberLauncherForActivityResult
}
try {
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val name = queryDisplayName(context, uri) ?: "background"
// Persistent (filesDir), unlike the upload staging dir in cacheDir.
val dir = File(context.filesDir, "theme").apply { mkdirs() }
val target = File(dir, "background${extForMime(mime)}")
context.contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
} catch (e: Exception) {
onPicked(null)
}
} }
try {
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val name = queryDisplayName(context, uri) ?: "background"
// Persistent (filesDir), unlike the upload staging dir in cacheDir.
val dir = File(context.filesDir, "theme").apply { mkdirs() }
val target = File(dir, "background${extForMime(mime)}")
context.contentResolver.openInputStream(uri)!!.use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
} catch (e: Exception) {
onPicked(null)
}
}
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") } Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
} }
private fun queryDisplayName( private fun queryDisplayName(context: Context, uri: Uri): String? {
context: Context,
uri: Uri,
): String? {
context.contentResolver.query(uri, null, null, null, null)?.use { c -> context.contentResolver.query(uri, null, null, null, null)?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && c.moveToFirst()) return c.getString(idx) if (idx >= 0 && c.moveToFirst()) return c.getString(idx)
@@ -124,99 +119,59 @@ actual fun mediaCacheBaseDir(): String =
AndroidEnv.context.externalCacheDir?.absolutePath AndroidEnv.context.externalCacheDir?.absolutePath
?: AndroidEnv.context.cacheDir.absolutePath ?: AndroidEnv.context.cacheDir.absolutePath
actual suspend fun loadMediaImage(path: String): ImageBitmap? = actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) {
try {
BitmapFactory.decodeFile(path)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
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.
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, 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.decodeFile(path, opts)?.asImageBitmap()
} 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 { try {
AndroidEnv.context.assets BitmapFactory.decodeFile(path)?.asImageBitmap()
.open("backdrops/$id.jpg")
.use { it.readBytes() }
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
}
actual suspend fun encodeImageAsBase64( actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
path: String, try {
maxSize: Int, // inJustDecodeBounds fills [bounds] and returns null by design — only
): String? = // the outWidth/outHeight are meaningful here.
withContext(Dispatchers.IO) { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
try { BitmapFactory.decodeFile(path, bounds)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
BitmapFactory.decodeFile(path, bounds) var sample = 1
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
var sample = 1 val opts = BitmapFactory.Options().apply { inSampleSize = sample }
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2 BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
val opts = BitmapFactory.Options().apply { inSampleSize = sample } } catch (e: Exception) {
val bitmap = BitmapFactory.decodeFile(path, opts) ?: return@withContext null null
val stream = java.io.ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream)
bitmap.recycle()
android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP)
} catch (e: Exception) {
null
}
} }
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { try {
try { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) BitmapFactory.decodeFile(path, bounds)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
} catch (e: Exception) { var sample = 1
null while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
} val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = BitmapFactory.decodeFile(path, opts) ?: return@withContext null
val stream = java.io.ByteArrayOutputStream()
bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream)
bitmap.recycle()
android.util.Base64.encodeToString(stream.toByteArray(), android.util.Base64.NO_WRAP)
} catch (e: Exception) {
null
} }
}
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 @Composable
actual fun MediaPlayerView( actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
media: MediaItem,
modifier: Modifier,
) {
val path = media.localPath val path = media.localPath
if (path == null) { if (path == null) {
Box(modifier = modifier, contentAlignment = Alignment.Center) { Box(modifier = modifier, contentAlignment = Alignment.Center) {
@@ -236,10 +191,7 @@ actual fun MediaPlayerView(
} }
@Composable @Composable
private fun VideoPlayerView( private fun VideoPlayerView(path: String, modifier: Modifier) {
path: String,
modifier: Modifier,
) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
PlayerView(ctx).apply { PlayerView(ctx).apply {
@@ -253,27 +205,21 @@ private fun VideoPlayerView(
}, },
update = {}, update = {},
onRelease = { view -> view.player?.release() }, onRelease = { view -> view.player?.release() },
modifier = modifier = modifier
modifier .fillMaxWidth()
.fillMaxWidth() .height(220.dp),
.height(220.dp),
) )
} }
@Composable @Composable
private fun AudioPlayerView( private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) {
path: String,
filename: String,
modifier: Modifier,
) {
val context = LocalContext.current val context = LocalContext.current
val player = val player = remember {
remember { ExoPlayer.Builder(context).build().apply {
ExoPlayer.Builder(context).build().apply { setMediaItem(M3MediaItem.fromUri(path.toUri()))
setMediaItem(M3MediaItem.fromUri(path.toUri())) prepare()
prepare()
}
} }
}
DisposableEffect(player) { onDispose { player.release() } } DisposableEffect(player) { onDispose { player.release() } }
var playing by remember { mutableStateOf(false) } var playing by remember { mutableStateOf(false) }
@@ -291,10 +237,9 @@ private fun AudioPlayerView(
} }
Row( Row(
modifier = modifier = modifier
modifier .fillMaxWidth()
.fillMaxWidth() .padding(8.dp),
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = { IconButton(onClick = {
@@ -325,21 +270,17 @@ private fun fmt(seconds: Float): String {
return "%d:%02d".format(s / 60, s % 60) return "%d:%02d".format(s / 60, s % 60)
} }
actual fun openDocument( actual fun openDocument(path: String, mime: String) {
path: String,
mime: String,
) {
val context = AndroidEnv.context val context = AndroidEnv.context
try { try {
val file = File(path) val file = File(path)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = val intent = Intent(Intent.ACTION_VIEW).apply {
Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, mime)
setDataAndType(uri, mime) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }
}
context.startActivity(intent) context.startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
Toast.makeText(context, "No app to open $mime", Toast.LENGTH_SHORT).show() Toast.makeText(context, "No app to open ${mime}", Toast.LENGTH_SHORT).show()
} }
} }
@@ -1,8 +1,6 @@
package iris.platform package iris.platform
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import iris.state.IrisController import iris.state.IrisController
@@ -13,33 +11,6 @@ actual fun setActiveController(controller: Any?) {
AppBridge.controller = controller as? IrisController AppBridge.controller = controller as? IrisController
} }
actual fun syncNtfyListener(backend: String) {
val context = AndroidEnv.context
val intent = Intent(context, NtfyListenerService::class.java)
// L-18: reuse one AndroidSecureStore instead of rebuilding it (and
// re-running EncryptedSharedPreferences.create + migration) on every call.
val store = ntfyStore(context)
if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) {
ContextCompat.startForegroundService(context, intent)
} else {
// FCM (or unknown) backend: no persistent listener, no permanent
// notification. stopService is a no-op when it isn't running.
context.stopService(intent)
}
}
private val ntfyStoreLock = Any()
@Volatile
private var cachedNtfyStore: AndroidSecureStore? = null
private fun ntfyStore(context: Context): AndroidSecureStore {
cachedNtfyStore?.let { return it }
return synchronized(ntfyStoreLock) {
cachedNtfyStore ?: AndroidSecureStore(context).also { cachedNtfyStore = it }
}
}
actual fun postSystemNotification( actual fun postSystemNotification(
chatId: String?, chatId: String?,
chatName: String?, chatName: String?,
@@ -48,7 +19,7 @@ actual fun postSystemNotification(
threadId: String?, threadId: String?,
) { ) {
val context = AndroidEnv.context val context = AndroidEnv.context
val id = chatId ?: "default" val id = chatId ?: "android:default"
// POST_NOTIFICATIONS is a runtime permission on API 33+. // POST_NOTIFICATIONS is a runtime permission on API 33+.
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
@@ -6,10 +6,7 @@ import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import iris.data.SecureStore import iris.data.SecureStore
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import java.util.UUID import java.util.UUID
/** /**
@@ -18,22 +15,18 @@ import java.util.UUID
* values are migrated on first run (read old key, write encrypted, delete * values are migrated on first run (read old key, write encrypted, delete
* old key) so an upgrade never loses the pairing. * old key) so an upgrade never loses the pairing.
*/ */
class AndroidSecureStore( class AndroidSecureStore(context: Context) : SecureStore {
context: Context,
) : SecureStore {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE) private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
private val prefs: SharedPreferences = private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
EncryptedSharedPreferences.create( appContext,
appContext, SECURE_PREFS_NAME,
SECURE_PREFS_NAME, MasterKey.Builder(appContext)
MasterKey .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.Builder(appContext) .build(),
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
.build(), EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, )
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
init { init {
migratePlainValues() migratePlainValues()
@@ -57,8 +50,7 @@ class AndroidSecureStore(
} }
if (migrated) editor.apply() if (migrated) editor.apply()
// The plain store must not keep a copy of any value. // The plain store must not keep a copy of any value.
plainPrefs plainPrefs.edit()
.edit()
.remove(KEY_URL) .remove(KEY_URL)
.remove(KEY_TOKEN) .remove(KEY_TOKEN)
.remove(KEY_DEVICE_ID) .remove(KEY_DEVICE_ID)
@@ -77,24 +69,11 @@ class AndroidSecureStore(
get() = prefs.getString(KEY_TOKEN, "").orEmpty() get() = prefs.getString(KEY_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply() set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
override var deviceToken: String
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
override var pinnedCertFingerprint: String
get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty()
set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply()
override val deviceId: String override val deviceId: String
get() { get() {
var id = prefs.getString(KEY_DEVICE_ID, null) var id = prefs.getString(KEY_DEVICE_ID, null)
if (id.isNullOrBlank()) { if (id.isNullOrBlank()) {
id = "dev_" + id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
UUID
.randomUUID()
.toString()
.replace("-", "")
.take(16)
prefs.edit().putString(KEY_DEVICE_ID, id).apply() prefs.edit().putString(KEY_DEVICE_ID, id).apply()
} }
return id return id
@@ -119,10 +98,6 @@ class AndroidSecureStore(
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty() get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply() set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
override var pushBackend: String
get() = prefs.getString(KEY_PUSH_BACKEND, "").orEmpty()
set(value) = prefs.edit().putString(KEY_PUSH_BACKEND, value).apply()
override var threadsEnabled: Boolean override var threadsEnabled: Boolean
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false) get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply() set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
@@ -135,10 +110,6 @@ class AndroidSecureStore(
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true) get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply() set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
override var streamSmoothness: Float
get() = prefs.getFloat(KEY_STREAM_SMOOTHNESS, STREAM_SMOOTHNESS_DEFAULT)
set(value) = prefs.edit().putFloat(KEY_STREAM_SMOOTHNESS, value).apply()
override var reasoningAutoCollapse: Boolean override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true) get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply() set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
@@ -152,11 +123,7 @@ class AndroidSecureStore(
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply() set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
override var backgroundMode: String override var backgroundMode: String
get() = get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" }
prefs
.getString(KEY_BACKGROUND_MODE, BackgroundMode.Image.name.lowercase())
.orEmpty()
.ifBlank { BackgroundMode.Image.name.lowercase() }
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply() set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
override var backgroundColor: Int override var backgroundColor: Int
@@ -164,54 +131,16 @@ class AndroidSecureStore(
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply() set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
override var backgroundImagePath: String override var backgroundImagePath: String
get() = get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty()
prefs
.getString(KEY_BACKGROUND_IMAGE_PATH, Backdrop.DEFAULT.path)
.orEmpty()
.ifBlank { Backdrop.DEFAULT.path }
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply() set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
override var fontSizeScale: Float override fun savePairing(url: String, token: String) {
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,
) {
serverUrl = url serverUrl = url
this.token = token this.token = token
// A (re-)pair may target a different gateway: the old per-device
// token is dead there. The next hello.ack re-mints/returns it.
deviceToken = ""
} }
override fun clear() { override fun clear() {
// M-10: clearing pairing must also wipe the device identity + push prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
prefs
.edit()
.remove(KEY_URL)
.remove(KEY_TOKEN)
.remove(KEY_DEVICE_TOKEN)
.remove(KEY_DEVICE_ID)
.remove(KEY_SYNC_CURSOR)
.remove(KEY_FCM_TOKEN)
.remove(KEY_NTFY_TOPIC)
.remove(KEY_NTFY_SERVER)
.remove(KEY_PUSH_BACKEND)
.remove(KEY_PINNED_CERT)
.apply()
} }
private companion object { private companion object {
@@ -219,26 +148,19 @@ class AndroidSecureStore(
const val SECURE_PREFS_NAME = "iris_secure" const val SECURE_PREFS_NAME = "iris_secure"
const val KEY_URL = "server_url" const val KEY_URL = "server_url"
const val KEY_TOKEN = "token" const val KEY_TOKEN = "token"
const val KEY_DEVICE_TOKEN = "device_token"
const val KEY_DEVICE_ID = "device_id" const val KEY_DEVICE_ID = "device_id"
const val KEY_SYNC_CURSOR = "sync_cursor" const val KEY_SYNC_CURSOR = "sync_cursor"
const val KEY_FCM_TOKEN = "fcm_token" const val KEY_FCM_TOKEN = "fcm_token"
const val KEY_NTFY_TOPIC = "ntfy_topic" const val KEY_NTFY_TOPIC = "ntfy_topic"
const val KEY_NTFY_SERVER = "ntfy_server" const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_PUSH_BACKEND = "push_backend"
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
const val KEY_THREADS_ENABLED = "threads_enabled" const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled" const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_STREAM_SMOOTHNESS = "stream_smoothness"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse" const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color" const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color" const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
const val KEY_BACKGROUND_MODE = "background_mode" const val KEY_BACKGROUND_MODE = "background_mode"
const val KEY_BACKGROUND_COLOR = "background_color" const val KEY_BACKGROUND_COLOR = "background_color"
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path" const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
const val KEY_FONT_SIZE_SCALE = "font_size_scale"
const val KEY_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/" private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable @Composable
actual fun PlatformWebView( actual fun PlatformWebView(html: String, modifier: Modifier) {
html: String,
modifier: Modifier,
) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL) val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier) WebView(state, modifier = modifier)
@@ -36,18 +33,16 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack) val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) { DisposableEffect(dispatcher) {
val callback = if (dispatcher != null) {
dispatcher?.let { d -> val callback = object : OnBackPressedCallback(true) {
val c = override fun handleOnBackPressed() {
object : OnBackPressedCallback(true) { currentOnBack.value()
override fun handleOnBackPressed() { }
currentOnBack.value()
}
}
d.addCallback(c)
c
} }
onDispose { callback?.remove() } dispatcher.addCallback(callback)
onDispose { callback.remove() }
}
onDispose { }
} }
return this return this
} }
@@ -14,14 +14,7 @@ object AppBridge {
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
/** True while the launcher activity is resumed (set by MainActivity). /** True while the launcher activity is resumed (set by MainActivity). */
* A change is forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = false var foreground: Boolean = false
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
} }
@@ -4,59 +4,39 @@ import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import iris.net.GatewayClient
/** /**
* M5: FCM handler (docs/08 §8.1). * M5: FCM handler (docs/08 §8.1).
* *
* - [onNewToken]: persist the rotated token and push it to the server via * - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token). * `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: posts a system notification from the data payload. * - [onMessageReceived]: the data payload drives a silent sync. When the app
* When the app is foregrounded the SSE path already delivered the frame * is foregrounded the WS path already delivered the frame (in-app banner),
* (in-app banner), so we only post a notification when backgrounded. * so we only post a system notification when backgrounded.
* *
* Inert without a Firebase project (no google-services.json): the service is * Inert without a Firebase project (no google-services.json): the service is
* declared in the manifest but never receives messages, and the app falls * declared in the manifest but never receives messages, and the app falls
* back to the ntfy listener. * back to the ntfy listener.
*/ */
class IrisFirebaseMessagingService : FirebaseMessagingService() { class IrisFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) { override fun onNewToken(token: String) {
val store = AndroidSecureStore(applicationContext) val store = AndroidSecureStore(applicationContext)
store.fcmToken = token store.fcmToken = token
// Push the rotation to the server if we're connected (the ntfy topic // Push the rotation to the server if we're connected.
// rides along so a wiped registry recovers both push tokens).
AppBridge.controller?.client?.sendFrame( AppBridge.controller?.client?.sendFrame(
iris.protocol.fcmRegisterFrame( iris.protocol.fcmRegisterFrame(fcmToken = token),
fcmToken = token,
ntfyTopic = store.ntfyTopic.ifBlank { null },
),
) )
} }
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
// Foreground + live SSE: the in-app banner already showed this.
if (AppBridge.foreground) return
// Live SSE: the frame arrives over the stream and the controller
// mirrors it to a system notification itself — posting here would
// duplicate it (docs/08 §8.7).
if (AppBridge.controller
?.client
?.state
?.value is GatewayClient.State.Connected
) {
return
}
// Backgrounded/killed: FCM already displayed the `notification`
// payload on our behalf (the data payload only carries sync
// metadata). Posting again would show a second notification with a
// different id. Data-only messages (no notification payload) are the
// exception: the app must display them itself.
if (message.notification != null) return
val data = message.data val data = message.data
val chatId = data["chat_id"] ?: "default" val chatId = data["chat_id"] ?: "android:default"
val threadId = data["thread_id"] val threadId = data["thread_id"]
val title = data["title"] ?: "Iris" val title = data["title"] ?: "Iris"
val body = data["body"] ?: data["title"].orEmpty() val body = data["body"] ?: data["title"].orEmpty()
// Foreground + live WS: the in-app banner already showed this.
if (AppBridge.foreground) return
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS) if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
) { ) {
@@ -5,6 +5,7 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
/** /**
@@ -20,22 +21,15 @@ object IrisNotifications {
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT" const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000 private const val NOTIF_ID_BASE = 1_000_000
// L-31: notification channel ids are capped at 64 chars (Android limit) and fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
// are user-visible, so a long server-provided chatId must be truncated. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
private fun channelIdFor(chatId: String): String = (CHANNEL_PREFIX + chatId).take(64)
fun ensureChannel(
context: Context,
chatId: String,
chatName: String? = null,
) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val id = channelIdFor(chatId) val id = CHANNEL_PREFIX + chatId
val name = chatName ?: chatId val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) { if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel( nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT) NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "Iris messages for $name" }, .apply { description = "Iris messages for $name" }
) )
} }
} }
@@ -49,28 +43,23 @@ object IrisNotifications {
threadId: String?, threadId: String?,
) { ) {
ensureChannel(context, chatId, chatName) ensureChannel(context, chatId, chatName)
// L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
// to the same notification id and clobber each other. val intent = Intent(ACTION_OPEN_CHAT).apply {
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff) setPackage(context.packageName)
val intent = flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
Intent(ACTION_OPEN_CHAT).apply { putExtra("chat_id", chatId)
setPackage(context.packageName) if (threadId != null) putExtra("thread_id", threadId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP }
putExtra("chat_id", chatId) val pi = PendingIntent.getActivity(
if (threadId != null) putExtra("thread_id", threadId) context,
} id,
val pi = intent,
PendingIntent.getActivity( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
context, )
id,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify( nm.notify(
id, id,
NotificationCompat NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
.Builder(context, channelIdFor(chatId))
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
@@ -11,13 +11,11 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -30,31 +28,18 @@ import java.util.concurrent.TimeUnit
* *
* A foreground service that subscribes to this device's ntfy topic and posts * A foreground service that subscribes to this device's ntfy topic and posts
* a system notification for each push. The structured payload rides in the * a system notification for each push. The structured payload rides in the
* `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message * `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
* body is the short preview. When the app is foregrounded the SSE path * is the short preview. When the app is foregrounded the WS path already
* already delivered the frame, so the service skips posting to avoid a * delivered the frame, so the service skips posting to avoid a duplicate.
* duplicate.
*
* The stream is reconnected with capped exponential backoff when it drops
* (EOF, network error, or a non-2xx response) — `START_STICKY` alone only
* restarts the service after process death, not after a failed read.
*/ */
class NtfyListenerService : Service() { class NtfyListenerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null private var streamJob: Job? = null
private val client = private val client = OkHttpClient.Builder()
OkHttpClient .readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
.Builder() .build()
// ntfy sends keep-alive comments every ~10 s; a 60 s read timeout
// detects a half-open connection instead of hanging forever.
.readTimeout(60, TimeUnit.SECONDS)
.build()
override fun onStartCommand( override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent: Intent?,
flags: Int,
startId: Int,
): Int {
startForeground(NOTIF_ID, foregroundNotification()) startForeground(NOTIF_ID, foregroundNotification())
streamJob?.cancel() streamJob?.cancel()
streamJob = scope.launch { stream() } streamJob = scope.launch { stream() }
@@ -75,79 +60,47 @@ class NtfyListenerService : Service() {
if (topic.isBlank()) return if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/") val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic" val url = "$server/$topic"
val request = val request = Request.Builder()
Request .url(url)
.Builder() .header("Accept", "text/event-stream")
.url(url) .build()
.header("Accept", "text/event-stream") try {
.build() client.newCall(request).execute().use { resp ->
var backoff = 1_000L if (!resp.isSuccessful) return
while (true) { val body = resp.body ?: return
try { val source = body.source()
client.newCall(request).execute().use { resp -> var data: String? = null
if (!resp.isSuccessful) { var title: String? = null
IrisLog.w("ntfy stream HTTP ${resp.code}") var msgBody: String? = null
} else { while (!source.exhausted()) {
val body = resp.body ?: return val line = source.readUtf8Line() ?: break
readEvents(body.source()) 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.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
data = null
title = null
msgBody = null
}
} }
} }
} catch (e: Exception) {
IrisLog.w("ntfy stream dropped: ${e.message}")
} }
// Stream ended (EOF, error, or non-2xx): back off and reconnect. } catch (_: Exception) {
delay(backoff) // Stream dropped; the service is START_STICKY so the system
backoff = (backoff * 2).coerceAtMost(30_000L) // restarts it. If it keeps failing, the WS path still works.
} }
} }
/** private fun handleData(dataJson: String, title: String?, msgBody: String?) {
* Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF; val data = try {
* do NOT use `source.exhausted()` here — it reads until EOF and would IrisJson.instance.decodeFromString<JsonObject>(dataJson)
* block forever on a live stream. } catch (_: Exception) {
*/ null
private fun readEvents(source: okio.BufferedSource) {
var data: String? = null
var title: String? = null
var msgBody: String? = null
while (true) {
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.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
data = null
title = null
msgBody = null
}
}
} }
} val chatId = data?.str("chat_id") ?: "android:default"
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 threadId = data?.str("thread_id") val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the // The short preview rides in the SSE `data:` field; fall back to the
// X-Title, then a generic label. // X-Title, then a generic label.
@@ -173,12 +126,11 @@ class NtfyListenerService : Service() {
LISTENER_CHANNEL, LISTENER_CHANNEL,
"Iris push listener", "Iris push listener",
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_MIN,
), )
) )
} }
} }
return NotificationCompat return NotificationCompat.Builder(context, LISTENER_CHANNEL)
.Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris") .setContentTitle("Iris")
.setContentText("Listening for messages") .setContentText("Listening for messages")
@@ -194,4 +146,5 @@ class NtfyListenerService : Service() {
} }
/** Read a string field from a JSON object (null when absent / not a string). */ /** Read a string field from a JSON object (null when absent / not a string). */
private fun JsonObject?.str(key: String): String? = (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.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import iris.data.SecureStore import iris.data.SecureStore
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.setActiveController import iris.platform.setActiveController
@@ -27,21 +24,19 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage import iris.ui.theme.rememberBackgroundImage
import iris.util.PairLink
/** /**
* Root composable shared by the Android and Desktop shells. * Root composable shared by the Android and Desktop shells.
* *
* Routes between the Connect screen (unpaired / auth failed) and the main * M1: routes between the Connect screen (unpaired / auth failed) and the
* app (paired), which hosts the channel list, chat, search, settings, and * Chat screen (paired). Later milestones add the channel list, search,
* media (docs/10-android-app.md). * settings, and media (docs/10-android-app.md).
*/ */
@Composable @Composable
fun IrisApp( fun IrisApp(
store: SecureStore, store: SecureStore,
deepLinkChatId: String? = null, deepLinkChatId: String? = null,
deepLinkThreadId: String? = null, deepLinkThreadId: String? = null,
deepLinkPair: PairLink? = null,
) { ) {
val controller = remember(store) { IrisController(store) } val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) { DisposableEffect(controller) {
@@ -60,21 +55,9 @@ fun IrisApp(
} }
val state by controller.client.state.collectAsState() val state by controller.client.state.collectAsState()
val theme by controller.theme.collectAsState() val theme by controller.theme.collectAsState()
val fontScale by controller.fontSizeScale.collectAsState()
val baseDensity = LocalDensity.current
IrisTheme { IrisTheme {
// Font-size setting (Settings → Appearance): a multiplier on top of the CompositionLocalProvider(LocalUserTheme provides theme) {
// 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,
),
) {
// The color goes through Surface's `color` parameter: a // The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the // Modifier.background on the Surface would be painted *under* the
// Surface's own colorScheme.surface fill and never show through. // Surface's own colorScheme.surface fill and never show through.
@@ -99,49 +82,20 @@ fun IrisApp(
) )
} }
val s = state val s = state
// QR pairing (docs/20): an iris://pair deep link prefills
// the Connect screen, taking priority over stored creds.
val pairUrl = deepLinkPair?.url ?: store.serverUrl
val pairToken = deepLinkPair?.token ?: store.token
when (s) { when (s) {
GatewayClient.State.Disconnected -> { GatewayClient.State.Disconnected ->
key(deepLinkPair) { ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken) is GatewayClient.State.AuthFailed ->
} ConnectScreen(
} controller,
prefillUrl = store.serverUrl,
is GatewayClient.State.AuthFailed -> { prefillToken = store.token,
key(deepLinkPair) { initialError = "Pairing rejected: ${s.message}",
ConnectScreen( )
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
initialError = "Pairing rejected: ${s.message}",
)
}
}
// TLS: the gateway's certificate is untrusted and not
// the pinned one (docs/09 §9.4) — ask the user to
// confirm its fingerprint (SSH host-key style).
is GatewayClient.State.TlsConfirmRequired -> {
key(deepLinkPair) {
ConnectScreen(
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.",
tlsFingerprint = s.fingerprint,
)
}
}
// Connecting / Reconnecting / Connected all render the chat; the header // Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state // status bubble + connection banner show the link state
// without blocking the view (M7's full-screen spinner is gone). // without blocking the view (M7's full-screen spinner is gone).
else -> { else -> ChatScreen(controller)
ChatScreen(controller)
}
} }
// M9: full-screen HTML artifact preview (opened from an // M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open. // artifact card in the chat); covers everything while open.
@@ -26,32 +26,15 @@ class ChannelStore {
_channels.value = sorted(channels) _channels.value = sorted(channels)
} }
/** Restore the directory from the persistent local cache on startup
* (offline: the drawer is populated before the gateway connection is
* up). The server re-seeds it on connect (`hello.ack`). No-op when
* [channels] is empty (first launch). */
fun loadFromCache(channels: List<ChannelInfo>) {
if (channels.isNotEmpty()) _channels.value = sorted(channels)
}
/** Reconcile a server frame into the cache. */ /** Reconcile a server frame into the cache. */
fun onFrame(frame: Frame) { fun onFrame(frame: Frame) {
when (frame.type) { when (frame.type) {
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> { TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame)
upsert(frame) TYPE_CHANNEL_DELETED -> remove(frame)
}
TYPE_CHANNEL_DELETED -> {
remove(frame)
}
TYPE_CHANNEL_LIST -> { TYPE_CHANNEL_LIST -> {
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) } frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
} }
else -> Unit
else -> {
Unit
}
} }
} }
@@ -65,58 +48,7 @@ class ChannelStore {
private fun remove(frame: Frame) { private fun remove(frame: Frame) {
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
// A channel delete also removes its threads (child entries); a thread _channels.value = _channels.value.filter { it.chatId != p.chatId }
// delete removes just that thread. Both are hard deletes server-side.
_channels.value =
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
}
// ── Optimistic local updates (M-7) ────────────────────────────────────
//
// The gateway only broadcasts channel.created/renamed/deleted — there are
// no favorite/icon/automation/default events. So a toggle sent by THIS
// device would not update its own UI until a full channel.list re-fetch.
// These apply the change locally (optimistically); a later channel.list /
// hello.ack re-seed reconciles any divergence (e.g. a server rejection).
/** Toggle the cosmetic favorite flag locally. */
fun setFavorite(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(favorite = on) }
/** Toggle the automation flag locally. */
fun setAutomation(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(automation = on) }
/** Set the icon (base64) and/or avatar color locally; null clears a field. */
fun setIcon(
chatId: String,
icon: String?,
color: String?,
) = update(chatId) { it.copy(icon = icon, color = color) }
/** Make [chatId] the default channel locally (clearing the previous one). */
fun setDefault(chatId: String) {
_channels.value =
sorted(
_channels.value.map {
when {
it.chatId == chatId -> it.copy(isDefault = true)
it.isDefault -> it.copy(isDefault = false)
else -> it
}
},
)
}
private fun update(
chatId: String,
transform: (ChannelInfo) -> ChannelInfo,
) {
_channels.value = sorted(_channels.value.map { if (it.chatId == chatId) transform(it) else it })
} }
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> = private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
@@ -130,14 +62,18 @@ class ChannelStore {
// ── UI helpers ──────────────────────────────────────────────────────── // ── UI helpers ────────────────────────────────────────────────────────
/** Channels (default + user channels) for the drawer/rail. */ /** Channels (default + user channels) for the drawer/rail. */
fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" } fun channelsForDrawer(): List<ChannelInfo> =
_channels.value.filter { it.kind != "thread" }
/** Threads under a channel, for the topic switcher. */ /** Threads under a channel, for the topic switcher. */
fun threadsFor(chatId: String): List<ChannelInfo> = _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() { fun clear() {
_channels.value = emptyList() _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)
}
File diff suppressed because it is too large. Load diff
@@ -2,26 +2,16 @@ package iris.data
/** /**
* Pairing settings storage. The token is a secret: platform actuals keep it * Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android, OS keyring or an * in secure storage (EncryptedSharedPreferences on Android — M5; plain
* encrypted file on desktop). * SharedPreferences for M1 dev, file on desktop).
*/ */
interface SecureStore { interface SecureStore {
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */ /** ws(s)://host:port/ws */
var serverUrl: String var serverUrl: String
/** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */ /** ANDROID_TOKEN presented in the hello frame. */
var token: String var token: String
/** Per-device token minted at pairing (hello.ack ``device_token``,
* docs/09 §9.3). Presented INSTEAD of [token] when non-empty; the
* gateway can revoke it per device. Empty until the first hello.ack. */
var deviceToken: String
/** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS
* certificate, confirmed by the user on first pair (docs/09 §9.4).
* Empty = nothing pinned (CA-signed certs need no pin). */
var pinnedCertFingerprint: String
/** Stable app-generated device id (persisted). */ /** Stable app-generated device id (persisted). */
val deviceId: String val deviceId: String
@@ -40,11 +30,6 @@ interface SecureStore {
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */ /** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
var ntfyServer: String var ntfyServer: String
/** Push backend of the paired gateway ("fcm"/"ntfy"; from hello.ack
* server_caps; empty when unknown — decides whether the ntfy listener
* foreground service runs at all). */
var pushBackend: String
/** UI setting: show threads (topics) in the chat view. */ /** UI setting: show threads (topics) in the chat view. */
var threadsEnabled: Boolean var threadsEnabled: Boolean
@@ -54,10 +39,6 @@ interface SecureStore {
/** UI setting: stream assistant replies live (token by token). */ /** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean var streamingEnabled: Boolean
/** UI setting: streaming smoothness — seconds between visible text
* updates while streaming (0.2–0.8; lower = faster/smoother). */
var streamSmoothness: Float
/** UI setting: auto-collapse long reasoning blocks in the chat view. */ /** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean var reasoningAutoCollapse: Boolean
@@ -76,23 +57,6 @@ interface SecureStore {
/** UI setting: background image path (empty when unused). */ /** UI setting: background image path (empty when unused). */
var backgroundImagePath: String var backgroundImagePath: String
/** UI setting: text size multiplier (1.0 = default; applied on top of the fun savePairing(url: String, token: String)
* 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 clear() 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,43 +6,32 @@ import iris.protocol.KIND_IMAGE
import iris.protocol.KIND_VIDEO import iris.protocol.KIND_VIDEO
/** Map a MIME type to a media kind (docs/07 §7.1). */ /** Map a MIME type to a media kind (docs/07 §7.1). */
fun kindFromMime(mime: String): String = fun kindFromMime(mime: String): String = when {
when { mime.startsWith("image/") -> KIND_IMAGE
mime.startsWith("image/") -> KIND_IMAGE mime.startsWith("video/") -> KIND_VIDEO
mime.startsWith("video/") -> KIND_VIDEO mime.startsWith("audio/") -> KIND_AUDIO
mime.startsWith("audio/") -> KIND_AUDIO else -> KIND_DOCUMENT
else -> KIND_DOCUMENT }
}
/** Best-effort file extension for a MIME type (cache file naming). */ /** Best-effort file extension for a MIME type (cache file naming). */
fun extForMime(mime: String): String = fun extForMime(mime: String): String = when {
when { mime == "image/jpeg" -> ".jpg"
mime == "image/jpeg" -> ".jpg" mime == "image/png" -> ".png"
mime == "image/png" -> ".png" mime == "image/webp" -> ".webp"
mime == "image/webp" -> ".webp" mime == "image/gif" -> ".gif"
mime == "image/gif" -> ".gif" mime == "image/heic" -> ".heic"
mime == "image/heic" -> ".heic" mime == "image/heif" -> ".heif"
mime == "image/heif" -> ".heif" mime == "video/mp4" -> ".mp4"
mime == "video/mp4" -> ".mp4" mime == "video/webm" -> ".webm"
mime == "video/webm" -> ".webm" mime == "video/quicktime" -> ".mov"
mime == "video/quicktime" -> ".mov" mime == "audio/mpeg" -> ".mp3"
mime == "audio/mpeg" -> ".mp3" mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a" mime == "audio/ogg" -> ".ogg"
mime == "audio/ogg" -> ".ogg" mime == "audio/wav" -> ".wav"
mime == "audio/wav" -> ".wav" mime == "audio/flac" -> ".flac"
mime == "audio/flac" -> ".flac" mime == "audio/aac" -> ".aac"
mime == "audio/aac" -> ".aac" mime == "application/pdf" -> ".pdf"
mime == "application/pdf" -> ".pdf" mime == "application/zip" -> ".zip"
mime == "application/zip" -> ".zip" mime == "text/plain" -> ".txt"
mime == "text/plain" -> ".txt" else -> ".bin"
else -> ".bin" }
}
/**
* Validate a server-provided media id before it is used in a file path or a
* `GET /v1/media/{id}` URL (M-2 / S-1). The id comes from the gateway's
* `media.offer` frame; a value like `../../x` would write outside the media
* directory and break the pull URL. Only a conservative token charset is
* accepted.
*/
fun isValidMediaId(id: String): Boolean = id.length in 1..128 && id.all { it.isLetterOrDigit() || it == '_' || it == '-' }
@@ -1,48 +1,66 @@
package iris.net package iris.net
import iris.data.SecureStore import iris.data.SecureStore
import iris.media.FileSource
import iris.media.Sha256
import iris.protocol.ChannelInfo import iris.protocol.ChannelInfo
import iris.protocol.ErrorPayload
import iris.protocol.Frame import iris.protocol.Frame
import iris.protocol.HelloAckPayload import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaPullEndPayload
import iris.protocol.MediaUploadAckPayload
import iris.protocol.ServerCaps import iris.protocol.ServerCaps
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HELLO_ACK
import iris.protocol.TYPE_MEDIA_PULL_END
import iris.protocol.TYPE_MEDIA_UPLOAD_ACK
import iris.protocol.TYPE_PONG
import iris.protocol.helloFrame
import iris.protocol.mediaPullFrame
import iris.protocol.mediaUploadEndFrame
import iris.protocol.mediaUploadStartFrame
import iris.protocol.messageSendFrame import iris.protocol.messageSendFrame
import iris.protocol.pingFrame
import iris.protocol.syncFrame import iris.protocol.syncFrame
import iris.util.IrisLog
import iris.util.nowMillis
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okio.ByteString
import okio.ByteString.Companion.toByteString
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
/** /**
* 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 * - connect + hello (real auth leg), hello.ack
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
*
* - connect: health probe + SSE hello (the HTTP hello.ack)
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect * - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - events: server frames on [events] * - heartbeat: app-level ping every 20s; reap after ~60s of silence
* - correlation: the POST body carries the synchronous reply (e.g. read * - events: server frames (minus hello.ack) on [events]
* receipt, errors); everything else arrives on the event stream, and the * - request/response correlation by id (M2+ consumers)
* app reconciles by frame id (docs/19 §19.7)
*/ */
class GatewayClient( class GatewayClient(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -50,30 +68,10 @@ class GatewayClient(
) { ) {
sealed interface State { sealed interface State {
data object Disconnected : State data object Disconnected : State
data object Connecting : State data object Connecting : State
data class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
data class Connected(
val caps: ServerCaps,
val channels: List<ChannelInfo>,
/** M5: highest outbox cursor already pushed to this device
* (from hello.ack; 0 = never). Sync-replayed frames at/below
* it must not re-post system notifications (docs/08 §8.7). */
val lastPushedCursor: Long = 0,
) : State
data object Reconnecting : State data object Reconnecting : State
data class AuthFailed(val message: String) : State
data class AuthFailed(
val message: String,
) : State
/** The gateway's TLS certificate is untrusted and doesn't match the
* pinned fingerprint (docs/09 §9.4). Terminal: the connect loop
* stops and the UI asks the user to confirm the fingerprint. */
data class TlsConfirmRequired(
val fingerprint: String,
) : State
} }
private val _state = MutableStateFlow<State>(State.Disconnected) private val _state = MutableStateFlow<State>(State.Disconnected)
@@ -86,79 +84,36 @@ class GatewayClient(
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128) private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
val events: SharedFlow<Frame> = _events.asSharedFlow() val events: SharedFlow<Frame> = _events.asSharedFlow()
// TLS: the pinning trust manager wraps the platform default (CA-signed private val client: OkHttpClient = OkHttpClient.Builder()
// certs behave as before); a self-signed gateway cert is accepted only .pingInterval(20, TimeUnit.SECONDS)
// after the user confirms its fingerprint (docs/09 §9.4). The pin is .build()
// read live from the store so a fresh confirm takes effect without
// rebuilding the client.
private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint }
private val client: OkHttpClient =
OkHttpClient
.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
.build()
private var connectJob: Job? = null private var connectJob: Job? = null
private var socket: WebSocket? = null
private var nextRequestId = 1 private var nextRequestId = 1
// Incremented from the SSE callback thread (onHttpHello) and the UI
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
// request/response correlation breaks.
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
// Written by poke() (UI thread) and the connect loop (Default).
@Volatile
private var attempt = 0 private var attempt = 0
// Set by poke() (app returned to the foreground): the connect loop's
// backoff waits in 500 ms slices and re-probes immediately when set.
@Volatile
private var wakeRequested = false
// True once a connection has been established this session; reset by // True once a connection has been established this session; reset by
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a // start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen. // drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is // M4: binary frames (media upload chunks / pull stream) have no per-frame
// the resume cursor (SSE id / outbox high-water mark), updated from the // id, so at most one binary session is active per socket. The gateway
// SSE/poll callback threads. // allows one upload per connection; pull is request/response.
private var http: HttpGateway? = null private sealed interface BinarySession {
data class Pulling(
val requestId: Int,
val chunks: Channel<ByteArray>,
val end: CompletableDeferred<Frame>,
) : BinarySession
}
@Volatile private var binarySession: BinarySession? = null
private var httpCursor: Long = 0
private var sseFailures = 0
private var usingLongPoll = false
// Last hello.ack payload — used to restore State.Connected after a // M4: only one pull may be in flight at a time (binarySession is a single
// reconnect state race in the connect loop. Written by the SSE callback // slot). Serialize concurrent offers so their byte streams don't interleave.
// thread, read by the long-poll loop.
@Volatile
private var lastAck: HelloAckPayload? = null
// Epoch ms of the last frame delivered by the receive stream (SSE or
// long-poll). The watchdog uses this to detect a STALE stream: a live
// connection (heartbeats / poll answers keep it open, so the read timeout
// never fires) that has stopped delivering frames — the state a gateway
// restart can leave the app in, where sent messages sit at "sending…"
// because their echo is never delivered (issue #6). Set when the receive
// loop (re)starts so the first window isn't treated as stale.
@Volatile
private var lastFrameMs: Long = 0L
/**
* Fired promptly the moment the SSE hello (hello.ack) is received — on
* every (re)connect. Used for time-critical work that must not wait for
* the state collector, which can be starved for seconds during app
* startup (Dispatchers.Default) and would push a history request past a
* flaky network's window. Set before [start].
*/
var onHelloAck: ((State.Connected) -> Unit)? = null
// Only one pull may be in flight at a time. Serialize concurrent offers so
// their byte streams don't interleave.
private val pullMutex = Mutex() private val pullMutex = Mutex()
// ── Lifecycle ───────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────
@@ -171,34 +126,13 @@ class GatewayClient(
connectJob = scope.launch { connectLoop() } connectJob = scope.launch { connectLoop() }
} }
/** /** Stop the connect loop and close the socket. */
* Stop the connect loop and reset the HTTP leg. Without the reset, a
* re-pair to a *different* gateway would keep using the cached
* [HttpGateway] (old URL, old token, old resume cursor) until the process
* is killed.
*/
fun stop() { fun stop() {
connectJob?.cancel() connectJob?.cancel()
connectJob = null connectJob = null
socket?.close(1000, "client shutdown")
socket = null
_state.value = State.Disconnected _state.value = State.Disconnected
http?.close()
http = null
httpCursor = 0
sseFailures = 0
usingLongPoll = false
lastAck = null
}
/**
* Call when the app returns to the foreground: if the connect loop is
* between attempts (backing off after failed health probes — up to 30 s),
* wake it so it re-probes immediately instead of making the user wait out
* the backoff with a "Connecting…" banner. No-op while connected.
*/
fun poke() {
if (_state.value is State.Connected) return
attempt = 0
wakeRequested = true
} }
/** Re-pair: stop, then start fresh (used after saving new settings). */ /** Re-pair: stop, then start fresh (used after saving new settings). */
@@ -210,294 +144,178 @@ class GatewayClient(
private suspend fun connectLoop() { private suspend fun connectLoop() {
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
val url = store.serverUrl.trim() val url = store.serverUrl.trim()
// Per-device token when the gateway minted one (docs/09 §9.3), val token = store.token
// else the shared IRIS_TOKEN (bootstrap).
val token = store.deviceToken.ifBlank { store.token }
if (url.isBlank() || token.isBlank()) { if (url.isBlank() || token.isBlank()) {
_state.value = State.Disconnected _state.value = State.Disconnected
return return
} }
_state.value = if (hasConnected) State.Reconnecting else State.Connecting _state.value = if (hasConnected) State.Reconnecting else State.Connecting
val gw = httpGateway() ?: continue val dial = dial(url, token)
// Health probe: if the gateway is alive, open the SSE receive loop when (val result = dial.result) {
// (which delivers the hello.ack). Otherwise back off and retry. is DialResult.AuthFailed -> {
val healthOk = _state.value = State.AuthFailed(result.message)
try { dial.socket.close(1000, "auth failed")
gw.health() return
} catch (e: Exception) {
if (failTls(e)) return
false
} }
if (!healthOk) { DialResult.Connected -> {
attempt++ hasConnected = true
backoffOrWake(backoffMs(attempt)) attempt = 0
continue lastLiveness = TimeSource.Monotonic.markNow()
} dial.closed.await()
attempt = 0 if (!currentCoroutineContext().isActive) return
hasConnected = true // socket dropped -> loop again (Reconnecting)
sseFailures = 0 }
usingLongPoll = false is DialResult.Failed -> {
httpCursor = store.syncCursor attempt++
lastFrameMs = nowMs() delay(backoffMs(attempt))
// 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,
)
// Run the HTTP receive loop (SSE/long-poll) until cancelled.
coroutineScope {
val receiveJob = launch { httpReceiveLoop(gw) }
// Dead-stream watchdog: the SSE read timeout (45 s) is the
// only in-stream dead-connection detector; probe /v1/health
// in parallel so a dropped network flips the state to
// Reconnecting within ~20 s (2 failed probes) instead of 45,
// and a stuck stream can't keep a stale "Connected".
var probeFailures = 0
while (receiveJob.isActive) {
delay(10_000)
val ok =
try {
gw.health()
} catch (e: Exception) {
if (failTls(e)) {
receiveJob.cancel()
break
}
false
}
probeFailures = if (ok) 0 else probeFailures + 1
if (probeFailures >= 2) {
receiveJob.cancel()
break
}
// Stale-stream watchdog: the gateway is up (health ok) but
// the receive stream has delivered no frames for a while —
// a live-but-dead connection the read timeout can't see.
// Force a fresh (re)connect so parked frames (e.g. our own
// echo) are re-delivered from the outbox.
if (lastFrameMs > 0L && nowMs() - lastFrameMs > STALE_STREAM_TIMEOUT_MS) {
IrisLog.w("receive stream stale (no frames for ${STALE_STREAM_TIMEOUT_MS}ms); forcing reconnect")
receiveJob.cancel()
break
}
} }
receiveJob.join()
} }
// Terminal failures: don't redial with the same bad token / the
// same untrusted certificate (the UI asks the user to act).
if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return
} }
} }
// ── HTTP receive leg ────────────────────────────────────────────────── // ── Dial (one connect + hello) ────────────────────────────────────────
/** Lazily build the HTTP client from the stored URL. Synchronized: two private sealed interface DialResult {
* threads racing the check-then-create would leak a gateway (and its data object Connected : DialResult
* OkHttp clients). */ data class AuthFailed(val message: String) : DialResult
private fun httpGateway(): HttpGateway? = data class Failed(val message: String) : DialResult
synchronized(this) { }
val url = store.serverUrl.trim()
val token = store.deviceToken.ifBlank { store.token }
if (url.isBlank() || token.isBlank()) return@synchronized null
http
?: HttpGateway(
client,
HttpGateway.deriveHttpUrl(url),
// Live provider: a device token minted by the next
// hello.ack is picked up without rebuilding the client.
token = { store.deviceToken.ifBlank { store.token } },
store.deviceId,
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
).also { http = it }
}
/** private data class Dial(
* The HTTP receive loop: SSE by default; after two consecutive SSE open val result: DialResult,
* failures (buffering proxy) it switches to long-poll until the next full val socket: WebSocket,
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled. val closed: CompletableDeferred<Unit>,
*/ )
private suspend fun httpReceiveLoop(gw: HttpGateway) {
var backoff = 1_000L private suspend fun dial(url: String, token: String): Dial {
while (currentCoroutineContext().isActive) { val closed = CompletableDeferred<Unit>()
if (usingLongPoll) { val helloAck = CompletableDeferred<HelloAckPayload>()
try { val authError = CompletableDeferred<String>()
val res = gw.poll(httpCursor) val fail = CompletableDeferred<String>()
res.frames.forEach { emitHttpFrame(it) }
if (res.cursor > httpCursor) httpCursor = res.cursor val request = Request.Builder().url(url).build()
// The poll answered: the link is back (long-poll has no val ws = client.newWebSocket(
// hello — restore the Connected state from the last one). request,
restoreConnected() object : WebSocketListener() {
} catch (e: HttpGateway.HttpAuthException) { override fun onOpen(webSocket: WebSocket, response: Response) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)") webSocket.send(
return helloFrame(
} catch (e: Exception) { token = token,
if (failTls(e)) return deviceId = store.deviceId,
markStreamLost() deviceName = store.deviceName,
IrisLog.w("http poll failed: ${e.message}") fcmToken = store.fcmToken.ifBlank { null },
delay(backoff) ntfyTopic = store.ntfyTopic.ifBlank { null },
backoff = minOf(backoff * 2, 15_000) ).toWire(),
}
} 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() },
) )
// Clean EOF: back off briefly so a server that keeps
// closing cleanly can't tight-loop the reconnect.
backoff = 1_000L
delay(backoff)
} catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return
} catch (e: Exception) {
if (failTls(e)) return
sseFailures++
markStreamLost()
if (sseFailures >= 2) {
// SSE seems blocked: switch to long-poll.
usingLongPoll = true
continue
}
IrisLog.w("sse read failed: ${e.message}")
delay(backoff)
backoff = minOf(backoff * 2, 15_000)
} }
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
}
}
}
}
}
}
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 = nextRequestId++
ws.send(syncFrame(id, local).toWire())
}
winner.complete(DialResult.Connected)
} }
} }
} authError.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
/** The SSE `event: hello` (the HTTP hello.ack). */
private fun onHttpHello(ack: HelloAckPayload) {
lastAck = ack
// Per-device token (docs/09 §9.3): minted at pairing, stable across
// (re)connects. Store it — from the next request on the app presents
// it instead of the shared IRIS_TOKEN, so the gateway can revoke
// THIS device without touching the others.
if (ack.deviceToken.isNotBlank() && ack.deviceToken != store.deviceToken) {
store.deviceToken = ack.deviceToken
} }
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor) fail.invokeOnCompletion { e ->
_state.value = connected if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
// 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)) }
} }
// Prompt fast path (before the possibly-starved state collector). val result = withTimeoutOrNull(15_000) { winner.await() }
onHelloAck?.invoke(connected) ?: DialResult.Failed("timeout waiting for hello.ack")
} return Dial(result, ws, closed)
/** 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})")
}
}
/** 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)")
}
}
/** Terminal TLS failure: the presented certificate is untrusted and not
* the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect
* loop and let the UI ask the user to confirm the fingerprint. True when
* the state was set. */
private fun failTls(e: Exception): Boolean {
val tls = tlsFingerprintRequired(e) ?: return false
IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})")
_state.value = State.TlsConfirmRequired(tls.fingerprint)
return true
} }
// ── Outbound ────────────────────────────────────────────────────────── // ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back). /** Send a text message (fire-and-forget; the server echoes it back).
* [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 * [autoThread] asks the gateway to mint a fresh thread for the message
* (auto-threading, docs/06 §6.3). * (auto-threading, docs/06 §6.3). */
* [onResult] is called with the POST's HTTP status — 0 means "no
* response" (not connected, or the network failed); 2xx means the
* gateway accepted it; 4xx is a gateway rejection (error frame already
* delivered via [events]). Used to fail the optimistic bubble instead
* of leaving it at "sending…" forever. */
fun sendMessage( fun sendMessage(
chatId: String, chatId: String,
text: String, text: String,
threadId: String? = null, threadId: String? = null,
mediaRefs: List<String> = emptyList(), mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false, autoThread: Boolean = false,
onResult: ((Int) -> Unit)? = null,
) { ) {
if (_state.value !is State.Connected) { val ws = socket ?: return
onResult?.invoke(0) val id = nextRequestId++
return ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
}
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)
}
} }
// ── Media upload / pull ─────────────────────────────────────────────── // ── M4: media upload / pull ───────────────────────────────────────────
/** /**
* Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2). * Upload a local file as media (docs/07 §7.2): media.upload.start,
* Returns the server's media_ref (for message.send media_refs) on success. * 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's
* media_ref (for message.send media_refs) on success.
*/ */
suspend fun uploadMedia( suspend fun uploadMedia(
path: String, path: String,
@@ -506,140 +324,163 @@ class GatewayClient(
filename: String, filename: String,
mediaRef: String, mediaRef: String,
): Result<String> { ): Result<String> {
val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected")) val ws = socket ?: return Result.failure(IllegalStateException("not connected"))
return http.uploadMedia(path, mime, kind, filename, mediaRef) 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 * Pull offered media (docs/07 §7.3): media.pull, then binary frames until
* chunk is handed to [onChunk] (write to cache). * media.pull.end. Each chunk is handed to [onChunk] (write to cache).
*/ */
suspend fun pullMedia( suspend fun pullMedia(mediaId: String, onChunk: suspend (ByteArray) -> Unit): Result<Unit> =
mediaId: String,
onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> =
pullMutex.withLock { pullMutex.withLock {
val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected")) val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected"))
http.pullMedia(mediaId) { chunk -> onChunk(chunk) } 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 { companion object {
/** No frames (or keep-alive comments) from the receive stream for /** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */
* this long (gateway still const val UPLOAD_CHUNK_BYTES = 256 * 1024
* healthy) = stale stream: force a fresh (re)connect. 3 min keeps an const val UPLOAD_TIMEOUT_MS = 120_000L
* idle app from churning while still recovering a stuck stream well const val PULL_TIMEOUT_MS = 300_000L
* before a user notices (issue #6). */
const val STALE_STREAM_TIMEOUT_MS = 180_000L
} }
/** /**
* Send an arbitrary frame with a fresh request id (fire-and-forget). The * Send an arbitrary frame with a fresh request id (fire-and-forget).
* server replies (or broadcasts) a frame carrying the same id; the app * The server replies (or broadcasts) a frame carrying the same id; the
* reconciles from [events]. Returns the id used, or -1 if not connected. * app reconciles from [events]. Returns the id used, or -1 if not connected.
*/ */
fun sendFrame(frame: Frame): Int { fun sendFrame(frame: Frame): Int {
if (_state.value !is State.Connected) return -1 val ws = socket ?: return -1
val id = nextId() val id = nextRequestId++
scope.launch { ws.send(frame.copy(id = id).toWire())
val res = httpGateway()?.postFrame(frame.copy(id = id))
// Single-frame responses (commands.catalog, channel.list, search,
// history, sync, errors) come back in the POST body, not on the
// event stream — deliver it or it is lost (docs/19 §19.7).
handlePostResult(res)
}
return id return id
} }
/** Send a ping (heartbeat). */
fun ping() {
socket?.send(pingFrame().toWire())
}
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
fun isStale(timeoutMs: Long = 60_000): Boolean =
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
fun reapStale() {
if (isStale()) {
socket?.close(1000, "heartbeat timeout")
}
}
// ── One-shot hello test (Connect screen) ────────────────────────────── // ── One-shot hello test (Connect screen) ──────────────────────────────
/** /**
* Real connection test: health probe + SSE open. The auth leg is proven * Real `hello` test: dial, wait for hello.ack (or auth error), close.
* by the stream being accepted (200 vs 401) — we do NOT wait for the * Exercises the auth leg, not just TCP (docs/10 §10.8).
* hello event, because the server replays the outbox (up to 72 h of
* frames) before it and a large outbox would time out a healthy gateway
* (docs/10 §10.8).
*/ */
suspend fun testHello( suspend fun testHello(url: String, token: String): Result<Unit> {
url: String, val dial = dial(url, token)
token: String, return when (val result = dial.result) {
): Result<Unit> { DialResult.Connected -> {
val gw = dial.socket.close(1000, "test complete")
HttpGateway( Result.success(Unit)
client, }
HttpGateway.deriveHttpUrl(url), is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message))
// An already-paired device presents its per-device token is DialResult.Failed -> Result.failure(IllegalStateException(result.message))
// (docs/09 §9.3); a fresh pairing falls back to the entered }
// shared token (bootstrap). }
token = { store.deviceToken.ifBlank { token } },
store.deviceId, // ── Heartbeat job ─────────────────────────────────────────────────────
deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } }, fun startHeartbeat() {
ntfyTopic = { store.ntfyTopic.ifBlank { null } }, scope.launch {
) while (isActive) {
return try { delay(20_000)
if (!gw.health()) { if (_state.value is State.Connected) {
Result.failure(IllegalStateException("gateway unreachable")) ping()
} else { reapStale()
// Open the SSE stream briefly: 200 = auth leg proven, 401 =
// bad token. Don't wait for the hello (outbox replay first).
val opened = CompletableDeferred<Unit>()
val job =
scope.launch {
try {
gw.events(
cursor = store.syncCursor,
onOpen = { opened.complete(Unit) },
onHello = { },
onFrame = { },
onCursor = { },
)
} catch (e: Exception) {
opened.completeExceptionally(e)
}
}
try {
if (withTimeoutOrNull(10_000) { opened.await() } == null) {
Result.failure(IllegalStateException("timeout opening the event stream"))
} else {
Result.success(Unit)
}
} catch (e: HttpGateway.HttpAuthException) {
Result.failure(IllegalStateException("unauthorized — check the pairing token"))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Unwrap the pinning manager's signal so the Connect
// screen can offer the fingerprint confirm dialog.
Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}"))
} finally {
job.cancel()
} }
} }
} catch (e: Exception) {
Result.failure(tlsFingerprintRequired(e) ?: e)
} }
} }
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
/** Backoff that wakes early when [poke] is called (app foregrounded). */
private suspend fun backoffOrWake(ms: Long) {
var remaining = ms
while (remaining > 0 && currentCoroutineContext().isActive) {
delay(minOf(remaining, 500L))
if (wakeRequested) {
wakeRequested = false
return
}
remaining -= 500L
}
}
private fun backoffMs(attempt: Int): Long { private fun backoffMs(attempt: Int): Long {
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
val capped = minOf(base, 30_000L) val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500) return capped + Random.nextLong(0, 500)
} }
private fun nowMs(): Long = nowMillis()
} }
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
@@ -1,507 +0,0 @@
package iris.net
import iris.AppVersion
import iris.media.Sha256
import iris.media.isValidMediaId
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.MediaUploadAckPayload
import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* HTTP transport client (docs/19) — the only transport.
*
* The app sends over `POST /v1/frame` and receives over SSE
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
* media travels via `POST/GET /v1/media`.
*
* [events] is ONE SSE connection attempt (blocking read on
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
* outbox cursor, so resume is just the last seen id).
*/
class HttpGateway(
private val client: OkHttpClient,
private val baseUrl: String,
/** Live auth-token provider, read per request: the per-device token
* (docs/09 §9.3) when the gateway minted one, else the shared
* IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is
* picked up without rebuilding the client. */
private val token: () -> String,
private val deviceId: String,
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
* upserts it into the device registry on every SSE open — the HTTP
* equivalent of the old WS hello upsert). */
private val deviceName: String? = null,
/** Live push-token providers, read per request so a rotated FCM token or
* a fresh ntfy topic is picked up without rebuilding the client. */
private val fcmToken: () -> String? = { null },
private val ntfyTopic: () -> String? = { null },
) {
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
* with the same token can't succeed. */
class HttpAuthException : IOException("unauthorized (HTTP 401)")
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
// with extended call timeouts (see the *Client() helpers below).
private val healthClient: OkHttpClient = client.healthClient()
private val streamClient: OkHttpClient = client.streamClient()
private val pollClient: OkHttpClient = client.pollClient()
private val mediaClient: OkHttpClient = client.mediaClient()
/** Close the per-purpose clients (and their idle connections). The
* shared base [client] is owned by the caller. */
fun close() {
healthClient.dispatcher.executorService.shutdown()
streamClient.dispatcher.executorService.shutdown()
pollClient.dispatcher.executorService.shutdown()
mediaClient.dispatcher.executorService.shutdown()
healthClient.connectionPool.evictAll()
streamClient.connectionPool.evictAll()
pollClient.connectionPool.evictAll()
mediaClient.connectionPool.evictAll()
}
/** POST /v1/frame result. [frame] is the handler's synchronous reply
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
* 202 accept-and-ack. */
data class PostResult(
val ok: Boolean,
val status: Int,
val frame: Frame?,
)
/** Long-poll result: new high-water [cursor] + frames with cursor >
* the requested one (may be empty at timeout). */
data class PollResult(
val cursor: Long,
val frames: List<Frame>,
)
companion object {
val JSON = "application/json".toMediaType()
/** Default port of the gateway's HTTP leg (WS default is 8790). */
const val DEFAULT_PORT = 8791
/** Media transfer chunk (docs/07 §7.5). */
private const val MEDIA_CHUNK_BYTES = 256 * 1024
/**
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
* a different service, so the port is always replaced with the
* HTTP leg's default. Pure function (unit-tested).
*/
fun deriveHttpUrl(wsUrl: String): String {
val u = wsUrl.trim()
val (scheme, rest) =
when {
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
else -> return u // already http(s)
}
val authority = rest.substringBefore('/')
val host = authority.substringBefore(':')
return "$scheme://$host:$DEFAULT_PORT"
}
}
private fun authHeaders(): Headers {
val b =
Headers
.Builder()
.add("Authorization", "Bearer ${token()}")
.add("X-Iris-Device", deviceId)
// Device registration (docs/19): the gateway upserts name + push
// tokens from these headers on every SSE open (COALESCE — absent
// headers never clobber a newer fcm.register value).
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
// Release version (repo-root VERSION baked in at build time); the
// gateway stores it in the device registry (docs/04 hello.ack note).
b.add("X-Iris-App-Version", AppVersion.VERSION)
return b.build()
}
/** Parse a response body as a protocol frame (null when not a frame,
* e.g. the plain `{"ok":true}` ack). */
private fun parseFrame(body: String): Frame? =
try {
if (body.startsWith("{")) {
val obj = IrisJson.instance.parseToJsonElement(body)
if (obj.jsonObject.containsKey("type")) {
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
} else {
null
}
} else {
null
}
} catch (e: Exception) {
null
}
/** Liveness probe (unauthenticated by design). True on 200. */
suspend fun health(): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/health")
.build()
healthClient
.newCall(request)
.execute()
.use { response ->
response.body?.close()
response.code == 200
}
}
/**
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
* (with the synchronous reply frame when the handler sent one); 4xx ->
* the error frame as the body. Network failures (timeout, reset, DNS —
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
* callers are fire-and-forget coroutines — an uncaught exception here
* kills the app process.
*/
suspend fun postFrame(frame: Frame): PostResult =
withContext(Dispatchers.IO) {
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
val request =
Request
.Builder()
.url("$baseUrl/v1/frame")
.headers(authHeaders())
.post(wire.toRequestBody(JSON))
.build()
try {
client
.newCall(request)
.execute()
.use { response ->
val body = response.body?.string().orEmpty()
val parsed = parseFrame(body)
PostResult(response.isSuccessful, response.code, parsed)
}
} catch (e: Exception) {
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
PostResult(ok = false, status = 0, frame = null)
}
}
/**
* One SSE connection attempt: outbox catch-up from [cursor], then live
* frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
* leg is proven; the server may still replay a large outbox before the
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
* cursor) when present; [onKeepAlive] for SSE comment lines (the
* heartbeat) so callers can count keep-alives toward liveness. Returns
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
* on the IO thread.
*/
suspend fun events(
cursor: Long,
onOpen: (() -> Unit)? = null,
onHello: (HelloAckPayload) -> Unit,
onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit,
onKeepAlive: (() -> Unit)? = null,
) {
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/events?cursor=$cursor")
.headers(authHeaders())
.build()
streamClient
.newCall(request)
.execute()
.use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) {
throw IOException("SSE open failed: HTTP ${response.code}")
}
onOpen?.invoke()
val source = response.body?.source() ?: throw IOException("empty SSE body")
var eventId: String? = null
val dataLines = mutableListOf<String>()
while (true) {
val line = source.readUtf8Line() ?: break // EOF
when {
line.isEmpty() -> {
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n")
try {
val frame =
IrisJson.instance.decodeFromString(
Frame.serializer(),
data,
)
when (eventId) {
"hello" -> {
onHello(
frame.payloadAs<HelloAckPayload>()
?: HelloAckPayload(),
)
}
else -> {
onFrame(frame)
}
}
} catch (e: Exception) {
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
}
}
eventId = null
dataLines.clear()
}
line.startsWith(":") -> {
// heartbeat comment
onKeepAlive?.invoke()
}
line.startsWith("id:") -> {
line
.removePrefix("id:")
.trim()
.toLongOrNull()
?.let(onCursor)
}
line.startsWith("event:") -> {
eventId = line.removePrefix("event:").trim()
}
line.startsWith("data:") -> {
dataLines.add(line.removePrefix("data:").removePrefix(" "))
}
}
}
}
}
}
/**
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
* Returns the new high-water cursor + any frames with cursor > [cursor].
*/
suspend fun poll(cursor: Long): PollResult =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url("$baseUrl/v1/poll?cursor=$cursor")
.headers(authHeaders())
.build()
pollClient
.newCall(request)
.execute()
.use { response ->
if (response.code == 401) throw HttpAuthException()
if (!response.isSuccessful) {
throw IOException("poll failed: HTTP ${response.code}")
}
val body = response.body?.string().orEmpty()
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
val frames =
obj["frames"]
?.jsonArray
?.mapNotNull { el ->
try {
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
} catch (e: Exception) {
null
}
}.orEmpty()
PollResult(newCursor, frames)
}
}
/**
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
* with the whole file as the body and the metadata in `X-Iris-Media-*`
* headers (sha256 precomputed in a first pass). Returns the server's
* media_ref (for message.send media_refs) on success.
*/
suspend fun uploadMedia(
path: String,
mime: String,
kind: String,
filename: String,
mediaRef: String,
): Result<String> =
withContext(Dispatchers.IO) {
val file = File(path)
if (!file.isFile() || file.length() <= 0) {
return@withContext Result.failure(IllegalStateException("empty file"))
}
val sha = Sha256()
file.inputStream().use { ins ->
val buf = ByteArray(MEDIA_CHUNK_BYTES)
while (true) {
val n = ins.read(buf)
if (n < 0) break
if (n > 0) sha.update(buf, 0, n)
}
}
val request =
Request
.Builder()
.url("$baseUrl/v1/media")
.headers(
authHeaders()
.newBuilder()
.add("X-Iris-Media-Ref", mediaRef)
.add("X-Iris-Media-Kind", kind)
.add("X-Iris-Media-Filename", filename)
.add("X-Iris-Media-Sha256", sha.hex())
.build(),
).post(file.asRequestBody(mime.toMediaType()))
.build()
try {
mediaClient
.newCall(request)
.execute()
.use { response ->
val body = response.body?.string().orEmpty()
val frame = parseFrame(body)
if (response.isSuccessful) {
val p = frame?.payloadAs<MediaUploadAckPayload>()
if (p != null && p.ok) {
Result.success(p.mediaRef)
} else {
Result.failure(IllegalStateException("upload rejected by server"))
}
} else {
val e = frame?.payloadAs<ErrorPayload>()
Result.failure(
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
)
}
}
} catch (e: Exception) {
// Network failure mid-upload: report, don't throw (the caller
// is a fire-and-forget coroutine — an uncaught exception kills
// the app process).
IrisLog.w("media upload failed: ${e.message}")
Result.failure(e)
}
}
/**
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
* response body is the file, streamed to [onChunk] (write to cache).
*/
suspend fun pullMedia(
mediaId: String,
onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> =
withContext(Dispatchers.IO) {
// Server-controlled id: reject path-traversal / URL-breaking
// values before they reach the request line (M-2 / S-1).
if (!isValidMediaId(mediaId)) {
return@withContext Result.failure(IllegalStateException("invalid media id"))
}
val request =
Request
.Builder()
.url("$baseUrl/v1/media/$mediaId")
.headers(authHeaders())
.build()
try {
mediaClient
.newCall(request)
.execute()
.use { response ->
if (!response.isSuccessful) {
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
Result.failure(
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
)
} else {
try {
val source = response.body?.source() ?: throw IOException("empty body")
val buf = ByteArray(MEDIA_CHUNK_BYTES)
while (true) {
val n = source.read(buf)
if (n < 0) break
if (n == 0) continue
onChunk(buf.copyOfRange(0, n))
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
} catch (e: Exception) {
// Network failure before/while opening the pull: report, don't
// throw (fire-and-forget caller — uncaught = process death).
IrisLog.w("media pull failed: ${e.message}")
Result.failure(e)
}
}
}
/**
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
* (25 s) — it would kill both receive paths while they are simply waiting
* for the next byte, so the streaming clients override it. The read timeout
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
* comment every 15 s; a healthy poll answers within 25 s).
*/
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
* heartbeat (detects a dead connection within 45 s). */
internal fun OkHttpClient.streamClient(): OkHttpClient =
newBuilder()
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(45_000, TimeUnit.MILLISECONDS)
.build()
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
* hold + 15 s margin. */
internal fun OkHttpClient.pollClient(): OkHttpClient =
newBuilder()
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(40_000, TimeUnit.MILLISECONDS)
.build()
internal fun OkHttpClient.healthClient(): OkHttpClient =
newBuilder()
.callTimeout(2_000, TimeUnit.MILLISECONDS)
.build()
/** Media transfers (upload/pull) can take a while on large files. */
internal fun OkHttpClient.mediaClient(): OkHttpClient =
newBuilder()
.callTimeout(300_000, TimeUnit.MILLISECONDS)
.build()
@@ -1,96 +0,0 @@
package iris.net
import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
/**
* Latest-release check (docs/04 hello.ack note): the repo is public on Gitea,
* so the app can ask for the newest release tag without any token. Settings
* shows "vX.Y.Z available" when the running build is older.
*
* Best-effort by design: any failure (offline, DNS, rate limit) just yields
* `null` — the check must never surface an error in the UI.
*/
object ReleaseCheck {
const val LATEST_RELEASE_URL =
"https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest"
/**
* One shared client for the process lifetime: an OkHttpClient owns a
* thread pool and connection pool, so it must not be rebuilt per screen
* open (and never needs explicit shutdown — the pools idle out). Short
* timeouts so a black-holed network can't linger the LaunchedEffect.
*/
private val client: OkHttpClient =
OkHttpClient
.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
/**
* The latest release version (tag without the leading "v"), or `null`
* when the check could not be performed.
*/
suspend fun latestVersion(): String? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(LATEST_RELEASE_URL)
.get()
.build()
try {
client.newCall(request).execute().use { response: Response ->
if (!response.isSuccessful) {
IrisLog.d("ReleaseCheck: HTTP ${response.code}")
return@withContext null
}
val body = response.body?.string() ?: return@withContext null
val tag =
IrisJson
.instance
.parseToJsonElement(body)
.jsonObject
.get("tag_name")
?.jsonPrimitive
?.content
.orEmpty()
tag.removePrefix("v").ifBlank { null }
}
} catch (e: Exception) {
IrisLog.d("ReleaseCheck: ${e.message}")
null
}
}
/**
* True when [latest] is a newer release than [running]. Numeric
* component-wise comparison (so "0.1.10" > "0.1.9"); anything that does
* not parse as dotted numbers is treated as "not newer" — the hint is
* best-effort and must never fire for dev builds ahead of the latest
* release.
*/
fun isNewer(
latest: String,
running: String,
): Boolean {
val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
if (a.isEmpty() || b.isEmpty()) return false
val n = maxOf(a.size, b.size)
for (i in 0 until n) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
}
return false
}
}
@@ -1,118 +0,0 @@
package iris.net
import java.security.KeyStore
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
*
* A gateway behind `IRIS_HTTP_CERT` may present a
* self-signed certificate the platform doesn't trust. Instead of forcing the
* user to install it into the system trust store, the app shows the
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
* the user confirms it, the fingerprint is pinned in secure storage and
* [PinningTrustManager] accepts exactly that certificate from then on.
*
* Hostname verification is NOT bypassed: OkHttp runs its own hostname check
* on top of the trust manager, so a pinned cert still has to match the URL's
* host. A *changed* certificate (different fingerprint) fails again with
* [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH
* host key.
*/
/**
* The gateway presented a certificate the platform doesn't trust and that
* doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the
* user must confirm. Thrown by [PinningTrustManager] during the handshake;
* the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired].
*/
class TlsFingerprintRequired(
val fingerprint: String,
) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)")
/**
* SHA-256 of the certificate's DER encoding, colon-separated byte pairs
* (SSH host-key style) — the string the user verifies on the gateway host.
*/
fun certFingerprint(cert: X509Certificate): String =
MessageDigest
.getInstance("SHA-256")
.digest(cert.encoded)
.joinToString(":") { String.format("%02X", it) }
/**
* Wraps the platform default trust manager:
* - CA-signed certs behave exactly as before (the default manager decides).
* - A cert the default manager REJECTS is accepted only when its fingerprint
* equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh
* pin is picked up without rebuilding the client).
* - Anything else fails with [TlsFingerprintRequired] carrying the
* presented fingerprint, so the UI can offer the confirm dialog.
*/
class PinningTrustManager(
private val pinnedFingerprint: () -> String,
) : X509TrustManager {
private val default: X509TrustManager = defaultTrustManager()
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
default.checkClientTrusted(chain, authType)
}
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
try {
default.checkServerTrusted(chain, authType)
} catch (e: CertificateException) {
val fp = certFingerprint(chain.first())
if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return
throw TlsFingerprintRequired(fp)
}
}
}
/** The platform default server trust manager (the JDK's CA store). */
fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager)
?: error("no X509TrustManager in the default trust store")
}
/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */
fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory {
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf<TrustManager>(tm), SecureRandom())
return ctx.socketFactory
}
/**
* Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport
* exception: the trust manager throws it during the handshake and the
* JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when
* the failure has nothing to do with an untrusted gateway certificate.
*/
fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? {
var t: Throwable? = e
var depth = 0
while (t != null && depth < 10) {
if (t is TlsFingerprintRequired) return t
t = t.cause
depth++
}
return null
}
@@ -35,49 +35,21 @@ expect fun mediaCacheBaseDir(): String
expect suspend fun loadMediaImage(path: String): ImageBitmap? expect suspend fun loadMediaImage(path: String): ImageBitmap?
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */ /** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
expect suspend fun loadScaledImage( expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
path: String,
maxSize: Int,
): ImageBitmap?
/**
* Decode an image from raw bytes (bundled backdrops), scaled to at most
* [maxSize] px on the longest edge. Null on failure.
*/
expect suspend fun decodeImageBytes(
bytes: ByteArray,
maxSize: Int,
): ImageBitmap?
/**
* Read a bundled backdrop image (shipped in the app) by its [id] (the file
* name without extension, e.g. `pexels-yunszyveli-12368637`). Android reads it
* from `assets/backdrops/`, desktop from the classpath. Null on failure.
*/
expect fun readBackdropBytes(id: String): ByteArray?
/** /**
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on * Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
* the longest edge. Null on failure. Used for channel icons (stored on the * the longest edge. Null on failure. Used for channel icons (stored on the
* gateway, synced across devices). * gateway, synced across devices).
*/ */
expect suspend fun encodeImageAsBase64( expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String?
path: String,
maxSize: Int,
): String?
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */ /** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
expect suspend fun decodeBase64Image(base64: String): ImageBitmap? expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */ /** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable @Composable
expect fun MediaPlayerView( expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
media: MediaItem,
modifier: Modifier,
)
/** Open a document with the system handler (best effort). */ /** Open a document with the system handler (best effort). */
expect fun openDocument( expect fun openDocument(path: String, mime: String)
path: String,
mime: String,
)
@@ -6,7 +6,7 @@ package iris.platform
* The controller (commonMain) needs to know whether the app is in the * The controller (commonMain) needs to know whether the app is in the
* foreground (to decide between an in-app banner and a system notification) * foreground (to decide between an in-app banner and a system notification)
* and to post a system notification when a `notification` frame arrives while * and to post a system notification when a `notification` frame arrives while
* the app is backgrounded but the 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). */ /** 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. * FCM / ntfy services can reach it). No-op on desktop.
*/ */
expect fun setActiveController(controller: Any?) expect fun setActiveController(controller: Any?)
/**
* Align the ntfy listener with the paired gateway's push backend (Android:
* start/stop the listener foreground service — and with it the permanent
* "Listening for messages" notification; no-op on desktop). The listener
* only runs when the gateway actually pushes via ntfy; FCM gateways need
* no persistent listener.
*/
expect fun syncNtfyListener(backend: String)
@@ -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,28 +12,30 @@ import kotlinx.serialization.json.put
/** /**
* Wire protocol frames (mirror of gateway-plugin/protocol.py). * Wire protocol frames (mirror of gateway-plugin/protocol.py).
* See docs/04-wire-protocol.md. Defines all frame types and payloads: * See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
* hello/hello.ack, message (send + streaming), tool cards, commentary, * error, ping/pong, typing. M2: message.start/update/stop, tool.start/
* reasoning, channels, media, notifications, sync, and error frames. * progress/end, commentary, reasoning (on message / message.stop).
*/ */
const val PROTOCOL_VERSION = 1 const val PROTOCOL_VERSION = 1
object IrisJson { object IrisJson {
val instance: Json = val instance: Json = Json {
Json { ignoreUnknownKeys = true
ignoreUnknownKeys = true encodeDefaults = true
encodeDefaults = true isLenient = true
isLenient = true }
}
} }
// ── Frame type constants ──────────────────────────────────────────────── // ── Frame type constants ────────────────────────────────────────────────
const val TYPE_HELLO = "hello"
const val TYPE_HELLO_ACK = "hello.ack" const val TYPE_HELLO_ACK = "hello.ack"
const val TYPE_MESSAGE = "message" const val TYPE_MESSAGE = "message"
const val TYPE_MESSAGE_SEND = "message.send" const val TYPE_MESSAGE_SEND = "message.send"
const val TYPE_ERROR = "error" const val TYPE_ERROR = "error"
const val TYPE_PING = "ping"
const val TYPE_PONG = "pong"
const val TYPE_TYPING = "typing" const val TYPE_TYPING = "typing"
// M2 — streaming / tools / commentary // M2 — streaming / tools / commentary
@@ -47,16 +49,15 @@ const val TYPE_MESSAGE_DELETED = "message.deleted"
const val TYPE_TOOL_START = "tool.start" const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress" const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end" const val TYPE_TOOL_END = "tool.end"
// Agent todo list (live planning state; ephemeral, never outboxed — a
// reconnecting device re-learns it from the snapshot after hello.ack).
const val TYPE_TODO_UPDATE = "todo.update"
const val TYPE_COMMENTARY = "commentary" const val TYPE_COMMENTARY = "commentary"
// M4 — media (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_UPLOAD_ACK = "media.upload.ack"
const val TYPE_MEDIA_OFFER = "media.offer" const val TYPE_MEDIA_OFFER = "media.offer"
const val TYPE_MEDIA_PULL = "media.pull"
const val TYPE_MEDIA_PULL_END = "media.pull.end"
// M5 — push / notifications / read receipt / gateway status // M5 — push / notifications / read receipt / gateway status
const val TYPE_NOTIFICATION = "notification" const val TYPE_NOTIFICATION = "notification"
@@ -70,7 +71,6 @@ const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default" const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_FAVORITE = "channel.favorite" const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
const val TYPE_CHANNEL_ICON = "channel.icon" const val TYPE_CHANNEL_ICON = "channel.icon"
const val TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
const val TYPE_CHANNEL_DELETE = "channel.delete" const val TYPE_CHANNEL_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created" const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed" const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -78,22 +78,24 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted"
const val TYPE_CHANNEL_LIST = "channel.list" const val TYPE_CHANNEL_LIST = "channel.list"
const val TYPE_SEARCH = "search" const val TYPE_SEARCH = "search"
const val TYPE_SEARCH_RESULTS = "search.results" const val TYPE_SEARCH_RESULTS = "search.results"
// Slash-command catalog (the composer's "/" drawer)
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
// Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
const val TYPE_PICKER_CHOICE = "picker.choice"
const val TYPE_PICKER_SELECT = "picker.select"
const val TYPE_SYNC = "sync" const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done" const val TYPE_SYNC_DONE = "sync.done"
const val TYPE_HISTORY = "history" const val TYPE_HISTORY = "history"
// ── Error codes ─────────────────────────────────────────────────────────
const val ERR_AUTH = "auth"
const val ERR_NOT_FOUND = "not_found"
const val ERR_UNSUPPORTED = "unsupported"
const val ERR_INTERNAL = "internal"
const val ERR_MEDIA_TOO_LARGE = "media_too_large"
// M4 — media kinds (docs/07 §7.1) // M4 — media kinds (docs/07 §7.1)
const val KIND_IMAGE = "image" const val KIND_IMAGE = "image"
const val KIND_AUDIO = "audio" const val KIND_AUDIO = "audio"
const val KIND_VIDEO = "video" const val KIND_VIDEO = "video"
const val KIND_DOCUMENT = "document" const val KIND_DOCUMENT = "document"
const val KIND_VOICE = "voice"
// ── Roles ─────────────────────────────────────────────────────────────── // ── Roles ───────────────────────────────────────────────────────────────
@@ -109,11 +111,6 @@ data class Frame(
val type: String, val type: String,
@SerialName("chat_id") val chatId: String? = null, @SerialName("chat_id") val chatId: String? = null,
@SerialName("thread_id") val threadId: String? = null, @SerialName("thread_id") val threadId: String? = null,
/** Outbox cursor this frame was parked under. Set only on frames
* replayed by `sync` (live frames carry none) — the app skips
* re-notifying replayed frames with `cursor <= lastPushedCursor`
* (they already woke the device via push, docs/08 §8.7). */
val cursor: Long? = null,
val payload: JsonElement = JsonObject(emptyMap()), val payload: JsonElement = JsonObject(emptyMap()),
) { ) {
/** Payload as a JSON object (the wire format); parse per-type with /** Payload as a JSON object (the wire format); parse per-type with
@@ -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) ─────────────────────────────────────────── // ── hello.ack (server -> app) ───────────────────────────────────────────
@Serializable @Serializable
@@ -135,13 +144,9 @@ data class ServerCaps(
val tools: Boolean = false, val tools: Boolean = false,
val media: Boolean = false, val media: Boolean = false,
val search: Boolean = false, val search: Boolean = false,
/** The gateway answers `commands.catalog` (the composer's "/" drawer). */
@SerialName("commands_catalog") val commandsCatalog: Boolean = false,
val push: String = "fcm", val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "", @SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false, val pickers: Boolean = false,
/** Release version of the gateway plugin (repo-root VERSION file). */
@SerialName("app_version") val appVersion: String = "",
) )
@Serializable @Serializable
@@ -152,9 +157,6 @@ data class ChannelInfo(
@SerialName("is_default") val isDefault: Boolean = false, @SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null, @SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false, val archived: Boolean = false,
/** Unix timestamp (seconds) when the channel/thread was created; 0.0 if
* the gateway predates the field. Used to order threads newest-first. */
val created: Double = 0.0,
/** Gateway minted this thread for an incoming message (auto-threading); /** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */ * the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false, val auto: Boolean = false,
@@ -166,10 +168,6 @@ data class ChannelInfo(
/** Cosmetic avatar color override ("#RRGGBB"), or null for the /** Cosmetic avatar color override ("#RRGGBB"), or null for the
* auto-generated name-hash color. */ * auto-generated name-hash color. */
val color: String? = null, val color: String? = null,
/** Automation channel: read-only for the user. It only receives
* gateway-originated output (cron jobs, webhooks); the app hides the
* composer and the gateway rejects direct sends into it. */
val automation: Boolean = false,
) )
@Serializable @Serializable
@@ -177,35 +175,10 @@ data class HelloAckPayload(
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(), @SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
@SerialName("sync_cursor") val syncCursor: Long = 0, @SerialName("sync_cursor") val syncCursor: Long = 0,
val channels: List<ChannelInfo> = emptyList(), val channels: List<ChannelInfo> = emptyList(),
/** M5: highest outbox cursor already delivered to THIS device via the
* push backend (0 = never). Sync-replayed frames at/below it must not
* re-post system notifications (dedupe, docs/08 §8.7). */
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
/** Per-device token minted at pairing (docs/09 §9.3). The app stores it
* and presents it INSTEAD of the shared IRIS_TOKEN from then on; the
* gateway can revoke it per device. Empty when the gateway didn't
* issue one (legacy). */
@SerialName("device_token") val deviceToken: String = "",
) )
// ── message (server -> app) ───────────────────────────────────────────── // ── message (server -> app) ─────────────────────────────────────────────
/**
* Structured runtime metadata for the app's footer (mirror of the gateway's
* `runtime` object). The gateway ALWAYS sends this on final assistant
* messages; whether/what is shown is a per-app setting (Settings → Runtime
* footer), NOT a hermes config. Fields are absent when the data is
* unavailable (local models have no cost, etc.).
*/
@Serializable
data class RuntimeMeta(
val model: String? = null,
@SerialName("context_pct") val contextPct: Int? = null,
val cwd: String? = null,
val latency: Double? = null,
val cost: Double? = null,
)
@Serializable @Serializable
data class MessagePayload( data class MessagePayload(
@SerialName("message_id") val messageId: String, @SerialName("message_id") val messageId: String,
@@ -215,7 +188,6 @@ data class MessagePayload(
@SerialName("reply_to") val replyTo: String? = null, @SerialName("reply_to") val replyTo: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
val media: List<MediaRef> = emptyList(), val media: List<MediaRef> = emptyList(),
) )
@@ -232,6 +204,21 @@ data class MediaRef(
val filename: String, val filename: String,
) )
@Serializable
data class MediaUploadStartPayload(
@SerialName("media_ref") val mediaRef: String,
val kind: String,
val mime: String,
val filename: String,
val size: Long,
)
@Serializable
data class MediaUploadEndPayload(
@SerialName("media_ref") val mediaRef: String,
@SerialName("sha256") val sha256: String,
)
@Serializable @Serializable
data class MediaUploadAckPayload( data class MediaUploadAckPayload(
val ok: Boolean, val ok: Boolean,
@@ -248,6 +235,14 @@ data class MediaOfferPayload(
@SerialName("message_id") val messageId: String? = null, @SerialName("message_id") val messageId: String? = null,
) )
@Serializable
data class MediaPullPayload(
@SerialName("media_id") val mediaId: String,
)
@Serializable
data class MediaPullEndPayload(val ok: Boolean)
// ── M2: streaming frames (server -> app) ──────────────────────────────── // ── M2: streaming frames (server -> app) ────────────────────────────────
@Serializable @Serializable
@@ -269,7 +264,6 @@ data class MessageStopPayload(
val reasoning: String? = null, val reasoning: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
) )
@@ -281,9 +275,6 @@ data class ToolStartPayload(
val name: String, val name: String,
val preview: String? = null, val preview: String? = null,
val args: JsonElement? = null, val args: JsonElement? = null,
/** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji);
* absent for unknown tools — the UI falls back to its default. */
val emoji: String? = null,
) )
@Serializable @Serializable
@@ -302,30 +293,6 @@ data class ToolEndPayload(
@SerialName("output_preview") val outputPreview: String? = null, @SerialName("output_preview") val outputPreview: String? = null,
) )
// ── todo.update (server -> app): the agent's live todo list ────────────────
/** One item of the agent's todo list (hermes `todo` tool). [status] is one
* of [TODO_PENDING], [TODO_IN_PROGRESS], [TODO_COMPLETED], [TODO_CANCELLED]. */
@Serializable
data class TodoItem(
val id: String,
val content: String,
val status: String,
)
const val TODO_PENDING = "pending"
const val TODO_IN_PROGRESS = "in_progress"
const val TODO_COMPLETED = "completed"
const val TODO_CANCELLED = "cancelled"
/** The agent's FULL current todo list for a lane (last-write-wins; the
* gateway emits it whenever the `todo` tool completes — the tool result is
* authoritative even for merge writes — and as a snapshot on connect). */
@Serializable
data class TodoUpdatePayload(
val todos: List<TodoItem> = emptyList(),
)
// ── M2: commentary frame (server -> app) ──────────────────────────────── // ── M2: commentary frame (server -> app) ────────────────────────────────
@Serializable @Serializable
@@ -346,18 +313,16 @@ data class MessageSendPayload(
@SerialName("auto_thread") val autoThread: Boolean = false, @SerialName("auto_thread") val autoThread: Boolean = false,
) )
// ── typing / error ────────────────────────────────────────────────────── // ── typing / error / ping ───────────────────────────────────────────────
@Serializable @Serializable
data class TypingPayload( data class TypingPayload(val on: Boolean)
val on: Boolean,
)
@Serializable @Serializable
data class ErrorPayload( data class ErrorPayload(val code: String, val message: String)
val code: String,
val message: String, @Serializable
) data class PingPayload(val ts: Long? = null)
// ── M3: channel directory (app -> server requests) ────────────────────── // ── M3: channel directory (app -> server requests) ──────────────────────
@@ -369,19 +334,10 @@ data class ChannelCreatePayload(
) )
@Serializable @Serializable
data class ChannelRenamePayload( data class ChannelRenamePayload(val name: String)
val name: String,
)
@Serializable @Serializable
data class ChannelFavoritePayload( data class ChannelFavoritePayload(val on: Boolean)
val on: Boolean,
)
@Serializable
data class ChannelSetAutomationPayload(
val on: Boolean,
)
@Serializable @Serializable
data class ChannelIconPayload( data class ChannelIconPayload(
@@ -392,14 +348,10 @@ data class ChannelIconPayload(
) )
@Serializable @Serializable
data class ChannelListPayload( data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
val channels: List<ChannelInfo> = emptyList(),
)
@Serializable @Serializable
data class ChannelDeletedPayload( data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String)
@SerialName("chat_id") val chatId: String,
)
// ── M3: search ────────────────────────────────────────────────────────── // ── M3: search ──────────────────────────────────────────────────────────
@@ -429,56 +381,13 @@ data class SearchResultsPayload(
val hits: List<SearchHit> = emptyList(), val hits: List<SearchHit> = emptyList(),
) )
// ── commands.catalog (slash-command catalog for the "/" drawer) ──────────
/** One slash command from the gateway catalog. [name]/[aliases] carry the
* leading slash ("/new", "/reset"); the app fuzzy-matches client-side. */
@Serializable
data class SlashCommand(
val name: String,
val description: String = "",
@SerialName("args_hint") val argsHint: String = "",
val category: String = "",
val aliases: List<String> = emptyList(),
)
@Serializable
data class CommandsCatalogPayload(
val commands: List<SlashCommand> = emptyList(),
)
// ── picker.choice / picker.select (interactive slash-command menus) ────────
/** One option in a choice picker. [isCurrent] marks the active value (the
* UI shows a ✓ on it, like Telegram's inline keyboards). */
@Serializable
data class PickerChoice(
val value: String,
val label: String = value,
@SerialName("is_current") val isCurrent: Boolean = false,
)
/** An interactive choice picker (one tap → one value), sent by finite-choice
* slash commands (/reasoning, /fast, …). The app renders it as a card with
* buttons and answers via [pickerSelectFrame]. */
@Serializable
data class PickerChoicePayload(
@SerialName("picker_id") val pickerId: String,
val title: String,
val choices: List<PickerChoice> = emptyList(),
)
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────── // ── M3: sync (reconnect catch-up) ───────────────────────────────────────
@Serializable @Serializable
data class SyncPayload( data class SyncPayload(val cursor: Long)
val cursor: Long,
)
@Serializable @Serializable
data class SyncDonePayload( data class SyncDonePayload(val cursor: Long)
val cursor: Long,
)
// ── history (full message history for a chat/thread) ──────────────────── // ── history (full message history for a chat/thread) ────────────────────
@@ -491,12 +400,8 @@ data class HistoryMessage(
val reasoning: String? = null, val reasoning: String? = null,
val model: String? = null, val model: String? = null,
val tokens: Int? = null, val tokens: Int? = null,
val runtime: RuntimeMeta? = null,
val ts: Long? = null, val ts: Long? = null,
// Nullable: the schema says array, but older gateways sent val media: List<MediaRef> = emptyList(),
// `"media": null` for streaming finals — a null there used to break
// deserialization of the whole history page (silently dropping it).
val media: List<MediaRef>? = null,
) )
@Serializable @Serializable
@@ -517,9 +422,12 @@ data class MessageDeletedPayload(
// ── M5: push / notifications ──────────────────────────────────────────── // ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */ /** Notification kinds (mirror of protocol.NOTIF_*). */
const val NOTIF_MESSAGE = "message"
const val NOTIF_APPROVAL = "approval" const val NOTIF_APPROVAL = "approval"
const val NOTIF_CLARIFY = "clarify" const val NOTIF_CLARIFY = "clarify"
const val NOTIF_CRON = "cron" const val NOTIF_CRON = "cron"
const val NOTIF_CHANNEL = "channel"
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */ /** Kinds that stay on screen until dismissed (docs/08 §8.3). */
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON) val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
@@ -548,12 +456,31 @@ data class ReadReceiptPayload(
) )
@Serializable @Serializable
data class StatusPayload( data class StatusPayload(val state: String)
val state: String,
)
// ── Frame builders ────────────────────────────────────────────────────── // ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(
token: String,
deviceId: String,
deviceName: String,
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame(
type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(
token = token,
deviceId = deviceId,
deviceName = deviceName,
fcmToken = fcmToken,
ntfyTopic = ntfyTopic,
),
),
)
fun messageSendFrame( fun messageSendFrame(
id: Int, id: Int,
chatId: String, chatId: String,
@@ -567,107 +494,68 @@ fun messageSendFrame(
type = TYPE_MESSAGE_SEND, type = TYPE_MESSAGE_SEND,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( MessageSendPayload.serializer(),
MessageSendPayload.serializer(), MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread), ),
),
) )
fun pingFrame(): Frame =
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── M3 frame builders ─────────────────────────────────────────────────── // ── M3 frame builders ───────────────────────────────────────────────────
fun channelCreateFrame( fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame =
id: Int,
name: String,
kind: String = "channel",
parentChatId: String? = null,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_CREATE, type = TYPE_CHANNEL_CREATE,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( ChannelCreatePayload.serializer(),
ChannelCreatePayload.serializer(), ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId), ),
),
) )
fun channelRenameFrame( fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
id: Int,
chatId: String,
name: String,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_RENAME, type = TYPE_CHANNEL_RENAME,
chatId = chatId, chatId = chatId,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( ChannelRenamePayload.serializer(),
ChannelRenamePayload.serializer(), ChannelRenamePayload(name = name),
ChannelRenamePayload(name = name), ),
),
) )
fun channelSetDefaultFrame( fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
id: Int, Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
chatId: String,
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelFavoriteFrame( fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_FAVORITE, type = TYPE_CHANNEL_FAVORITE,
chatId = chatId, chatId = chatId,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( ChannelFavoritePayload.serializer(),
ChannelFavoritePayload.serializer(), ChannelFavoritePayload(on = on),
ChannelFavoritePayload(on = on), ),
),
) )
fun channelSetAutomationFrame( fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
id: Int,
chatId: String,
on: Boolean,
): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_SET_AUTOMATION,
chatId = chatId,
payload =
IrisJson.instance.encodeToJsonElement(
ChannelSetAutomationPayload.serializer(),
ChannelSetAutomationPayload(on = on),
),
)
fun channelIconFrame(
id: Int,
chatId: String,
icon: String?,
color: String?,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_CHANNEL_ICON, type = TYPE_CHANNEL_ICON,
chatId = chatId, chatId = chatId,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( ChannelIconPayload.serializer(),
ChannelIconPayload.serializer(), ChannelIconPayload(icon = icon, color = color),
ChannelIconPayload(icon = icon, color = color), ),
),
) )
fun channelDeleteFrame( fun channelDeleteFrame(id: Int, chatId: String): Frame =
id: Int, Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
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( fun searchFrame(
id: Int, id: Int,
@@ -681,47 +569,20 @@ fun searchFrame(
type = TYPE_SEARCH, type = TYPE_SEARCH,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( SearchPayload.serializer(),
SearchPayload.serializer(), SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId), ),
),
) )
/** Request the gateway's slash-command catalog (the composer's "/" drawer). fun syncFrame(id: Int, cursor: Long): Frame =
* Answered by a `commands.catalog` frame carrying the same id. */
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
/** Answer an interactive picker (picker.choice) with the chosen value.
* The server runs the command's selection callback and delivers its reply
* as a normal message in the picker's chat. */
fun pickerSelectFrame(
id: Int,
pickerId: String,
value: String,
): Frame =
Frame(
id = id,
type = TYPE_PICKER_SELECT,
payload =
buildJsonObject {
put("picker_id", pickerId)
put("value", value)
},
)
fun syncFrame(
id: Int,
cursor: Long,
): Frame =
Frame( Frame(
id = id, id = id,
type = TYPE_SYNC, type = TYPE_SYNC,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( SyncPayload.serializer(),
SyncPayload.serializer(), SyncPayload(cursor = cursor),
SyncPayload(cursor = cursor), ),
),
) )
/** Request a page of full message history for a chat/thread (initial open / /** Request a page of full message history for a chat/thread (initial open /
@@ -739,11 +600,10 @@ fun historyFrame(
type = TYPE_HISTORY, type = TYPE_HISTORY,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = payload = buildJsonObject {
buildJsonObject { if (beforeMessageId != null) put("before_message_id", beforeMessageId)
if (beforeMessageId != null) put("before_message_id", beforeMessageId) put("limit", limit)
put("limit", limit) },
},
) )
/** Request deletion of the given message(s) in a chat/thread. The server /** Request deletion of the given message(s) in a chat/thread. The server
@@ -759,23 +619,57 @@ fun messageDeleteFrame(
type = TYPE_MESSAGE_DELETE, type = TYPE_MESSAGE_DELETE,
chatId = chatId, chatId = chatId,
threadId = threadId, threadId = threadId,
payload = payload = buildJsonObject {
buildJsonObject { put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) })) },
}, )
// ── M4 frame builders ────────────────────────────────────────────────────
fun mediaUploadStartFrame(
id: Int,
mediaRef: String,
kind: String,
mime: String,
filename: String,
size: Long,
): Frame =
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_START,
payload = IrisJson.instance.encodeToJsonElement(
MediaUploadStartPayload.serializer(),
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
),
)
fun mediaUploadEndFrame(id: Int, mediaRef: String, sha256: String): Frame =
Frame(
id = id,
type = TYPE_MEDIA_UPLOAD_END,
payload = IrisJson.instance.encodeToJsonElement(
MediaUploadEndPayload.serializer(),
MediaUploadEndPayload(mediaRef, sha256),
),
)
fun mediaPullFrame(id: Int, mediaId: String): Frame =
Frame(
id = id,
type = TYPE_MEDIA_PULL,
payload = IrisJson.instance.encodeToJsonElement(
MediaPullPayload.serializer(),
MediaPullPayload(mediaId),
),
) )
// ── M5 frame builders ─────────────────────────────────────────────────── // ── M5 frame builders ───────────────────────────────────────────────────
fun fcmRegisterFrame( fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
fcmToken: String? = null,
ntfyTopic: String? = null,
): Frame =
Frame( Frame(
type = TYPE_FCM_REGISTER, type = TYPE_FCM_REGISTER,
payload = payload = IrisJson.instance.encodeToJsonElement(
IrisJson.instance.encodeToJsonElement( FcmRegisterPayload.serializer(),
FcmRegisterPayload.serializer(), FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic), ),
),
) )
File diff suppressed because it is too large. Load diff
@@ -9,21 +9,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -41,28 +37,15 @@ import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.StreamingMarkdownState
import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import iris.util.appendChunk
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import iris.util.preserveNewlinesAsHardBreaksStreaming
import iris.util.revealStep
import iris.util.streamCharsPerSecond
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
/** Reveal tick for the streaming typewriter effect (docs/05 §5.1). */
private const val STREAM_REVEAL_TICK_MS = 50L
/** /**
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM * Renders a chat message as markdown (M8): bold / italic / underscore, GFM
@@ -70,12 +53,6 @@ private const val STREAM_REVEAL_TICK_MS = 50L
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the * syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the * raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in. * rendered text blends in.
*
* While [isStreaming], the text is revealed at a steady rate (controlled by
* [streamSmoothness], Settings → Streaming) and parsed INCREMENTALLY: settled
* blocks are parsed once and never re-laid out, only the tail re-renders per
* tick — so the bubble stays readable instead of reflowing (and flashing raw
* markdown) on every gateway update.
*/ */
@Composable @Composable
fun MarkdownText( fun MarkdownText(
@@ -87,32 +64,13 @@ fun MarkdownText(
// plain highlighted code — the artifact card (and its runnable preview) // plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete. // only appears once the message is complete.
isStreaming: Boolean = false, isStreaming: Boolean = false,
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
) { ) {
// Static path: transform once per text change (leading blanks stripped, val state = rememberMarkdownState(text)
// single newlines → hard breaks).
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
// Whether this bubble has ever been streamed. Static history messages
// skip the reveal pipeline entirely.
val hasStreamed = remember { mutableStateOf(isStreaming) }
if (isStreaming) hasStreamed.value = true
val pipeline =
rememberStreamingMarkdownPipeline(
text = text,
smoothness = streamSmoothness,
active = hasStreamed.value,
streaming = isStreaming,
)
// Keep the streaming renderer until the reveal catches up, even after
// message.stop — a fast model that dumps the whole text at once still
// plays out the typewriter instead of jumping to the full message.
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
// The app is always dark-themed, so force the dark highlight palette // The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop). // (isSystemInDarkTheme() is unreliable on desktop).
val highlights = val highlights = remember {
remember { Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true)) }
}
val base = TextStyle(color = color, fontSize = fontSize) val base = TextStyle(color = color, fontSize = fontSize)
val inlineCodeBackground = Color.White.copy(alpha = 0.08f) val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
// Inline-code span style (mirrors the library's codeSpanStyle): the // Inline-code span style (mirrors the library's codeSpanStyle): the
@@ -122,8 +80,7 @@ fun MarkdownText(
// Brief green flash shown on the chip right after a copy, so the tap is // Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent). // visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle = val copiedCodeSpanStyle =
base base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.toSpanStyle() .toSpanStyle()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -135,74 +92,57 @@ fun MarkdownText(
// Re-render it without the padding, and wrap it in a link so tapping the // Re-render it without the padding, and wrap it in a link so tapping the
// inline code copies it to the clipboard (Telegram-style). The // inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened. // LinkAnnotation carries the click listener; the URL is never opened.
val annotator = val annotator = markdownAnnotator(
markdownAnnotator( annotate = { content, child ->
annotate = { content, child -> if (child.type == MarkdownElementTypes.CODE_SPAN) {
when { val children = child.children
child.type == MarkdownElementTypes.CODE_SPAN -> { // Drop the surrounding backtick tokens (present as first/last child).
val children = child.children val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
// Drop the surrounding backtick tokens (present as first/last child). val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) } pushStyle(spanStyle)
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle withLink(
pushStyle(spanStyle) LinkAnnotation.Url(
withLink( url = "iris:copy-code",
LinkAnnotation.Url( // Keep every interaction state identical to the chip so
url = "iris:copy-code", // hover/press never restyles the inline code.
// Keep every interaction state identical to the chip so styles = TextLinkStyles(
// hover/press never restyles the inline code. style = spanStyle,
styles = focusedStyle = spanStyle,
TextLinkStyles( hoveredStyle = spanStyle,
style = spanStyle, pressedStyle = spanStyle,
focusedStyle = spanStyle, ),
hoveredStyle = spanStyle, linkInteractionListener = LinkInteractionListener {
pressedStyle = spanStyle, clipboard.setText(AnnotatedString(code))
), copiedCode.value = code
linkInteractionListener = scope.launch {
LinkInteractionListener { delay(600)
clipboard.setText(AnnotatedString(code)) copiedCode.value = null
copiedCode.value = code }
scope.launch { },
delay(600) ),
copiedCode.value = null ) {
} append(code)
},
),
) {
append(code)
}
pop()
true
}
// Streaming cursor: append ▉ to the last text leaf of the
// document so the cursor sits at the end of the visible text.
// The tail is re-annotated on every reveal tick, so the cursor
// follows the text (and vanishes once the message is final).
useStreaming &&
child.type == MarkdownTokenTypes.TEXT &&
content.length - child.endOffset <= 1 -> {
append(child.getUnescapedTextInNode(content))
append(" ▉")
true
}
else -> {
false
}
} }
}, pop()
) true
val colors = } else {
markdownColor( false
}
},
)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = markdownColor(
text = color, text = color,
codeBackground = Color.Black.copy(alpha = 0.8f), codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground, inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider, dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f), tableBackground = Color.White.copy(alpha = 0.03f),
) ),
val typography = typography = markdownTypography(
markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold), h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold), h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold), h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
@@ -217,17 +157,13 @@ fun MarkdownText(
ordered = base, ordered = base,
bullet = base, bullet = base,
list = base, list = base,
textLink = link = base.copy(textDecoration = TextDecoration.Underline),
TextLinkStyles(
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
),
table = base.copy(fontSize = fontSize * 0.95f), table = base.copy(fontSize = fontSize * 0.95f),
) ),
val components = components = markdownComponents(
markdownComponents(
codeFence = { model -> codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style -> MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
if (!useStreaming && isHtmlArtifact(language, code)) { if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -236,7 +172,7 @@ fun MarkdownText(
}, },
codeBlock = { model -> codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style -> MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
if (!useStreaming && isHtmlArtifact(language, code)) { if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -246,129 +182,17 @@ fun MarkdownText(
// The core default checkbox renders literal "[x]"/"[ ]" text; use the // The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead. // Material 3 checkbox instead.
checkbox = { checkbox = {
com.mikepenz.markdown.m3.elements com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
.MarkdownCheckBox(it.content, it.node, it.typography.text)
}, },
) ),
if (useStreaming) { loading = { m ->
if (pipeline.displayed.isEmpty()) { // While (re)parsing — e.g. on each streaming update — show the raw
// No text revealed yet (message.start, first tick pending): show // text so the bubble never goes blank between updates.
// just the cursor so the bubble is visible immediately. Text(text, modifier = m, color = color, fontSize = fontSize)
Text("▉", modifier = modifier, color = color, fontSize = fontSize) },
} else { )
Markdown(
streamingMarkdownState = pipeline.state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
)
}
} else {
// retainState: keep the last formatted output visible while the new
// text re-parses (async) — no raw-markdown flash between updates.
val state = rememberMarkdownState(displayText, retainState = true)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
loading = { m ->
// First parse of a (long) message: show the text so the bubble
// never goes blank.
Text(displayText, modifier = m, color = color, fontSize = fontSize)
},
)
}
} }
/**
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
* snapshot from the gateway; it is revealed at a steady rate controlled by
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
* keep their AST identity, so Compose never re-lays them out — only the
* unstable tail re-renders per tick.
*
* [active] is false for history messages (never streamed): the reveal is
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
* indicates the message is still receiving updates; once the reveal catches
* up and the message is final, the reveal loop stops.
*
* Returns the parser state, the currently revealed text (read inside the
* composition so reveal ticks recompose the caller), and whether the reveal
* has caught up with the target.
*/
@Composable
private fun rememberStreamingMarkdownPipeline(
text: String,
smoothness: Float,
active: Boolean,
streaming: Boolean,
): StreamingPipeline {
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
// a prefix of the revealed text always maps to a prefix of the target, so
// the diff between consecutive reveals is a pure append.
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
val displayed = remember { mutableStateOf("") }
val latestTarget = rememberUpdatedState(target)
val latestSmoothness = rememberUpdatedState(smoothness)
val latestStreaming = rememberUpdatedState(streaming)
// Bumped when the target is rewritten (not extended): the parser state is
// recreated via key() below and re-seeded with the full text.
val generation = remember { mutableIntStateOf(0) }
val state = key(generation.value) { rememberStreamingMarkdownState() }
LaunchedEffect(state, active) {
if (!active) {
// Never streamed (history message): reveal everything at once so
// the caller falls back to the static renderer immediately.
displayed.value = latestTarget.value
return@LaunchedEffect
}
var last = ""
while (true) {
delay(STREAM_REVEAL_TICK_MS)
val t = latestTarget.value
val cur = displayed.value
if (cur == t) {
// Reveal complete: keep ticking only while the message is
// still streaming (more text may arrive); otherwise stop so
// finished bubbles don't burn battery.
if (!latestStreaming.value) return@LaunchedEffect
continue
}
val step =
revealStep(
remaining = t.length - cur.length,
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
)
val next = t.take(cur.length + step)
displayed.value = next
val chunk = appendChunk(last, next)
if (chunk == null) {
// Rewritten, not extended: recreate the parser state. The
// effect restarts (keyed on [state]) and re-seeds it.
generation.value++
return@LaunchedEffect
}
if (chunk.isNotEmpty()) state.append(chunk)
last = next
}
}
return StreamingPipeline(state, displayed.value, displayed.value == target)
}
/** Result of the streaming reveal pipeline. */
private data class StreamingPipeline(
val state: StreamingMarkdownState,
val displayed: String,
val done: Boolean,
)
/** /**
* Renders a highlighted code block with a copy button in the top-right corner * Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard. * (Telegram-style). Tapping the button copies the whole block to the clipboard.
@@ -383,17 +207,16 @@ internal fun CodeBlockWithCopy(
) { ) {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
Box { Box {
MarkdownHighlightedCode(code = code, language = language, highlightsBuilder = highlights, style = style) MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style)
if (code.isNotBlank()) { if (code.isNotBlank()) {
IconButton( IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) }, onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = modifier = Modifier
Modifier .align(Alignment.TopEnd)
.align(Alignment.TopEnd) // MarkdownHighlightedCode insets its background by 8dp top;
// MarkdownHighlightedCode insets its background by 8dp top; // match that so the button sits inside the block, not above it.
// match that so the button sits inside the block, not above it. .padding(top = 8.dp, end = 4.dp)
.padding(top = 8.dp, end = 4.dp) .size(28.dp),
.size(28.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Filled.ContentCopy, imageVector = Icons.Filled.ContentCopy,
File diff suppressed because it is too large. Load diff
@@ -11,12 +11,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -26,16 +24,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import iris.net.TlsFingerprintRequired
import iris.platform.QrScanButton
import iris.platform.isDesktop
import iris.state.IrisController import iris.state.IrisController
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.util.PairLink
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -48,49 +41,18 @@ fun ConnectScreen(
prefillUrl: String = "", prefillUrl: String = "",
prefillToken: String = "", prefillToken: String = "",
initialError: String? = null, initialError: String? = null,
/** Gateway certificate fingerprint awaiting user confirmation (docs/09
* §9.4) — shown as a confirm dialog on entry. */
tlsFingerprint: String? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Default is a cleartext (non-TLS) URL because the typical gateway is on var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
// the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
var token by remember { mutableStateOf(prefillToken) } var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) } var error by remember { mutableStateOf(initialError) }
// Fingerprint the user still has to confirm (self-signed gateway cert,
// docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a
// connect attempt fails with an untrusted certificate.
var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) }
// "Test & Connect" (also the dialog's confirm action): real hello test,
// then save + (re)connect. An untrusted gateway certificate surfaces as
// the fingerprint confirm dialog instead of a plain error.
fun doConnect() {
if (busy) return
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
val ex = result.exceptionOrNull()
if (ex is TlsFingerprintRequired) {
pendingFingerprint = ex.fingerprint
} else {
error = ex?.message ?: "connection failed"
}
}
}
}
Column( Column(
modifier = modifier = Modifier
Modifier .fillMaxSize()
.fillMaxSize() .verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()) .padding(24.dp),
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
@@ -104,19 +66,17 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Column( Column(
modifier = modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .clip(RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp)) .background(IrisColors.surface)
.background(IrisColors.surface) .padding(16.dp),
.padding(16.dp),
) { ) {
OutlinedTextField( OutlinedTextField(
value = url, value = url,
onValueChange = { url = it }, onValueChange = { url = it },
label = { Text("Server URL") }, label = { Text("Server URL") },
// Example LAN URL; https:// works too for TLS gateways. placeholder = { Text("ws://192.168.1.10:8790/ws") },
placeholder = { Text("http://192.168.1.10:8791") },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -126,29 +86,26 @@ fun ConnectScreen(
value = token, value = token,
onValueChange = { token = it }, onValueChange = { token = it },
label = { Text("Pairing token") }, label = { Text("Pairing token") },
placeholder = { Text("IRIS_TOKEN (64 hex)") }, placeholder = { Text("ANDROID_TOKEN (64 hex)") },
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
if (!isDesktop) {
Spacer(modifier = Modifier.height(12.dp))
QrScanButton { raw ->
if (raw != null) {
val link = PairLink.parse(raw)
if (link != null) {
url = link.url
token = link.token
} else {
error = "Couldn't read that QR code."
}
}
}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button( Button(
onClick = { doConnect() }, onClick = {
if (busy) return@Button
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
error = result.exceptionOrNull()?.message ?: "connection failed"
}
}
},
enabled = !busy, enabled = !busy,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
@@ -167,49 +124,11 @@ fun ConnectScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( 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.", "hermes gateway setup on the gateway host.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate
// this device doesn't trust (self-signed). The user verifies the
// fingerprint on the gateway host, then confirms — the pin is stored in
// secure storage and the connect is retried, like an SSH host key.
if (pendingFingerprint != null) {
AlertDialog(
onDismissRequest = { pendingFingerprint = null },
title = { Text("Confirm gateway certificate") },
text = {
Column {
Text(
"The gateway presents a certificate this device doesn't trust. " +
"Verify the fingerprint on the gateway host, then confirm to pin it.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
pendingFingerprint!!,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
}
},
confirmButton = {
Button(
onClick = {
val fp = pendingFingerprint!!
pendingFingerprint = null
controller.confirmTlsFingerprint(fp)
doConnect()
},
) { Text("Confirm & pin") }
},
dismissButton = {
TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") }
},
)
}
} }
@@ -8,7 +8,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -44,63 +43,37 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import iris.AppVersion
import iris.net.ReleaseCheck
import iris.platform.ImageFilePicker import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
import iris.state.IrisController import iris.state.IrisController
import iris.state.ToolDetail import iris.state.ToolDetail
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* Settings screen (post-M7): app-level preferences, opened from the channel * Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time. * drawer / rail. More settings land here over time.
*/ */
/** Which color the picker dialog is editing. */ /** Which color the picker dialog is editing. */
private enum class PickerTarget { UserBubble, AgentBubble, Background } private enum class PickerTarget { UserBubble, AgentBubble, Background }
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
controller: IrisController,
onBack: () -> Unit,
) {
val threadsEnabled by controller.threadsEnabled.collectAsState() val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState() val streamingEnabled by controller.streamingEnabled.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState() val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState()
val gatewayVersion by controller.gatewayVersion.collectAsState()
val theme = LocalUserTheme.current val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
// Best-effort latest-release check (docs/04): one query per screen open,
// failures stay silent (ReleaseCheck returns null).
var latestVersion by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
latestVersion = ReleaseCheck.latestVersion()
}
Box(modifier = Modifier.fillMaxSize().background(theme.background)) { Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column( Column(
modifier = modifier = Modifier
Modifier .fillMaxSize()
.fillMaxSize() .verticalScroll(rememberScrollState())
.verticalScroll(rememberScrollState()) .padding(12.dp),
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -155,27 +128,6 @@ fun SettingsScreen(
onCheckedChange = { controller.toggleStreaming() }, onCheckedChange = { controller.toggleStreaming() },
) )
} }
if (streamingEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Streaming speed", fontSize = 12.sp, color = IrisColors.textDim)
Text(
"How fast new text appears while streaming — lower is faster",
fontSize = 11.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Fast", fontSize = 12.sp, color = IrisColors.textDim)
Slider(
value = streamSmoothness,
onValueChange = { controller.setStreamSmoothness(it) },
valueRange = STREAM_SMOOTHNESS_MIN..STREAM_SMOOTHNESS_MAX,
steps = 5, // 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.7 / 0.8
modifier = Modifier.weight(1f),
)
Text("Slow", fontSize = 12.sp, color = IrisColors.textDim)
}
}
} }
SettingsCard { SettingsCard {
Row( Row(
@@ -196,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( Text(
"Appearance", "Appearance",
@@ -299,36 +218,14 @@ fun SettingsScreen(
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background } ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
} }
} else { } else {
Spacer(modifier = Modifier.height(8.dp))
Text("Backdrops", fontSize = 12.sp, color = IrisColors.textDim)
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Backdrop.ALL.forEach { backdrop ->
BackdropThumb(
backdrop = backdrop,
selected = theme.backgroundImagePath == backdrop.path,
onClick = {
controller.setBackgroundMode(BackgroundMode.Image)
controller.setBackgroundImagePath(backdrop.path)
},
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
BackgroundPreview(theme.backgroundImagePath) BackgroundPreview(theme.backgroundImagePath)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
if (theme.backgroundImagePath.isNullOrBlank()) { if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
"No image selected" else fileNameOf(theme.backgroundImagePath!!),
} else {
Backdrop.fromPath(theme.backgroundImagePath)?.name
?: fileNameOf(theme.backgroundImagePath!!)
},
fontSize = 12.sp, fontSize = 12.sp,
color = IrisColors.textDim, color = IrisColors.textDim,
) )
@@ -362,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( Text(
"Tools", "Tools",
@@ -426,175 +283,62 @@ 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)
}
}
Text(
"About",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
VersionCard(
appVersion = AppVersion.VERSION,
gatewayVersion = gatewayVersion,
latestVersion = latestVersion,
)
}
} }
when (pickerTarget) { when (pickerTarget) {
PickerTarget.UserBubble -> { PickerTarget.UserBubble -> ColorPickerDialog(
ColorPickerDialog( title = "Your bubble",
title = "Your bubble", initialArgb = theme.userBubbleColor,
initialArgb = theme.userBubbleColor, onApply = { controller.setUserBubbleColor(it) },
onApply = { controller.setUserBubbleColor(it) }, onConfirm = {
onConfirm = { controller.setUserBubbleColor(it)
controller.setUserBubbleColor(it) pickerTarget = null
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
}
PickerTarget.AgentBubble -> {
ColorPickerDialog(
title = "Agent bubble",
initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) },
onConfirm = {
controller.setAgentBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
}
PickerTarget.Background -> {
ColorPickerDialog(
title = "Background",
initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) },
onConfirm = {
controller.setBackgroundColor(it)
pickerTarget = null
},
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")
}
}, },
onDismiss = { pickerTarget = null },
) )
PickerTarget.AgentBubble -> ColorPickerDialog(
title = "Agent bubble",
initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) },
onConfirm = {
controller.setAgentBubbleColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
PickerTarget.Background -> ColorPickerDialog(
title = "Background",
initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) },
onConfirm = {
controller.setBackgroundColor(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
null -> Unit
} }
} }
} }
/** Material-style back arrow (vector, matches the app's icon weight). */ /** Material-style back arrow (vector, matches the app's icon weight). */
@Composable @Composable
private fun BackArrow( private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
size: Dp = 22.dp,
color: Color = IrisColors.onSurface,
) {
Canvas(modifier = Modifier.size(size)) { Canvas(modifier = Modifier.size(size)) {
val s = this.size.width / 24f val s = this.size.width / 24f
val path = val path = Path().apply {
Path().apply { moveTo(20f * s, 11f * s)
moveTo(20f * s, 11f * s) lineTo(7.83f * s, 11f * s)
lineTo(7.83f * s, 11f * s) lineTo(13.42f * s, 5.41f * s)
lineTo(13.42f * s, 5.41f * s) lineTo(12f * s, 4f * s)
lineTo(12f * s, 4f * s) lineTo(4f * s, 12f * s)
lineTo(4f * s, 12f * s) lineTo(12f * s, 20f * s)
lineTo(12f * s, 20f * s) lineTo(13.41f * s, 18.59f * s)
lineTo(13.41f * s, 18.59f * s) lineTo(7.83f * s, 13f * s)
lineTo(7.83f * s, 13f * s) lineTo(20f * s, 13f * s)
lineTo(20f * s, 13f * s) close()
close()
}
drawPath(path, color)
}
}
/**
* About card (docs/04 hello.ack note): app version (repo-root VERSION baked in
* at build time), the connected gateway's version (hello.ack
* `server_caps.app_version`), a mismatch hint, and the latest Gitea release
* when the running build is older.
*/
@Composable
private fun VersionCard(
appVersion: String,
gatewayVersion: String,
latestVersion: String?,
) {
Text("📦 Version", fontSize = 14.sp)
Text(
"Iris app v$appVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion.isNotBlank()) {
Text(
"Gateway v$gatewayVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion != appVersion) {
Text(
"App and gateway versions differ — update the other side to match.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
} }
} drawPath(path, color)
latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest ->
Text(
"New version v$latest available — see the Gitea releases page.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
} }
} }
@@ -602,12 +346,11 @@ private fun VersionCard(
@Composable @Composable
private fun SettingsCard(content: @Composable () -> Unit) { private fun SettingsCard(content: @Composable () -> Unit) {
Column( Column(
modifier = modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .clip(RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp)) .background(IrisColors.panel)
.background(IrisColors.panel) .padding(12.dp),
.padding(12.dp),
) { ) {
content() content()
} }
@@ -615,18 +358,14 @@ private fun SettingsCard(content: @Composable () -> Unit) {
/** Clickable rounded chip showing a color; opens the picker on tap. */ /** Clickable rounded chip showing a color; opens the picker on tap. */
@Composable @Composable
private fun ColorSwatch( private fun ColorSwatch(color: Color, onClick: () -> Unit) {
color: Color,
onClick: () -> Unit,
) {
Box( Box(
modifier = modifier = Modifier
Modifier .size(32.dp)
.size(32.dp) .clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp)) .background(color)
.background(color) .border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp))
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)) .clickable(onClick = onClick),
.clickable(onClick = onClick),
) )
} }
@@ -635,25 +374,14 @@ private fun ColorSwatch(
private fun BackgroundPreview(path: String?) { private fun BackgroundPreview(path: String?) {
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) } var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(path) { LaunchedEffect(path) {
bmp = bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
if (path.isNullOrBlank()) {
null
} else {
val bytes = Backdrop.fromPath(path)?.let { readBackdropBytes(it.id) }
if (bytes != null) {
decodeImageBytes(bytes, 128)
} else {
loadScaledImage(path, 128)
}
}
} }
Box( Box(
modifier = modifier = Modifier
Modifier .size(44.dp)
.size(44.dp) .clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp)) .background(IrisColors.panel)
.background(IrisColors.panel) .border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val b = bmp val b = bmp
@@ -668,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). * HSV color picker dialog (hue / saturation / brightness sliders).
* [onApply] applies the color and keeps the dialog open for further tweaks; * [onApply] applies the color and keeps the dialog open for further tweaks;
@@ -729,12 +420,11 @@ private fun ColorPickerDialog(
text = { text = {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box( Box(
modifier = modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .height(48.dp)
.height(48.dp) .clip(RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp)) .background(color),
.background(color),
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f) Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
@@ -773,12 +463,11 @@ private fun argbToHsv(argb: Int): FloatArray {
val delta = max - min val delta = max - min
var hue = 0f var hue = 0f
if (delta != 0f) { if (delta != 0f) {
hue = hue = when (max) {
when (max) { r -> ((g - b) / delta) % 6f
r -> ((g - b) / delta) % 6f g -> (b - r) / delta + 2f
g -> (b - r) / delta + 2f else -> (r - g) / delta + 4f
else -> (r - g) / delta + 4f }
}
hue *= 60f hue *= 60f
if (hue < 0f) hue += 360f if (hue < 0f) hue += 360f
} }
@@ -787,15 +476,5 @@ private fun argbToHsv(argb: Int): FloatArray {
} }
/** File name from a path (no java.io.File in common code). */ /** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') } 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
}
@@ -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.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
/** /**
* Centralized palette (M7). Dark is the only theme; every screen reads colors * Centralized palette (M7). Dark is the only theme; every screen reads colors
@@ -35,6 +33,8 @@ object IrisColors {
val textDim = Color(0xFF8A93A6) val textDim = Color(0xFF8A93A6)
// Bubbles // Bubbles
val bubbleUser = primary
val bubbleAssistant = Color(0xFF2A2E3B)
val bubbleCommentary = Color(0xFF23262F) val bubbleCommentary = Color(0xFF23262F)
// Panels, chips, rows // Panels, chips, rows
@@ -45,6 +45,7 @@ object IrisColors {
val divider = Color(0xFF2A2E3B) val divider = Color(0xFF2A2E3B)
// Status // Status
val statusGrey = Color(0xFF9E9E9E)
val statusAmber = Color(0xFFFFC107) val statusAmber = Color(0xFFFFC107)
val statusGreen = Color(0xFF4CAF50) val statusGreen = Color(0xFF4CAF50)
val statusRed = Color(0xFFF44336) val statusRed = Color(0xFFF44336)
@@ -53,17 +54,16 @@ object IrisColors {
val errorText = Color(0xFFFF8A80) val errorText = Color(0xFFFF8A80)
} }
private val IrisDark = private val IrisDark = darkColorScheme(
darkColorScheme( background = IrisColors.background,
background = IrisColors.background, surface = IrisColors.surface,
surface = IrisColors.surface, onBackground = IrisColors.onBackground,
onBackground = IrisColors.onBackground, onSurface = IrisColors.onSurface,
onSurface = IrisColors.onSurface, onSurfaceVariant = IrisColors.textDim,
onSurfaceVariant = IrisColors.textDim, primary = IrisColors.primary,
primary = IrisColors.primary, onPrimary = Color.White,
onPrimary = Color.White, error = IrisColors.statusRed,
error = IrisColors.statusRed, )
)
@Composable @Composable
fun IrisTheme(content: @Composable () -> Unit) { fun IrisTheme(content: @Composable () -> Unit) {
@@ -85,11 +85,9 @@ fun avatarColor(name: String): Color {
data class UserTheme( data class UserTheme(
val userBubbleColor: Int = DEFAULT_USER_BUBBLE, val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE, val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
// Fresh installs start with the default bundled backdrop (Settings → val backgroundMode: BackgroundMode = BackgroundMode.Color,
// Appearance to change it).
val backgroundMode: BackgroundMode = BackgroundMode.Image,
val backgroundColor: Int = DEFAULT_BACKGROUND, val backgroundColor: Int = DEFAULT_BACKGROUND,
val backgroundImagePath: String? = Backdrop.DEFAULT.path, val backgroundImagePath: String? = null,
) { ) {
val userBubble: Color get() = Color(userBubbleColor) val userBubble: Color get() = Color(userBubbleColor)
val agentBubble: Color get() = Color(agentBubbleColor) 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 * 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 * 2048 px on the longest edge. Null in color mode or on decode failure — the
* from the app resources; anything else is a user-picked file path. Null in * caller then falls back to the solid background color.
* color mode or on decode failure — the caller then falls back to the solid
* background color.
*/ */
@Composable @Composable
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? { fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
@@ -127,18 +123,7 @@ fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank() val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
var image by remember { mutableStateOf<ImageBitmap?>(null) } var image by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(wantImage, path) { LaunchedEffect(wantImage, path) {
image = image = if (wantImage) loadScaledImage(path!!, 2048) else null
if (!wantImage) {
null
} else {
val backdrop = Backdrop.fromPath(path)
val bytes = backdrop?.let { readBackdropBytes(it.id) }
if (bytes != null) {
decodeImageBytes(bytes, 2048)
} else {
loadScaledImage(path!!, 2048)
}
}
} }
return image return image
} }
@@ -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,118 +14,26 @@ fun String.prepareForMarkdown(): String {
} }
/** /**
* Converts single newlines into markdown hard breaks (two trailing spaces) so * Converts the single newlines a user typed into markdown hard breaks (two
* they render as line breaks instead of being collapsed into spaces by the * trailing spaces) so they render as line breaks instead of being collapsed
* parser (GFM treats a lone newline inside a paragraph as a space). Applied to * into spaces by the parser (GFM treats a lone newline inside a paragraph as a
* both user input (lazy line breaks) and agent replies (models write status * space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the
* lines and wrapped text expecting a break per line). Lines inside fenced code * code content is not altered; blank lines are kept as-is since they already
* blocks (``` / ~~~) are left untouched so the code content is not altered; * produce paragraph breaks.
* blank lines are kept as-is since they already produce paragraph breaks.
*/ */
fun String.preserveNewlinesAsHardBreaks(): String { fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n") val lines = split("\n")
var inCodeBlock = false var inCodeBlock = false
val out = val out = lines.map { line ->
lines.map { line -> val trimmed = line.trimStart()
val trimmed = line.trimStart() when {
when { trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> { inCodeBlock = !inCodeBlock
inCodeBlock = !inCodeBlock line
line
}
inCodeBlock || line.isBlank() -> {
line
}
else -> {
line + " "
}
} }
inCodeBlock || line.isBlank() -> line
else -> line + " "
} }
}
return out.joinToString("\n") return out.joinToString("\n")
} }
/**
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
*
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
* renderer feed the diff between consecutive snapshots into an append-only
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
* invisible at the end of the document, so a finished message renders exactly
* like the static transform.
*/
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
val lines = split("\n")
var inCodeBlock = false
val out =
lines.mapIndexed { index, line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> {
line
}
index == lines.lastIndex -> {
line
}
// still being typed
else -> {
line + " "
}
}
}
return out.joinToString("\n")
}
/**
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
* content was rewritten, not appended. The gateway sends full text snapshots
* on every `message.update`; this converts them into append chunks for the
* append-only streaming parser.
*/
fun appendChunk(
last: String,
next: String,
): String? = if (next.startsWith(last)) next.substring(last.length) else null
/**
* Number of characters to reveal on one streaming tick. The reveal rate comes
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
* (fast model, reconnect catch-up) everything is revealed at once so the
* display never lags far behind the received text.
*/
fun revealStep(
remaining: Int,
charsPerSecond: Double,
tickSeconds: Double,
): Int {
if (remaining <= 0) return 0
if (remaining > charsPerSecond * 2.0) return remaining
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
}
/**
* Range of the streaming-smoothness setting (seconds between visible text
* updates while streaming; lower = faster/smoother).
*/
const val STREAM_SMOOTHNESS_MIN = 0.2f
const val STREAM_SMOOTHNESS_MAX = 0.8f
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
/**
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
*/
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
@@ -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). */ /** Current wall-clock time in epoch milliseconds (for locally generated items). */
expect fun nowMillis(): Long expect fun nowMillis(): Long
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
fun hostFromUrl(url: String): String {
val noScheme = url.trim().substringAfter("://")
return noScheme.substringBefore("/").ifBlank { url.trim() }
}
@@ -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,163 +0,0 @@
package iris.net
import com.sun.net.httpserver.HttpsConfigurator
import com.sun.net.httpserver.HttpsServer
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.net.InetSocketAddress
import java.security.KeyFactory
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
/**
* docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real
* TLS handshake: a local HTTPS server presents the embedded self-signed
* certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like
* [GatewayClient] (pinning socket factory + trust manager).
*
* 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the
* presented fingerprint from the nested exception.
* 2. After "confirming" (setting the pin): the SAME client connects — the
* pin is read live, no client rebuild.
*
* The embedded key is a throwaway test key, not a secret.
*/
class TlsPinningIntegrationTest {
private companion object {
// Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname
// verifier requires a SAN; CN-only certs are rejected even when pinned).
val CERT_PEM =
"""
-----BEGIN CERTIFICATE-----
MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z
NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF
/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C
Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT
qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs
PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a
GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd
WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm
EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3
Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE
dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw
zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0
Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU=
-----END CERTIFICATE-----
""".trimIndent()
val KEY_PEM =
"""
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G
uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc
fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H
eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7
bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC
qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7
OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5
PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd
sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r
yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g
WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN
ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE
BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f
utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem
Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK
ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu
OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H
yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w
Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6
190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF
JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/
LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M
JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI
MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z
OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO
sZ1EltfJHT57a0ub22mRtVc=
-----END PRIVATE KEY-----
""".trimIndent()
// `openssl x509 -noout -fingerprint -sha256` over CERT_PEM.
const val EXPECTED_FINGERPRINT =
"9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44"
fun pemBody(pem: String): ByteArray {
val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "")
return Base64.getDecoder().decode(base64)
}
}
@Test
fun selfSignedGatewayRequiresConfirmThenPins() {
val cert =
CertificateFactory
.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray()))
.let { it as java.security.cert.X509Certificate }
val key =
KeyFactory
.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM)))
// Local HTTPS server presenting the self-signed cert.
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
ks.load(null, null)
ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert))
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(ks, CharArray(0))
val serverCtx = SSLContext.getInstance("TLS")
serverCtx.init(kmf.keyManagers, null, null)
val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.httpsConfigurator = HttpsConfigurator(serverCtx)
server.createContext("/v1/health") { exchange ->
val body = "ok".toByteArray()
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
}
server.start()
// The client is wired exactly like GatewayClient: pinning socket
// factory + trust manager, pin read live from a mutable holder.
var pin = ""
val tm = PinningTrustManager { pin }
val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build()
val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build()
try {
// 1. Unpinned: the handshake fails; the unwrap finds the
// presented fingerprint in the nested exception chain.
val e =
assertFailsWith<Exception> {
client.newCall(request).execute().use { it.body!!.string() }
}
val tls = tlsFingerprintRequired(e)
assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e")
assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint)
// 2. User "confirms" the fingerprint: the SAME client now
// connects (the pin is read live, no client rebuild).
pin = EXPECTED_FINGERPRINT
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
assertEquals("ok", response.body!!.string())
}
} finally {
server.stop(0)
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
}
}
@@ -1,129 +0,0 @@
package iris.net
import java.io.ByteArrayInputStream
import java.io.IOException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow.
*
* The embedded certificate is a self-signed cert (CN=iris-test-gateway) the
* platform trust store does NOT contain, so it exercises the exact path a
* self-signed gateway hits: default trust manager rejects → the pinning
* manager either offers the fingerprint for confirmation or accepts the
* user-confirmed pin.
*/
class TlsPinningTest {
private companion object {
// Self-signed, 10-year validity — generated with:
// openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway"
val SELF_SIGNED_PEM =
"""
-----BEGIN CERTIFICATE-----
MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL
BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3
WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp
9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V
LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs
BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc
HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66
UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC
AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY
MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj
GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/
Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S
vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA
I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2
vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs=
-----END CERTIFICATE-----
""".trimIndent()
// `openssl x509 -noout -fingerprint -sha256` over the same cert.
const val EXPECTED_FINGERPRINT =
"C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA"
val CERT: X509Certificate by lazy {
CertificateFactory
.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray()))
.let { it as X509Certificate }
}
}
@Test
fun certFingerprintMatchesOpenSsl() {
assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT))
}
@Test
fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() {
val tm = PinningTrustManager { "" }
val e =
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
}
@Test
fun confirmedPinAcceptsTheSelfSignedCert() {
val tm = PinningTrustManager { EXPECTED_FINGERPRINT }
// Must not throw: the user confirmed exactly this certificate.
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
@Test
fun wrongPinStillFailsWithThePresentedFingerprint() {
val tm = PinningTrustManager { "DE:AD:BE:EF" }
val e =
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
}
@Test
fun pinIsReadLive() {
// The provider is a lambda: a pin saved AFTER the manager was built
// (the confirm dialog's action) takes effect without rebuilding it.
var pin = ""
val tm = PinningTrustManager { pin }
assertFailsWith<TlsFingerprintRequired> {
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
pin = EXPECTED_FINGERPRINT
tm.checkServerTrusted(arrayOf(CERT), "RSA")
}
@Test
fun unwrapFindsNestedTlsFingerprintRequired() {
val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT)
// The JSSE/OkHttp layers wrap the trust manager's exception in an
// (SSL) handshake IOException — the unwrap must find it nested.
val wrapped = IOException("PKIX path building failed", inner)
val found = tlsFingerprintRequired(wrapped)
assertNotNull(found)
assertEquals(EXPECTED_FINGERPRINT, found.fingerprint)
// Unrelated failures must not be misread as a confirm request.
assertNull(tlsFingerprintRequired(IOException("remote host closed connection")))
assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable")))
}
@Test
fun pinningSocketFactoryCreatesSockets() {
val tm = PinningTrustManager { "" }
val factory = pinningSslSocketFactory(tm)
val socket = factory.createSocket()
assertTrue(socket.javaClass.name.contains("SSL"))
socket.close()
}
}
@@ -1,52 +0,0 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
/** Wire tests for the channel directory `created` field (thread ordering). */
class ChannelCreatedWireTest {
@Test
fun channelInfoDeserializesCreated() {
val raw =
"""
{"v":1,"type":"channel.created","payload":{"chat_id":"t_9","name":"New topic",
"kind":"thread","parent_chat_id":"default","created":1787648374.37}}
""".trimIndent()
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
val info = frame.payloadAs<ChannelInfo>()
assertEquals("t_9", info?.chatId)
assertEquals(1787648374.37, info?.created)
}
@Test
fun channelInfoDefaultsCreatedToZero() {
// Legacy gateways omit the field; the app falls back to name ordering.
val raw =
"""{"v":1,"type":"channel.created","payload":{"chat_id":"t_1","name":"Old","kind":"thread"}}"""
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
assertEquals(0.0, frame.payloadAs<ChannelInfo>()?.created)
}
@Test
fun threadsSortNewestFirst() {
// Mirrors the topic-switcher ordering in ChatScreen: created desc,
// name asc as the tie-break (e.g. for legacy entries with created==0).
val threads =
listOf(
ChannelInfo(chatId = "t_2", name = "Capital of Romania", kind = "thread", parentChatId = "default", created = 1787232736.0),
ChannelInfo(chatId = "t_1", name = "Capital of France", kind = "thread", parentChatId = "default", created = 1787232221.0),
ChannelInfo(
chatId = "t_9",
name = "Test file operations",
kind = "thread",
parentChatId = "default",
created = 1787422924.0,
),
ChannelInfo(chatId = "t_0", name = "Legacy b", kind = "thread", parentChatId = "default"),
ChannelInfo(chatId = "t_0a", name = "Legacy a", kind = "thread", parentChatId = "default"),
)
val ordered =
threads.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId })
}
}
@@ -1,31 +0,0 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
/** Wire tests for the hello.ack payload (docs/04). */
class HelloAckWireTest {
@Test
fun helloAckDeserializesDeviceToken() {
val raw =
"""
{"v":1,"type":"hello.ack","payload":{"sync_cursor":5,
"last_pushed_cursor":3,"device_token":"9f2c64hex"}}
""".trimIndent()
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
assertEquals("hello.ack", frame.type)
val p = frame.payloadAs<HelloAckPayload>()
assertEquals("9f2c64hex", p?.deviceToken)
assertEquals(5L, p?.syncCursor)
assertEquals(3L, p?.lastPushedCursor)
}
@Test
fun helloAckDefaultsDeviceTokenToEmpty() {
// Legacy gateways (pre per-device tokens) omit the field entirely.
val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}"""
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
val p = frame.payloadAs<HelloAckPayload>()
assertEquals("", p?.deviceToken)
}
}
@@ -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"))
}
}
@@ -2,9 +2,9 @@ package iris.util
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MarkdownTest { class MarkdownTest {
@Test @Test
fun stripsLeadingNewlines() { fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown()) assertEquals("Hello", "\n\nHello".prepareForMarkdown())
@@ -39,112 +39,4 @@ class MarkdownTest {
fun allBlankBecomesEmpty() { fun allBlankBecomesEmpty() {
assertEquals("", "\n\n \n".prepareForMarkdown()) assertEquals("", "\n\n \n".prepareForMarkdown())
} }
@Test
fun convertsSingleNewlinesToHardBreaks() {
assertEquals("a \nb ", "a\nb".preserveNewlinesAsHardBreaks())
}
@Test
fun keepsBlankLinesAsParagraphBreaks() {
assertEquals("a \n\nb ", "a\n\nb".preserveNewlinesAsHardBreaks())
}
@Test
fun leavesFencedCodeBlocksUntouched() {
val md = "```kotlin\nval a = 1\nval b = 2\n```"
assertEquals(md, md.preserveNewlinesAsHardBreaks())
}
@Test
fun statusStyleLinesBecomeHardBreaks() {
val md = "**Title:** x\n**Model:** y"
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
}
@Test
fun streamingTransformSkipsLastLineOnly() {
// Identical to the static transform except the (still-incomplete)
// last line gets no trailing hard-break spaces.
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
val md = "```kotlin\nval a = 1\nval b = 2\n```"
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformIsPrefixPreserving() {
// The core invariant the incremental renderer relies on: for every
// prefix p of s, transform(p) is a prefix of transform(s).
val docs =
listOf(
"a\nb",
"a\n\nb\nc",
"**bold** and `code`",
"```kotlin\nval a = 1\n```\nthen text",
"~~~\nx\n~~~\ny",
"| a | b |\n| - | - |\n| 1 | 2 |",
"- item\n- item2",
"line with trailing spaces \nnext",
"",
"\n\n \n",
)
for (s in docs) {
val whole = s.preserveNewlinesAsHardBreaksStreaming()
for (i in 0..s.length) {
val p = s.take(i)
val tp = p.preserveNewlinesAsHardBreaksStreaming()
assertTrue(
whole.startsWith(tp),
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
)
}
}
}
@Test
fun appendChunkReturnsSuffixOnExtension() {
assertEquals(" world", appendChunk("Hello", "Hello world"))
assertEquals("", appendChunk("abc", "abc"))
assertEquals("abc", appendChunk("", "abc"))
}
@Test
fun appendChunkReturnsNullOnRewrite() {
assertEquals(null, appendChunk("Hello", "Hi"))
assertEquals(null, appendChunk("Hello world", "Hello"))
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
}
@Test
fun revealStepRevealsAtLeastOneChar() {
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
}
@Test
fun revealStepCatchesUpWhenBacklogIsLarge() {
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
}
@Test
fun streamCharsPerSecondMapsSmoothnessRange() {
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
// values are clamped to the ends.
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
}
} }
@@ -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 package iris.platform
import iris.state.IrisController import iris.state.IrisController
import java.util.concurrent.CopyOnWriteArrayList
/** /**
* M6: bridge between the desktop shell (tray / window) and the shared * M6: bridge between the desktop shell (tray / window) and the shared
@@ -13,35 +12,19 @@ typealias NotificationListener =
(chatId: String?, title: String, body: String, threadId: String?) -> Unit (chatId: String?, title: String, body: String, threadId: String?) -> Unit
object DesktopBridge { object DesktopBridge {
/** True while the window has focus (set by the shell). A change is
* forwarded to the controller (M8: unread clear on focus). */
@Volatile @Volatile
var foreground: Boolean = true var foreground: Boolean = true
set(value) {
if (field != value) {
field = value
controller?.setForeground(value)
}
}
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
// M-13: CopyOnWriteArrayList — listeners are added from the UI thread and private val notificationListeners = mutableListOf<NotificationListener>()
// iterated from the OkHttp callback thread; a plain mutableListOf's
// .toList() copy is not atomic with a concurrent add.
private val notificationListeners = CopyOnWriteArrayList<NotificationListener>()
fun onNotification(listener: NotificationListener) { fun onNotification(listener: NotificationListener) {
notificationListeners.add(listener) notificationListeners.add(listener)
} }
fun notifyListeners( fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) {
chatId: String?, notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
title: String,
body: String,
threadId: String?,
) {
notificationListeners.forEach { it(chatId, title, body, threadId) }
} }
} }
@@ -1,6 +1,5 @@
package iris.platform package iris.platform
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -9,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
@@ -30,18 +31,7 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import iris.data.MediaItem import iris.data.MediaItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.net.StandardProtocolFamily import java.net.StandardProtocolFamily
@@ -52,6 +42,17 @@ import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.swing.JFileChooser import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.filechooser.FileNameExtensionFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -60,18 +61,24 @@ import kotlin.random.Random
*/ */
@Composable @Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
var busy by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .padding(16.dp),
.padding(16.dp),
) { ) {
Text("Attach a file:", fontSize = 13.sp) Text("Attach a file:", fontSize = 13.sp)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// pickFile() blocks the UI thread (JFileChooser is modal), so there's Button(
// no in-flight state to show while the dialog is open. onClick = {
Button(onClick = { onPicked(pickFile()) }) { busy = true
Text("Choose file…") val picked = pickFile()
busy = false
onPicked(picked)
},
enabled = !busy,
) {
Text(if (busy) "Choosing…" else "Choose file…")
} }
} }
} }
@@ -91,10 +98,9 @@ private fun pickFile(): PickedFile? {
@Composable @Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = { Button(onClick = {
val chooser = val chooser = JFileChooser().apply {
JFileChooser().apply { fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif") }
}
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) { if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
onPicked(null) onPicked(null)
return@Button return@Button
@@ -129,8 +135,7 @@ private fun guessMime(name: String): String {
"mov" -> "video/quicktime" "mov" -> "video/quicktime"
"mkv" -> "video/x-matroska" "mkv" -> "video/x-matroska"
"mp3" -> "audio/mpeg" "mp3" -> "audio/mpeg"
"m4a" -> "audio/mp4" "m4a", "aac" -> "audio/mp4"
"aac" -> "audio/aac"
"ogg", "opus" -> "audio/ogg" "ogg", "opus" -> "audio/ogg"
"wav" -> "audio/wav" "wav" -> "audio/wav"
"flac" -> "audio/flac" "flac" -> "audio/flac"
@@ -146,110 +151,62 @@ actual fun mediaCacheBaseDir(): String {
return dir.absolutePath return dir.absolutePath
} }
actual suspend fun loadMediaImage(path: String): ImageBitmap? = actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
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) {
try {
val img = ImageIO.read(File(path)) ?: 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 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 { try {
// Top-level function: no `javaClass`, so anchor the classpath lookup to a val img = ImageIO.read(File(path)) ?: return@withContext null
// class in this module (PickedFile, same package). img.toComposeImageBitmap()
PickedFile::class.java.getResourceAsStream("/backdrops/$id.jpg")?.use { it.readBytes() } } catch (_: Exception) {
} catch (e: Exception) {
null null
} }
}
actual suspend fun encodeImageAsBase64( actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
path: String, try {
maxSize: Int, val img = ImageIO.read(File(path)) ?: return@withContext null
): String? = val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
withContext(Dispatchers.IO) { if (scale >= 1f) {
try { img.toComposeImageBitmap()
val img = ImageIO.read(File(path)) ?: return@withContext null } else {
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
val sw = (img.width * scale).toInt().coerceAtLeast(1) val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1) val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB) val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics() val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null) g.drawImage(img, 0, 0, sw, sh, null)
g.dispose() g.dispose()
val baos = ByteArrayOutputStream() scaled.toComposeImageBitmap()
ImageIO.write(scaled, "png", baos)
java.util.Base64
.getEncoder()
.encodeToString(baos.toByteArray())
} catch (_: Exception) {
null
} }
} catch (_: Exception) {
null
} }
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { try {
try { val img = ImageIO.read(File(path)) ?: return@withContext null
val bytes = val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
java.util.Base64 val sw = (img.width * scale).toInt().coerceAtLeast(1)
.getDecoder() val sh = (img.height * scale).toInt().coerceAtLeast(1)
.decode(base64) val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null val g = scaled.createGraphics()
img.toComposeImageBitmap() g.drawImage(img, 0, 0, sw, sh, null)
} catch (_: Exception) { g.dispose()
null val baos = ByteArrayOutputStream()
} ImageIO.write(scaled, "png", baos)
java.util.Base64.getEncoder().encodeToString(baos.toByteArray())
} catch (_: Exception) {
null
} }
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
try {
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). * 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). * - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
*/ */
@Composable @Composable
actual fun MediaPlayerView( actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
media: MediaItem,
modifier: Modifier,
) {
val path = media.localPath val path = media.localPath
if (path == null || !File(path).exists()) { if (path == null || !File(path).exists()) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -292,12 +246,7 @@ private object Mpv {
val available: Boolean by lazy { val available: Boolean by lazy {
try { try {
val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start() val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start()
val found = val found = p.inputStream.bufferedReader().readText().trim().isNotBlank()
p.inputStream
.bufferedReader()
.readText()
.trim()
.isNotBlank()
p.waitFor(2, TimeUnit.SECONDS) p.waitFor(2, TimeUnit.SECONDS)
found found
} catch (_: Exception) { } catch (_: Exception) {
@@ -307,10 +256,7 @@ private object Mpv {
} }
@Composable @Composable
private fun AudioPlayer( private fun AudioPlayer(media: MediaItem, path: String) {
media: MediaItem,
path: String,
) {
val session = remember(path) { MpvAudioSession(path) } val session = remember(path) { MpvAudioSession(path) }
var paused by remember { mutableStateOf(true) } var paused by remember { mutableStateOf(true) }
var position by remember { mutableStateOf(0.0) } var position by remember { mutableStateOf(0.0) }
@@ -332,12 +278,11 @@ private fun AudioPlayer(
} }
Column( Column(
modifier = modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .clip(RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(10.dp)) .background(Color(0xFF20242E))
.background(Color(0xFF20242E)) .padding(10.dp),
.padding(10.dp),
) { ) {
Text(media.filename, fontSize = 12.sp, maxLines = 1) Text(media.filename, fontSize = 12.sp, maxLines = 1)
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
@@ -364,18 +309,14 @@ private fun AudioPlayer(
} }
@Composable @Composable
private fun VideoPlayer( private fun VideoPlayer(media: MediaItem, path: String) {
media: MediaItem,
path: String,
) {
var process by remember { mutableStateOf<Process?>(null) } var process by remember { mutableStateOf<Process?>(null) }
Column( Column(
modifier = modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .clip(RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(10.dp)) .background(Color(0xFF20242E))
.background(Color(0xFF20242E)) .padding(10.dp),
.padding(10.dp),
) { ) {
Text(media.filename, fontSize = 12.sp, maxLines = 1) Text(media.filename, fontSize = 12.sp, maxLines = 1)
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
@@ -411,9 +352,7 @@ private fun fmtTime(sec: Double): String {
* An mpv process + its JSON IPC socket (unix domain). Commands carry a * An mpv process + its JSON IPC socket (unix domain). Commands carry a
* [requestId] so responses can be matched and async events skipped. * [requestId] so responses can be matched and async events skipped.
*/ */
private class MpvAudioSession( private class MpvAudioSession(private val path: String) {
private val path: String,
) {
private var process: Process? = null private var process: Process? = null
private var channel: SocketChannel? = null private var channel: SocketChannel? = null
private val socketPath = private val socketPath =
@@ -423,15 +362,10 @@ private class MpvAudioSession(
fun start() { fun start() {
try { try {
process = process = ProcessBuilder(
ProcessBuilder( "mpv", "--no-video", "--really-quiet", "--no-terminal",
"mpv", "--input-ipc-server=$socketPath", path,
"--no-video", ).redirectErrorStream(true).start()
"--really-quiet",
"--no-terminal",
"--input-ipc-server=$socketPath",
path,
).redirectErrorStream(true).start()
connectIpc() connectIpc()
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -472,24 +406,9 @@ private class MpvAudioSession(
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
answers["p1"] answers["p1"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { pos = it }
?.get("data") answers["p2"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { dur = it }
?.jsonPrimitive answers["p3"]?.get("data")?.jsonPrimitive?.content?.toBooleanStrictOrNull()?.let { pause = it }
?.content
?.toDoubleOrNull()
?.let { pos = it }
answers["p2"]
?.get("data")
?.jsonPrimitive
?.content
?.toDoubleOrNull()
?.let { dur = it }
answers["p3"]
?.get("data")
?.jsonPrimitive
?.content
?.toBooleanStrictOrNull()
?.let { pause = it }
} catch (_: Exception) { } catch (_: Exception) {
} }
return Triple(pos, dur, pause) return Triple(pos, dur, pause)
@@ -521,16 +440,12 @@ private class MpvAudioSession(
} }
} }
private fun send( private fun send(cmd: List<String>, requestId: String?) {
cmd: List<String>,
requestId: String?,
) {
val ch = channel ?: return val ch = channel ?: return
val obj = val obj = buildJsonObject {
buildJsonObject { put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } }) requestId?.let { put("request_id", JsonPrimitive(it)) }
requestId?.let { put("request_id", JsonPrimitive(it)) } }
}
val bytes = (obj.toString() + "\n").toByteArray(Charsets.UTF_8) val bytes = (obj.toString() + "\n").toByteArray(Charsets.UTF_8)
ch.write(ByteBuffer.wrap(bytes)) ch.write(ByteBuffer.wrap(bytes))
} }
@@ -556,24 +471,14 @@ private class MpvAudioSession(
} }
} }
actual fun openDocument( actual fun openDocument(path: String, mime: String) {
path: String,
mime: String,
) {
try { try {
val os = System.getProperty("os.name").lowercase() val os = System.getProperty("os.name").lowercase()
when { when {
os.contains("mac") -> { os.contains("mac") -> ProcessBuilder("open", path).start()
ProcessBuilder("open", path).start() os.contains("win") ->
}
os.contains("win") -> {
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start() ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
} else -> ProcessBuilder("xdg-open", path).start()
else -> {
ProcessBuilder("xdg-open", path).start()
}
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -19,10 +19,6 @@ actual fun setActiveController(controller: Any?) {
DesktopBridge.controller = controller as? IrisController DesktopBridge.controller = controller as? IrisController
} }
actual fun syncNtfyListener(backend: String) {
// Desktop has no ntfy listener service.
}
/** /**
* M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the * M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the
* platform notifier cover the "backgrounded" leg. Shells out to * platform notifier cover the "backgrounded" leg. Shells out to
@@ -30,57 +26,34 @@ actual fun syncNtfyListener(backend: String) {
* Best effort — failures are ignored (the in-app banner is the primary path). * Best effort — failures are ignored (the in-app banner is the primary path).
*/ */
object DesktopNotifier { object DesktopNotifier {
fun post( fun post(title: String, body: String) {
title: String,
body: String,
) {
try { try {
val os = System.getProperty("os.name").lowercase() val os = System.getProperty("os.name").lowercase()
val cmd = val cmd = when {
when { os.contains("linux") ->
os.contains("linux") -> { listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body) os.contains("mac") ->
} listOf(
"osascript", "-e",
os.contains("mac") -> { "display notification \"${esc(body)}\" with title \"${esc(title)}\"",
listOf( )
"osascript", else ->
"-e", listOf(
"display notification \"${esc(body)}\" with title \"${esc(title)}\"", "powershell", "-NoProfile", "-Command",
) "Add-Type -AssemblyName System.Windows.Forms; " +
} "\$n = New-Object System.Windows.Forms.NotifyIcon; " +
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
else -> { "\$n.Visible = \$true; " +
listOf( "\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
"powershell", "[System.Windows.Forms.ToolTipIcon]::Info); " +
"-NoProfile", "Start-Sleep -Milliseconds 4500; \$n.Dispose()",
"-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)}\", " +
"[System.Windows.Forms.ToolTipIcon]::Info); " +
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
)
}
}
ProcessBuilder(cmd).redirectErrorStream(true).start() ProcessBuilder(cmd).redirectErrorStream(true).start()
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ") 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", " ")
} }
@@ -2,17 +2,14 @@ package iris.platform
import iris.data.SecureStore import iris.data.SecureStore
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import kotlinx.serialization.Serializable
import java.io.File import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
import java.util.UUID import java.util.UUID
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.Serializable
/** /**
* M6: desktop pairing storage (docs/11 §11.2). * M6: desktop pairing storage (docs/11 §11.2).
@@ -28,30 +25,7 @@ class DesktopSecureStore : SecureStore {
private val baseDir = File(System.getProperty("user.home"), ".iris") private val baseDir = File(System.getProperty("user.home"), ".iris")
private val settingsFile = File(baseDir, "settings.json") private val settingsFile = File(baseDir, "settings.json")
private val legacyFile = File(baseDir, "pairing.json") private val legacyFile = File(baseDir, "pairing.json")
private val secret = private val secret = SecretBackend(baseDir)
SecretBackend(
baseDir,
keyringService = "iris-gateway-token",
keyringLabel = "Iris gateway token",
keyringAttr = "iris",
encFileName = "pairing.enc",
)
// Per-device token (docs/09 §9.3): a second secret slot, same backends.
private val deviceSecret =
SecretBackend(
baseDir,
keyringService = "iris-device-token",
keyringLabel = "Iris device token",
keyringAttr = "iris-device",
encFileName = "device_token.enc",
)
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
// connect attempt) don't re-read + re-parse the file on every access.
// Invalidated on every save(). DesktopSecureStore is a per-process
// singleton (created once in Main.kt), so a per-instance cache is safe.
private var cached: Settings? = null
@Serializable @Serializable
private data class Settings( private data class Settings(
@@ -61,21 +35,15 @@ class DesktopSecureStore : SecureStore {
val fcmToken: String = "", val fcmToken: String = "",
val ntfyTopic: String = "", val ntfyTopic: String = "",
val ntfyServer: String = "", val ntfyServer: String = "",
val pushBackend: String = "",
val threadsEnabled: Boolean = false, val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated", val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true, val streamingEnabled: Boolean = true,
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
val reasoningAutoCollapse: Boolean = true, val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE, val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE, val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
val backgroundMode: String = BackgroundMode.Image.name.lowercase(), val backgroundMode: String = "color",
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND, val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = Backdrop.DEFAULT.path, val backgroundImagePath: String = "",
val fontSizeScale: Float = 1.0f,
val runtimeFooterEnabled: Boolean = false,
val runtimeFooterFields: String = "",
val pinnedCertFingerprint: String = "",
) )
init { init {
@@ -84,12 +52,11 @@ class DesktopSecureStore : SecureStore {
private fun migrateLegacy() { private fun migrateLegacy() {
if (!legacyFile.exists()) return if (!legacyFile.exists()) return
val legacy = val legacy = try {
try { IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText()) } catch (_: Exception) {
} catch (_: Exception) { null
null }
}
if (legacy != null) { if (legacy != null) {
val s = load() val s = load()
save( save(
@@ -103,32 +70,24 @@ class DesktopSecureStore : SecureStore {
), ),
) )
if (legacy.token.isNotBlank()) secret.write(legacy.token) if (legacy.token.isNotBlank()) secret.write(legacy.token)
// L-49: only delete the legacy file after a successful migration;
// a parse failure (legacy == null) must not destroy the data.
legacyFile.delete()
} }
legacyFile.delete()
} }
private fun load(): Settings { private fun load(): Settings =
cached?.let { return it } if (settingsFile.exists()) {
val s = try {
if (settingsFile.exists()) { IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
try { } catch (_: Exception) {
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
} catch (_: Exception) {
Settings()
}
} else {
Settings() Settings()
} }
cached = s } else {
return s Settings()
} }
private fun save(data: Settings) { private fun save(data: Settings) {
baseDir.mkdirs() baseDir.mkdirs()
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data)) settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
cached = data
} }
override var serverUrl: String override var serverUrl: String
@@ -144,23 +103,11 @@ class DesktopSecureStore : SecureStore {
if (value.isBlank()) secret.clear() else secret.write(value.trim()) if (value.isBlank()) secret.clear() else secret.write(value.trim())
} }
override var deviceToken: String
get() = deviceSecret.read().orEmpty()
set(value) {
if (value.isBlank()) deviceSecret.clear() else deviceSecret.write(value.trim())
}
override val deviceId: String override val deviceId: String
get() { get() {
val d = load() val d = load()
if (d.deviceId.isNotBlank()) return d.deviceId if (d.deviceId.isNotBlank()) return d.deviceId
val id = val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
"dev_" +
UUID
.randomUUID()
.toString()
.replace("-", "")
.take(16)
save(d.copy(deviceId = id)) save(d.copy(deviceId = id))
return id return id
} }
@@ -196,13 +143,6 @@ class DesktopSecureStore : SecureStore {
save(d.copy(ntfyServer = value)) save(d.copy(ntfyServer = value))
} }
override var pushBackend: String
get() = load().pushBackend
set(value) {
val d = load()
save(d.copy(pushBackend = value))
}
override var threadsEnabled: Boolean override var threadsEnabled: Boolean
get() = load().threadsEnabled get() = load().threadsEnabled
set(value) { set(value) {
@@ -224,13 +164,6 @@ class DesktopSecureStore : SecureStore {
save(d.copy(streamingEnabled = value)) save(d.copy(streamingEnabled = value))
} }
override var streamSmoothness: Float
get() = load().streamSmoothness
set(value) {
val d = load()
save(d.copy(streamSmoothness = value))
}
override var reasoningAutoCollapse: Boolean override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse get() = load().reasoningAutoCollapse
set(value) { set(value) {
@@ -253,7 +186,7 @@ class DesktopSecureStore : SecureStore {
} }
override var backgroundMode: String override var backgroundMode: String
get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() } get() = load().backgroundMode.ifBlank { "color" }
set(value) { set(value) {
val d = load() val d = load()
save(d.copy(backgroundMode = value)) save(d.copy(backgroundMode = value))
@@ -273,66 +206,16 @@ class DesktopSecureStore : SecureStore {
save(d.copy(backgroundImagePath = value)) save(d.copy(backgroundImagePath = value))
} }
override var fontSizeScale: Float override fun savePairing(url: String, token: String) {
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 var pinnedCertFingerprint: String
get() = load().pinnedCertFingerprint
set(value) {
val d = load()
save(d.copy(pinnedCertFingerprint = value.trim()))
}
override fun savePairing(
url: String,
token: String,
) {
val d = load() val d = load()
save(d.copy(serverUrl = url.trim())) save(d.copy(serverUrl = url.trim()))
if (token.isBlank()) secret.clear() else secret.write(token.trim()) if (token.isBlank()) secret.clear() else secret.write(token.trim())
// A (re-)pair may target a different gateway: the old per-device
// token is dead there. The next hello.ack re-mints/returns it.
deviceSecret.clear()
} }
override fun clear() { override fun clear() {
// M-10: clearing pairing must also wipe the device identity + push
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
val d = load() val d = load()
save( save(d.copy(serverUrl = ""))
d.copy(
serverUrl = "",
deviceId = "",
syncCursor = 0L,
fcmToken = "",
ntfyTopic = "",
ntfyServer = "",
pushBackend = "",
pinnedCertFingerprint = "",
),
)
secret.clear() secret.clear()
deviceSecret.clear()
} }
} }
@@ -351,33 +234,18 @@ private data class PairingData(
/** /**
* Token storage: OS keyring when available, else an AES-GCM encrypted file. * Token storage: OS keyring when available, else an AES-GCM encrypted file.
* All backend failures degrade to the encrypted file (never plaintext). * All backend failures degrade to the encrypted file (never plaintext).
*
* Parameterized so the shared gateway token and the per-device token
* (docs/09 §9.3) each get their own keyring entry / encrypted file.
*/ */
private class SecretBackend( private class SecretBackend(private val baseDir: File) {
private val baseDir: File, private val encFile = File(baseDir, "pairing.enc")
private val keyringService: String,
private val keyringLabel: String,
private val keyringAttr: String,
encFileName: String,
) {
private val encFile = File(baseDir, encFileName)
private val keyFile = File(baseDir, ".key") private val keyFile = File(baseDir, ".key")
private val keyring: KeyringBackend? = private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
KeyringBackend(keyringService, keyringLabel, keyringAttr).takeIf { it.available }
fun read(): String? = keyring?.read() ?: readEncrypted() fun read(): String? = keyring?.read() ?: readEncrypted()
fun write(value: String) { fun write(value: String) {
if (keyring != null) { if (keyring != null) {
keyring.write(value) keyring.write(value)
if (keyring.read() == value) { if (keyring.read() == value) return
// L-50: the keyring now holds the secret; drop the stale
// encrypted file so the old token can't be read back.
encFile.delete()
return
}
// Keyring accepted the write but did not persist it (e.g. KWallet // Keyring accepted the write but did not persist it (e.g. KWallet
// without a live daemon). Drop the stale entry and fall back to // without a live daemon). Drop the stale entry and fall back to
// the encrypted file so the token survives a restart. // the encrypted file so the token survives a restart.
@@ -408,12 +276,8 @@ private class SecretBackend(
private fun writeEncrypted(value: String) { private fun writeEncrypted(value: String) {
try { try {
// GCM with a fresh 12-byte SecureRandom IV per write (stored with
// the ciphertext); the IV is never reused for a given key.
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv)) cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
baseDir.mkdirs() baseDir.mkdirs()
@@ -429,9 +293,7 @@ private class SecretBackend(
if (bytes.size < 28) return null if (bytes.size < 28) return null
val iv = bytes.copyOfRange(0, 12) val iv = bytes.copyOfRange(0, 12)
val ct = bytes.copyOfRange(12, bytes.size) val ct = bytes.copyOfRange(12, bytes.size)
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
val cipher = Cipher.getInstance("AES/GCM/NoPadding") val cipher = Cipher.getInstance("AES/GCM/NoPadding")
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv)) cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
String(cipher.doFinal(ct), Charsets.UTF_8) String(cipher.doFinal(ct), Charsets.UTF_8)
} catch (_: Exception) { } catch (_: Exception) {
@@ -441,61 +303,40 @@ private class SecretBackend(
} }
/** OS keyring via the platform CLI (best effort). */ /** OS keyring via the platform CLI (best effort). */
private class KeyringBackend( private class KeyringBackend {
private val service: String,
private val label: String,
private val attr: String,
) {
private val os = System.getProperty("os.name").lowercase() private val os = System.getProperty("os.name").lowercase()
private val isMac = os.contains("mac") private val isMac = os.contains("mac")
private val isLinux = os.contains("linux") private val isLinux = os.contains("linux")
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */ /** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
val available: Boolean by lazy { val available: Boolean by lazy {
if (isMac) { if (isMac) true
true else if (isLinux) which("secret-tool") != null
} else if (isLinux) { else false
which("secret-tool") != null
} else {
false
}
} }
fun read(): String? = fun read(): String? = try {
try { if (isMac) {
if (isMac) { out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
out(listOf("security", "find-generic-password", "-a", "iris", "-s", service, "-w")) } else {
} else { out(listOf("secret-tool", "lookup", "app", "iris"))
out(listOf("secret-tool", "lookup", "app", attr))
}
} catch (_: Exception) {
null
} }
} catch (_: Exception) {
null
}
fun write(value: String) { fun write(value: String) {
try { try {
// M-9: pass the secret on stdin, not as a CLI argument. A trailing if (isMac) {
// argument is visible in the process list (`ps`); `secret-tool ProcessBuilder(
// store` and `security add-generic-password -w` both read the "security", "add-generic-password", "-U",
// secret from stdin when no value argument is given. "-a", "iris", "-s", "iris-gateway-token", "-w", value,
val cmd = ).inheritIO().start().waitFor()
if (isMac) { } else {
listOf( ProcessBuilder(
"security", "secret-tool", "store", "--label=Iris gateway token",
"add-generic-password", "app", "iris", "token", value,
"-U", ).inheritIO().start().waitFor()
"-a",
"iris",
"-s",
service,
"-w",
)
} else {
listOf("secret-tool", "store", "--label=$label", "app", attr)
}
ProcessBuilder(cmd).start().apply {
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -505,19 +346,12 @@ private class KeyringBackend(
try { try {
if (isMac) { if (isMac) {
ProcessBuilder( ProcessBuilder(
"security", "security", "delete-generic-password",
"delete-generic-password", "-a", "iris", "-s", "iris-gateway-token",
"-a",
"iris",
"-s",
service,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} else { } else {
ProcessBuilder( ProcessBuilder(
"secret-tool", "secret-tool", "clear", "app", "iris",
"clear",
"app",
attr,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -526,26 +360,17 @@ private class KeyringBackend(
private fun out(cmd: List<String>): String? { private fun out(cmd: List<String>): String? {
val p = ProcessBuilder(cmd).redirectErrorStream(true).start() val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
val text = val text = p.inputStream.bufferedReader().readText().trim()
p.inputStream
.bufferedReader()
.readText()
.trim()
p.waitFor() p.waitFor()
return text.ifBlank { null } return text.ifBlank { null }
} }
private fun which(bin: String): String? = private fun which(bin: String): String? = try {
try { val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start() val text = p.inputStream.bufferedReader().readText().trim()
val text = p.waitFor()
p.inputStream text.ifBlank { null }
.bufferedReader() } catch (_: Exception) {
.readText() null
.trim() }
p.waitFor()
text.ifBlank { null }
} catch (_: Exception) {
null
}
} }
@@ -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( KCEF.init(
builder = { builder = {
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle")) installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
// Pinned to the JBR 17 release the KCEF 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 // built against; the default "latest" JBR may be JDK 21-based
// and is not guaranteed compatible. // and is not guaranteed compatible.
download { download {
github { release("jbr-release-17.0.14b1367.22") } github { release("jbr-release-17.0.12b1207.37") }
} }
settings { settings {
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath 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 interface MediaWriter : AutoCloseable {
actual val path: String actual val path: String
actual fun write(bytes: ByteArray) actual fun write(bytes: ByteArray)
} }
actual class MediaCache actual constructor( actual class MediaCache actual constructor(baseDir: String) {
baseDir: String,
) {
private val maxBytes: Long = 500L * 1024 * 1024 private val maxBytes: Long = 500L * 1024 * 1024
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() } private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
/** actual fun path(mediaId: String, mime: String): String? {
* Resolve the cache file for [mediaId]. The id is server-controlled val f = File(mediaDir, "$mediaId${extForMime(mime)}")
* (`media.offer`); reject path-traversal values so a hostile gateway can't
* write outside [mediaDir] (M-2 / S-1). Returns null when the id is
* invalid.
*/
private fun fileFor(
mediaId: String,
mime: String,
): File? = if (!isValidMediaId(mediaId)) null else File(mediaDir, "$mediaId${extForMime(mime)}")
actual fun path(
mediaId: String,
mime: String,
): String? {
val f = fileFor(mediaId, mime) ?: return null
return if (f.exists()) f.absolutePath else null return if (f.exists()) f.absolutePath else null
} }
actual fun openWriter( actual fun openWriter(mediaId: String, mime: String): MediaWriter =
mediaId: String, JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this)
mime: String,
): MediaWriter = JvmMediaWriter(fileFor(mediaId, mime) ?: throw IllegalArgumentException("invalid media id"), this)
actual fun remove( actual fun remove(mediaId: String, mime: String) {
mediaId: String,
mime: String,
) {
path(mediaId, mime)?.let { File(it).delete() } path(mediaId, mime)?.let { File(it).delete() }
} }
@@ -58,10 +36,7 @@ actual class MediaCache actual constructor(
} }
} }
private class JvmMediaWriter( private class JvmMediaWriter(private val target: File, private val cache: MediaCache) : MediaWriter {
private val target: File,
private val cache: MediaCache,
) : MediaWriter {
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part")) private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
override val path: String get() = target.absolutePath override val path: String get() = target.absolutePath
@@ -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

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