Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb980d12b4 | ||
|
|
f3f1b37221 | ||
|
|
6f339330c5 | ||
|
|
573291fc1e | ||
|
|
83a67f6fb1 | ||
|
|
c7a16d51e3 | ||
|
|
b1c9bac7d8 | ||
|
|
a61b47a947 | ||
|
|
597a28050f | ||
|
|
f90e40a3fc | ||
|
|
330e63e941 | ||
|
|
b8e756c3dd | ||
|
|
7faaf2aa1c | ||
|
|
746d809d48 | ||
|
|
70282dfb65 | ||
|
|
44e8c7322e | ||
|
|
29d0c1a73f | ||
|
|
d801a18db5 | ||
|
|
560d9c19b3 | ||
|
|
32db7fc4e8 | ||
|
|
863ab34915 | ||
|
|
a4e4a4ea63 | ||
|
|
ca622d3a39 | ||
|
|
abbed438ec | ||
|
|
6458c3183c | ||
|
|
9c50f2dbc1 | ||
|
|
487fd1c83c | ||
|
|
4e9f1d028a | ||
|
|
742916903b | ||
|
|
1ff2ef380c | ||
|
|
82c5a20848 | ||
|
|
34a64d6e53 | ||
|
|
7a6d922d12 | ||
|
|
27dc7917f2 | ||
|
|
4866f14231 | ||
|
|
8fba00ba7f | ||
|
|
7f0bdcbbc1 | ||
|
|
e6015033b6 | ||
|
|
2349a95dd4 | ||
|
|
7f936fa596 | ||
|
|
e5c7d690b8 | ||
|
|
524ed8ce53 | ||
|
|
dd43033888 | ||
|
|
6591d7cec0 | ||
|
|
3a33f6be15 | ||
|
|
c71636ad55 | ||
|
|
5a69e3927b | ||
|
|
1f182a7e7e | ||
|
|
7309158e12 | ||
|
|
8c09aecf4a | ||
|
|
060420c615 | ||
|
|
81f42ab761 | ||
|
|
9a519e3c5a | ||
|
|
17bf41a0b9 | ||
|
|
9286937e2d | ||
|
|
678c0344c8 | ||
|
|
9f3f9842c8 | ||
|
|
acd5fb4ad0 | ||
|
|
75d491fd30 | ||
|
|
96b60daf08 | ||
|
|
ec53769325 | ||
|
|
1bcadcf950 | ||
|
|
10e9565e2e | ||
|
|
d7cd59b685 | ||
|
|
6846ab1e19 | ||
|
|
abf1374c1e | ||
|
|
f79c54fead | ||
|
|
73815b032a | ||
|
|
ac4097e9a1 | ||
|
|
7ba5632a03 | ||
|
|
a1814d016c | ||
|
|
f8effb4152 | ||
|
|
a8920d8d26 | ||
|
|
d16b47abdf | ||
|
|
49b7a3f577 | ||
|
|
079ef664a2 | ||
|
|
1ec705727a | ||
|
|
37c13b4d02 | ||
|
|
0e8f69a43c | ||
|
|
86a4c8ee70 | ||
|
|
e9e1aed0f2 | ||
|
|
efecf2732e | ||
|
|
e678caafdc |
No files matched your search
@@ -0,0 +1,79 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# The gateway tests run inside the hermes-agent test harness, which is
|
||||
# git-ignored in this repo (read-only research reference). CI clones the
|
||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
||||
# strip it so CI uses the JDK installed by setup-java.
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
||||
# missing SDK platforms (licenses accepted above).
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
@@ -0,0 +1,226 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# The release version comes from the repo-root VERSION file (the single
|
||||
# source of truth) — bump it in a commit, then dispatch this workflow.
|
||||
changelog:
|
||||
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
|
||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
||||
release:
|
||||
name: Build + create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
||||
# does not ship.
|
||||
- name: Install fakeroot (for jpackage --type deb)
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
||||
|
||||
- name: Restore release keystore (from Gitea secrets)
|
||||
env:
|
||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$KS_B64" ]; then
|
||||
echo "$KS_B64" | base64 -d > app/release.keystore
|
||||
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "Building SIGNED release APK"
|
||||
else
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
||||
fi
|
||||
|
||||
- name: Build APK + AAB
|
||||
run: |
|
||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||
cd app
|
||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
# APK for direct sideloading, AAB for Play Store uploads.
|
||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
||||
else
|
||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
||||
fi
|
||||
|
||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, add a second build job
|
||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
||||
- name: Build desktop app-image + deb
|
||||
run: |
|
||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||
cd app
|
||||
# Self-contained app image (JRE bundled via jlink).
|
||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||
(cd desktopApp/build/jpackage && zip -qr \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
||||
cp desktopApp/build/jpackage/*.deb \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
||||
|
||||
- name: Create release + upload artifacts
|
||||
env:
|
||||
# Optional: create a personal access token (scope: Releases: write)
|
||||
# and store it as secret GITEA_TOKEN. Without it the workflow uses
|
||||
# the automatic GITHUB_TOKEN that Gitea Actions provides.
|
||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
||||
TAG="v$VERSION"
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
|
||||
# message) before failing — plain `curl -f` hides it (exit 22).
|
||||
api() {
|
||||
local code body
|
||||
body=$(mktemp)
|
||||
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
||||
if [ "${code:0:1}" != "2" ]; then
|
||||
echo "API error $code: $(cat "$body")" >&2
|
||||
rm -f "$body"
|
||||
return 1
|
||||
fi
|
||||
cat "$body"
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
# Re-run safety: drop a previous release AND its tag for this version.
|
||||
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
||||
# makes the POST below fail with 409.)
|
||||
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
||||
if [ -n "$OLD_ID" ]; then
|
||||
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||
fi
|
||||
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
|
||||
|
||||
# Gitea creates the tag at the default branch HEAD automatically.
|
||||
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"$API/releases" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
||||
| jq -r .id)
|
||||
echo "Created release $TAG (id $RELEASE_ID)"
|
||||
|
||||
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading $(basename "$f")"
|
||||
# Forgejo-style API: release assets live under /assets, not /attachments.
|
||||
api -X POST -H "$AUTH" -F "attachment=@$f" \
|
||||
"$API/releases/$RELEASE_ID/assets" > /dev/null
|
||||
done
|
||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
@@ -0,0 +1,19 @@
|
||||
# Gitleaks allowlist for iris_x_hermes.
|
||||
#
|
||||
# pi-lens runs `gitleaks detect --no-git` over the working tree, which includes
|
||||
# git-ignored paths. The hits below are all false positives:
|
||||
# - hermes-agent/ read-only research reference (never committed)
|
||||
# - **/build/** Gradle / jpackage build artifacts (regenerated)
|
||||
# - google-services.json standard Firebase config; its "API key" is a web
|
||||
# client key restricted by package name + SHA-1, and
|
||||
# the file is git-ignored (optional; FCM is inert
|
||||
# without it).
|
||||
title = "iris_x_hermes gitleaks allowlist"
|
||||
|
||||
[allowlist]
|
||||
description = "Git-ignored reference tree, build artifacts, and Firebase config"
|
||||
paths = [
|
||||
'''^hermes-agent/''',
|
||||
'''.*/build/''',
|
||||
'''^app/androidApp/google-services\.json$''',
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"ignore": [
|
||||
"tests/test_android.py"
|
||||
],
|
||||
"rules": {
|
||||
"unchecked-throwing-call-python": {
|
||||
"disable": [
|
||||
"unchecked-throwing-call-python",
|
||||
"ast-grep:unchecked-throwing-call-python"
|
||||
]
|
||||
},
|
||||
"python-logger-credential-disclosure": {
|
||||
"disable": [
|
||||
"opengrep:python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure"
|
||||
]
|
||||
},
|
||||
"sqlalchemy-execute-raw-query": {
|
||||
"disable": [
|
||||
"opengrep:python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query"
|
||||
]
|
||||
},
|
||||
"exported-activity": {
|
||||
"disable": [
|
||||
"opengrep:java.android.security.exported_activity.exported_activity"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Hard rules
|
||||
|
||||
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
|
||||
- **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
|
||||
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
|
||||
|
||||
## Layout
|
||||
|
||||
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
|
||||
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
|
||||
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
|
||||
|
||||
## Commands
|
||||
|
||||
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
|
||||
- Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
|
||||
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
|
||||
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
|
||||
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
|
||||
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
|
||||
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `tests/README.md`.
|
||||
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python tests/e2e.py`.
|
||||
|
||||
## Environment / pairing quirks
|
||||
|
||||
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
|
||||
- HTTP default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
||||
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
||||
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
||||
- **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
|
||||
- Desktop jpackage on Linux/JDK 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
|
||||
|
||||
## 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`.
|
||||
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
|
||||
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
|
||||
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
|
||||
- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
|
||||
@@ -0,0 +1,518 @@
|
||||
# CI / Release setup — manual edit list
|
||||
|
||||
Everything needed for the Gitea workflows (CI + manual release). Items marked
|
||||
**DONE** were already applied; the rest are copy-paste instructions.
|
||||
|
||||
---
|
||||
|
||||
## 1. DONE — no action needed
|
||||
|
||||
- `tests/test_android.py` — vendored byte-identical mirror of
|
||||
`hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout
|
||||
is the canonical copy; **keep the two in sync** when you change that test).
|
||||
- `.pi-lens.json` — added `"ignore": ["tests/test_android.py"]`
|
||||
so the scanner doesn't flag the vendored mirror.
|
||||
|
||||
---
|
||||
|
||||
## 2. NEW FILE: `.gitea/workflows/ci.yml`
|
||||
|
||||
Runs on every push to `master` and on PRs: gateway plugin tests + Kotlin host
|
||||
tests (android + desktop).
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# The gateway tests run inside the hermes-agent test harness, which is
|
||||
# git-ignored in this repo (read-only research reference). CI clones the
|
||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
uv sync
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
||||
# strip it so CI uses the JDK installed by setup-java.
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
||||
# missing SDK platforms (licenses accepted above).
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. NEW FILE: `.gitea/workflows/release.yml`
|
||||
|
||||
The release version is the repo-root **`VERSION` file** (single source of
|
||||
truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
|
||||
commit). Manual trigger: **repo → Actions → Release → Run workflow**,
|
||||
optionally with a `changelog`. It runs the same tests as CI, builds a signed
|
||||
Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled),
|
||||
then creates the Gitea release `v<VERSION>` with all artifacts as download
|
||||
attachments.
|
||||
|
||||
Note: builds + release creation happen in ONE job because Gitea/act_runner
|
||||
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
|
||||
with `GHESNotSupportedError`).
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# The release version comes from the repo-root VERSION file (the single
|
||||
# source of truth) — bump it in a commit, then dispatch this workflow.
|
||||
changelog:
|
||||
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
|
||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
||||
release:
|
||||
name: Build + create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
||||
# does not ship.
|
||||
- name: Install fakeroot (for jpackage --type deb)
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
||||
|
||||
- name: Restore release keystore (from Gitea secrets)
|
||||
env:
|
||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$KS_B64" ]; then
|
||||
echo "$KS_B64" | base64 -d > app/release.keystore
|
||||
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "Building SIGNED release APK"
|
||||
else
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
||||
fi
|
||||
|
||||
- name: Build APK + AAB
|
||||
run: |
|
||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||
cd app
|
||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
# APK for direct sideloading, AAB for Play Store uploads.
|
||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
||||
else
|
||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
||||
fi
|
||||
|
||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, add a second build job
|
||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
||||
- name: Build desktop app-image + deb
|
||||
run: |
|
||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
||||
cd app
|
||||
# Self-contained app image (JRE bundled via jlink).
|
||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||
(cd desktopApp/build/jpackage && zip -qr \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
||||
cp desktopApp/build/jpackage/*.deb \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
||||
|
||||
- name: Create release + upload artifacts
|
||||
env:
|
||||
# Optional: create a personal access token (scope: Releases: write)
|
||||
# and store it as secret GITEA_TOKEN. Without it the workflow uses
|
||||
# the automatic GITHUB_TOKEN that Gitea Actions provides.
|
||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
||||
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
||||
TAG="v$VERSION"
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
|
||||
# message) before failing — plain `curl -f` hides it (exit 22).
|
||||
api() {
|
||||
local code body
|
||||
body=$(mktemp)
|
||||
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
||||
if [ "${code:0:1}" != "2" ]; then
|
||||
echo "API error $code: $(cat "$body")" >&2
|
||||
rm -f "$body"
|
||||
return 1
|
||||
fi
|
||||
cat "$body"
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
# Re-run safety: drop a previous release AND its tag for this version.
|
||||
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
||||
# makes the POST below fail with 409.)
|
||||
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
||||
if [ -n "$OLD_ID" ]; then
|
||||
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||
fi
|
||||
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
|
||||
|
||||
# Gitea creates the tag at the default branch HEAD automatically.
|
||||
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"$API/releases" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
||||
| jq -r .id)
|
||||
echo "Created release $TAG (id $RELEASE_ID)"
|
||||
|
||||
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading $(basename "$f")"
|
||||
# Forgejo-style API: release assets live under /assets, not /attachments.
|
||||
api -X POST -H "$AUTH" -F "attachment=@$f" \
|
||||
"$API/releases/$RELEASE_ID/assets" > /dev/null
|
||||
done
|
||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. EDITS to existing Gradle files
|
||||
|
||||
> **Note (versioning):** the `versionName` / `appVersion` lines shown below
|
||||
> have since been changed to read the repo-root **`VERSION` file** (single
|
||||
> source of truth; `-PappVersion` still overrides in CI). See
|
||||
> `.gitea/workflows/release.yml` and `gateway-plugin/version.py`.
|
||||
|
||||
### 4a. `app/androidApp/build.gradle.kts`
|
||||
|
||||
**Change 1** — in `defaultConfig`, replace:
|
||||
|
||||
```kotlin
|
||||
versionName = "0.1.0"
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```kotlin
|
||||
// CI passes -PappVersion=<version> (release workflow); local builds
|
||||
// keep the default.
|
||||
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
||||
```
|
||||
|
||||
**Change 2** — inside the `android { }` block, right after the `buildTypes { }`
|
||||
block, add:
|
||||
|
||||
```kotlin
|
||||
// CI release signing: .gitea/workflows/release.yml restores a keystore
|
||||
// from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see
|
||||
// scripts/make_release_keystore.sh). Without them the release build stays
|
||||
// unsigned and the workflow falls back to a debug APK.
|
||||
val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE")
|
||||
if (ciKeystore != null) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(ciKeystore)
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4b. `app/desktopApp/build.gradle.kts`
|
||||
|
||||
**Change 1** — near the top (after `val arch = ...`), add:
|
||||
|
||||
```kotlin
|
||||
// CI passes -PappVersion=<version> (release workflow); local builds keep the
|
||||
// default. jpackage requires a plain semver (no leading "v").
|
||||
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
||||
```
|
||||
|
||||
**Change 2** — in the `jpackage` Exec task's `commandLine(...)`, replace:
|
||||
|
||||
```kotlin
|
||||
"--app-version", "0.1.0",
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```kotlin
|
||||
"--app-version", appVersion,
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. NEW FILE: `scripts/make_release_keystore.sh` (Android signing, one-time)
|
||||
|
||||
This is the noob-friendly signing setup. Run it **once** on your machine:
|
||||
|
||||
```bash
|
||||
scripts/make_release_keystore.sh
|
||||
```
|
||||
|
||||
It generates a keystore with random passwords and prints the four values to
|
||||
paste into Gitea. Then create the secrets in
|
||||
**Gitea → repo → Settings → Actions → Secrets**:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `ANDROID_KEYSTORE_BASE64` | the long base64 blob the script prints |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | printed by the script |
|
||||
| `ANDROID_KEY_ALIAS` | `iris` |
|
||||
| `ANDROID_KEY_PASSWORD` | printed by the script |
|
||||
|
||||
Until the secrets exist, the release workflow still works but ships a
|
||||
**debug** APK (installable, but not suitable for updates).
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup: create the Android release keystore and print the values to
|
||||
# paste into Gitea (repo -> Settings -> Actions -> Secrets).
|
||||
#
|
||||
# Usage: scripts/make_release_keystore.sh [output-file]
|
||||
# (default: ~/iris-release.keystore)
|
||||
#
|
||||
# WARNING: back up the keystore file immediately. If it is lost, the app can
|
||||
# never be updated on users' phones (a new key = a brand-new app as far as
|
||||
# Android is concerned).
|
||||
set -euo pipefail
|
||||
|
||||
OUT="${1:-$HOME/iris-release.keystore}"
|
||||
if [ -e "$OUT" ]; then
|
||||
echo "Refusing to overwrite existing file: $OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STORE_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
|
||||
KEY_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
|
||||
|
||||
keytool -genkeypair -v \
|
||||
-keystore "$OUT" -storetype PKCS12 \
|
||||
-alias iris -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass "$STORE_PASS" -keypass "$KEY_PASS" \
|
||||
-dname "CN=Iris Release, OU=Mobile, O=Iris, C=DE"
|
||||
|
||||
echo
|
||||
echo "Keystore written to: $OUT"
|
||||
echo ">>> Back it up NOW (password manager / cloud storage). <<<"
|
||||
echo
|
||||
echo "Add these four secrets in Gitea (repo -> Settings -> Actions -> Secrets):"
|
||||
echo
|
||||
echo " ANDROID_KEYSTORE_BASE64 = $(base64 -w0 "$OUT")"
|
||||
echo
|
||||
echo " ANDROID_KEYSTORE_PASSWORD = $STORE_PASS"
|
||||
echo " ANDROID_KEY_ALIAS = iris"
|
||||
echo " ANDROID_KEY_PASSWORD = $KEY_PASS"
|
||||
```
|
||||
|
||||
Don't forget: `chmod +x scripts/make_release_keystore.sh`
|
||||
|
||||
---
|
||||
|
||||
## 6. Prerequisites / checks before the first run
|
||||
|
||||
1. **act_runner must be registered** on gitea.zephyre.one with the label
|
||||
`ubuntu-latest` (that's what both workflows request). Check under
|
||||
Gitea → (your user or the org) → Actions → Runners.
|
||||
2. The runner needs internet access (GitHub, Google, Maven Central, Gradle).
|
||||
3. No Gitea secrets are *required* — but add the four keystore secrets from
|
||||
§5 for a signed APK, and optionally a `GITEA_TOKEN` (Releases: write) if
|
||||
the automatic `GITHUB_TOKEN` doesn't have release permission.
|
||||
|
||||
## 7. Using it
|
||||
|
||||
- **CI**: pushes to `master` / PRs run automatically.
|
||||
- **Release**: repo → **Actions** → *Release* → **Run workflow** → enter
|
||||
`version` (e.g. `0.2.0`) + `changelog` → start. The release appears at
|
||||
`https://gitea.zephyre.one/ARIA/iris_x_hermes/releases/tag/v0.2.0` with:
|
||||
- `iris-android-v0.2.0.apk` (signed, or `-debug` without keystore secrets)
|
||||
- `iris-desktop-linux-x64-v0.2.0.zip` (app image, JRE bundled)
|
||||
- `iris-desktop-linux-x64-v0.2.0.deb`
|
||||
- Re-running with the same version **replaces** the old release (old tag +
|
||||
attachments are deleted first).
|
||||
|
||||
## 8. Later: Windows / macOS desktop builds
|
||||
|
||||
When the Windows VM / MacBook runner exists:
|
||||
|
||||
1. Register act_runner on that machine (labels e.g. `windows-latest`,
|
||||
`macos-latest`).
|
||||
2. In `release.yml`, copy the `desktop` job, change `runs-on`, and adjust the
|
||||
artifact names (`iris-desktop-windows-x64-…`, `iris-desktop-macos-…`).
|
||||
jpackage then produces `msi`/`dmg` natively — no other changes needed.
|
||||
3. Bump the pinned hermes-agent SHA in both workflows whenever you update the
|
||||
local `hermes-agent/` checkout (`git -C hermes-agent rev-parse HEAD`).
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,57 +1,183 @@
|
||||
# Iris × Hermes
|
||||
|
||||
A **native Android + Desktop** experience for [hermes-agent](https://github.com/NousResearch/hermes-agent), connected through a **gateway platform plugin**.
|
||||
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).
|
||||
|
||||
> ## ⚠️ HARD RULES
|
||||
> 1. **`hermes-agent/` is a read-only research reference. It is git-ignored and
|
||||
> must NEVER be committed, pushed, or shipped.** We only *install* our plugin
|
||||
> into a live hermes home (`~/.hermes/plugins/android`); we never modify hermes
|
||||
> core.
|
||||
> 2. **ADB is installed and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
|
||||
> Android 10 / API 29). Use it to install/launch/debug the app on-device.
|
||||
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.
|
||||
|
||||
## Start here
|
||||
## Features
|
||||
|
||||
📚 **The full implementation reference library is in [`docs/`](docs/README.md).**
|
||||
Read `docs/00-overview.md` first, then follow the numbered docs.
|
||||
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
|
||||
- **Absolute Privacy!** — everything stays on your own infrastructure
|
||||
(push: ntfy by default; FCM is opt-in and routes push metadata via Google —
|
||||
see [Push notifications](#push-notifications))
|
||||
- **100 MB file uploads by default** — configurable on the gateway via
|
||||
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
|
||||
on the gateway side (hermes), not in the app
|
||||
- **No 4,096-character message limit like Telegram** — messages travel over
|
||||
your own gateway (hard frame-body cap: 1 MiB)
|
||||
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
|
||||
- **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.
|
||||
- **Channels** — create channels to keep track of your reports, cronjobs, webhook calls
|
||||
- **Pin channels, rename them, change the color/icon**
|
||||
- **Customize how your chat should look like** — color? Check! Images? Check!
|
||||
- **Reasoning collapse/expandable** in the chat bubble
|
||||
- **Change the default behavior** of tool & reasoning verbosity
|
||||
- **Threads** — create your own, or let the AI create them with a title
|
||||
- **Search messages from everywhere**
|
||||
|
||||
## Quickstart
|
||||
## How it works
|
||||
|
||||
Full walkthrough: [`docs/setup.md`](docs/setup.md).
|
||||
```
|
||||
hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android / Desktop)
|
||||
```
|
||||
|
||||
1. **Gateway:** install the plugin (`ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android`), run `hermes gateway setup` (generates the pairing token, prints the server URL), then `hermes gateway`.
|
||||
2. **App:** `cd app && ./gradlew :androidApp:installDebug` (Android; ADB device connected) or `./gradlew :desktopApp:run` (desktop).
|
||||
3. **Pair:** on the app's Connect screen, enter the server URL (`ws://<gateway-ip>:8790/ws`) + pairing token, then **Test & Connect**.
|
||||
4. **Chat.**
|
||||
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
|
||||
`hermes gateway` process and opens an HTTP server the apps connect to.
|
||||
Zero new Python dependencies, zero hermes-core changes.
|
||||
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
|
||||
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
|
||||
(the same app, tweaked for a big screen).
|
||||
- The app is a first-class hermes *messaging platform*, so everything the gateway
|
||||
already does just works: slash commands, cron delivery, `send_message` routing,
|
||||
coexistence with Telegram/Discord/etc.
|
||||
- Push notifications: ntfy (default) or FCM (opt-in).
|
||||
|
||||
### Pairing notes
|
||||
## Push notifications
|
||||
|
||||
- **Token location:** `~/.hermes/.env` on the gateway host (`ANDROID_TOKEN`), or the `hermes gateway setup` output (printed once, at generation).
|
||||
- **Manual entry only:** the app has no QR scanner yet — the server prints a QR payload, but you type the URL + token.
|
||||
- **Default bind is `127.0.0.1`:** for a phone on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
||||
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
||||
outbox, so nothing is lost.
|
||||
|
||||
## The three deliverables (this monorepo)
|
||||
- **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.
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `gateway-plugin/` | Python hermes **platform plugin** (`android`). Runs in the `hermes gateway` process; opens a WebSocket server the apps connect to. Zero new Python deps, zero hermes-core changes. |
|
||||
| `app/androidApp` | Native **Kotlin + Jetpack Compose** client. |
|
||||
| `app/desktopApp` | **Kotlin + Compose Multiplatform** client — the Android app, tweaked for a big screen (shares `app/shared`). |
|
||||
Setup: [`docs/install.md`](docs/install.md); details:
|
||||
[`docs/08-push.md`](docs/08-push.md).
|
||||
|
||||
The Android and Desktop clients live in **one Compose Multiplatform Gradle
|
||||
project** (`app/`) with a shared KMP module (`app/shared`).
|
||||
## Install the gateway
|
||||
|
||||
## Status
|
||||
Three commands on the machine where hermes runs:
|
||||
|
||||
- **Phase:** M0–M6 complete; M7 (polish + E2E + docs) in progress.
|
||||
- **Milestones:** see [`docs/14-milestones.md`](docs/14-milestones.md).
|
||||
- **Locked decisions:** see [`docs/16-open-questions.md`](docs/16-open-questions.md).
|
||||
```bash
|
||||
cd hermes-agent && uv sync # 1. hermes with its venv (separate project, not this repo)
|
||||
|
||||
## Quick orientation
|
||||
# 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
|
||||
|
||||
- Architecture + rationale → [`docs/01-architecture.md`](docs/01-architecture.md)
|
||||
- Wire protocol → [`docs/04-wire-protocol.md`](docs/04-wire-protocol.md) (+ [`docs/protocol/frames.schema.json`](docs/protocol/frames.schema.json))
|
||||
- Gateway plugin design → [`docs/03-gateway-plugin.md`](docs/03-gateway-plugin.md)
|
||||
- hermes source cheat-sheet → [`docs/15-hermes-reference.md`](docs/15-hermes-reference.md)
|
||||
- Toolchain setup → [`docs/12-toolchain.md`](docs/12-toolchain.md)
|
||||
</file>
|
||||
# 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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Where | You need |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| Desktop build machine | JDK 17 only |
|
||||
|
||||
No system Gradle needed — both apps use the project wrapper (`./gradlew`).
|
||||
|
||||
### 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
|
||||
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
|
||||
hermes gateway # run the gateway
|
||||
```
|
||||
|
||||
(Otherwise see [Install the gateway](#install-the-gateway) above.)
|
||||
|
||||
### 2. Android app
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :androidApp:installDebug # build + install on the connected ADB device
|
||||
# or just build the APK:
|
||||
./gradlew :androidApp:assembleDebug # → app/androidApp/build/outputs/apk/debug/
|
||||
```
|
||||
|
||||
### 3. Desktop app
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew :desktopApp:run # dev run
|
||||
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
|
||||
```
|
||||
|
||||
### 4. Pair
|
||||
|
||||
On the app's **Connect** screen:
|
||||
|
||||
1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
|
||||
2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
|
||||
on the gateway host.
|
||||
3. **Test & Connect.**
|
||||
|
||||
Notes:
|
||||
|
||||
- **Android** has a **Scan QR** button that reads the QR printed by
|
||||
`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 on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
||||
- Remote access: Tailscale/WireGuard, or a reverse proxy/tunnel with TLS
|
||||
(`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`).
|
||||
|
||||
Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
|
||||
[`docs/install.md`](docs/install.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Before you start:
|
||||
|
||||
1. **Read the docs.** The full reference library is in [`docs/`](docs/README.md) —
|
||||
start with [`docs/00-overview.md`](docs/00-overview.md), then follow the
|
||||
numbered docs (architecture, wire protocol, plugin design, testing, …).
|
||||
2. **Know the layout.**
|
||||
|
||||
| Path | What |
|
||||
| --- | --- |
|
||||
| `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/androidApp` | Thin Android shell (package `dev.iris.app`) |
|
||||
| `app/desktopApp` | Thin desktop shell |
|
||||
| `docs/` | Numbered reference library |
|
||||
|
||||
3. **Run the tests.**
|
||||
- Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
|
||||
(never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`).
|
||||
- Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest`
|
||||
- Live check (gateway must be running): `tests/ws_probe.py` and
|
||||
`tests/e2e.py` — see [`tests/README.md`](tests/README.md).
|
||||
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
|
||||
`app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json`
|
||||
must always agree.
|
||||
5. **Pre-commit hooks** are configured (`.pre-commit-config.yaml`); run
|
||||
`pre-commit install` once after cloning.
|
||||
|
||||
Open an issue first for anything big, then send a pull request.
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 — see [LICENSE](LICENSE).
|
||||
|
Before Width: | Height: | Size: 376 KiB |
@@ -1,19 +1,31 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.iris.app"
|
||||
compileSdk = 34
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.iris.app"
|
||||
minSdk = 29
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// Play Store requires an incrementing versionCode per upload; CI can
|
||||
// pass -PappVersionCode=<n>. Local builds keep the default.
|
||||
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
|
||||
// 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 {
|
||||
@@ -22,23 +34,54 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// CI release signing: .gitea/workflows/release.yml restores a keystore
|
||||
// from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see
|
||||
// scripts/make_release_keystore.sh). Without them the release build stays
|
||||
// unsigned and the workflow falls back to a debug APK.
|
||||
val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE")
|
||||
if (ciKeystore != null) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(ciKeystore)
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
// targetSdk is deliberately pinned to 34 (a stable API level): the
|
||||
// reference device is API 29 and API 37 (the only newer installed
|
||||
// platform) is a preview SDK, which is not appropriate to target for a
|
||||
// stable build. Silence the informational OldTargetApi hint.
|
||||
lint {
|
||||
disable += "OldTargetApi"
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(platform("androidx.compose:compose-bom:2024.09.02"))
|
||||
implementation(platform("androidx.compose:compose-bom:2026.08.00"))
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.activity:activity-compose:1.9.2")
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("androidx.core:core-splashscreen:1.2.0")
|
||||
}
|
||||
|
||||
// M5: apply the google-services plugin only when a Firebase project is
|
||||
@@ -46,4 +89,4 @@ dependencies {
|
||||
// inert and the ntfy listener is the push path.
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,20 @@
|
||||
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<!-- M-3: cleartext (http://) is required because the gateway is a LAN host
|
||||
addressed by IP (e.g. 192.168.x.x), not a domain. Android's
|
||||
network_security_config can only scope cleartext to domain names, not
|
||||
IP ranges, so a per-host allowlist isn't possible for this use case.
|
||||
The token is still required for auth; traffic is only ever sent to the
|
||||
user-configured gateway on the local network. -->
|
||||
<application
|
||||
android:label="Iris"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:allowBackup="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
@@ -36,8 +47,22 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="iris" android:host="chat" />
|
||||
</intent-filter>
|
||||
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="iris" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- QR pairing (docs/20): full-screen scanner launched from the
|
||||
Connect screen. Not exported; started only by our own app. -->
|
||||
<activity
|
||||
android:name="iris.platform.QrScanActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
|
After Width: | Height: | Size: 758 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
@@ -15,12 +15,17 @@ import iris.IrisApp
|
||||
import iris.platform.AndroidEnv
|
||||
import iris.platform.AndroidSecureStore
|
||||
import iris.platform.AppBridge
|
||||
import iris.platform.NtfyListenerService
|
||||
import iris.platform.syncNtfyListener
|
||||
import iris.util.PairLink
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val deepLinkChatId = mutableStateOf<String?>(null)
|
||||
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
||||
|
||||
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
|
||||
// screen with.
|
||||
private val pendingPair = mutableStateOf<PairLink?>(null)
|
||||
|
||||
private val notificationPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
||||
|
||||
@@ -31,11 +36,19 @@ class MainActivity : ComponentActivity() {
|
||||
val store = AndroidSecureStore(applicationContext)
|
||||
handleDeepLink(intent)
|
||||
requestNotificationPermission()
|
||||
startNtfyListener(store)
|
||||
// M5: the ntfy listener (and its permanent notification) only runs
|
||||
// when the paired gateway pushes via ntfy; unknown ("") = not yet.
|
||||
syncNtfyListener(store.pushBackend)
|
||||
setContent {
|
||||
val chatId by deepLinkChatId
|
||||
val threadId by deepLinkThreadId
|
||||
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
|
||||
val pair by pendingPair
|
||||
IrisApp(
|
||||
store = store,
|
||||
deepLinkChatId = chatId,
|
||||
deepLinkThreadId = threadId,
|
||||
deepLinkPair = pair,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +60,10 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
AppBridge.foreground = true
|
||||
// Wake the connect loop's backoff: after a background stint the
|
||||
// network is usually back, so re-probe immediately instead of making
|
||||
// the user wait out the (up to 30 s) backoff with a Connecting banner.
|
||||
AppBridge.controller?.client?.poke()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -58,10 +75,20 @@ class MainActivity : ComponentActivity() {
|
||||
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
||||
private fun handleDeepLink(intent: Intent?) {
|
||||
val data = intent?.data
|
||||
val chatId = intent?.getStringExtra("chat_id")
|
||||
?: data?.pathSegments?.firstOrNull()
|
||||
val threadId = intent?.getStringExtra("thread_id")
|
||||
?: data?.getQueryParameter("thread")
|
||||
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
|
||||
// the whole payload is in the query string; parse it with the shared
|
||||
// PairLink parser.
|
||||
if (data?.scheme == "iris" && data.host == "pair") {
|
||||
val link = PairLink.parse(data.toString())
|
||||
if (link != null) pendingPair.value = link
|
||||
return
|
||||
}
|
||||
val chatId =
|
||||
intent?.getStringExtra("chat_id")
|
||||
?: data?.pathSegments?.firstOrNull()
|
||||
val threadId =
|
||||
intent?.getStringExtra("thread_id")
|
||||
?: data?.getQueryParameter("thread")
|
||||
if (!chatId.isNullOrBlank()) {
|
||||
deepLinkChatId.value = chatId
|
||||
deepLinkThreadId.value = threadId
|
||||
@@ -77,9 +104,4 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNtfyListener(store: AndroidSecureStore) {
|
||||
if (store.ntfyTopic.isBlank()) return
|
||||
ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M0,0h108v108h-108z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startColor="#262B3A"
|
||||
android:endColor="#14171F"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:endX="0"
|
||||
android:endY="108" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M33.96,51.18a29.75,29.75 0 1,0 59.5,0a29.75,29.75 0 1,0 -59.5,0z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="radial"
|
||||
android:centerX="63.71"
|
||||
android:centerY="51.18"
|
||||
android:gradientRadius="29.75"
|
||||
android:startColor="#384F7CFF"
|
||||
android:endColor="#004F7CFF" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M41.79,43.67Q32.39,42.10 24.57,45.55"
|
||||
android:strokeWidth="4.07"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startColor="#E8EAF0"
|
||||
android:endColor="#7E9BFF"
|
||||
android:startX="23"
|
||||
android:startY="54"
|
||||
android:endX="42"
|
||||
android:endY="54" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M41.16,50.56Q30.51,49.93 23.00,54.31"
|
||||
android:strokeWidth="4.07"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startColor="#E8EAF0"
|
||||
android:endColor="#7E9BFF"
|
||||
android:startX="23"
|
||||
android:startY="54"
|
||||
android:endX="42"
|
||||
android:endY="54" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M41.79,57.44Q33.02,58.70 26.76,63.71"
|
||||
android:strokeWidth="4.07"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startColor="#E8EAF0"
|
||||
android:endColor="#7E9BFF"
|
||||
android:startX="23"
|
||||
android:startY="54"
|
||||
android:endX="42"
|
||||
android:endY="54" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M49.62,66.21C47.42,71.97 44.92,73.10 41.79,75.61C46.49,74.35 50.87,71.54 54.94,66.84Z"
|
||||
android:fillColor="#3D63E8" />
|
||||
<path
|
||||
android:pathData="M53.68,32.39H73.73A11.27,11.27 0 0,1 85,43.66V56.19A11.27,11.27 0 0,1 73.73,67.46H53.68A11.27,11.27 0 0,1 42.41,56.19V43.66A11.27,11.27 0 0,1 53.68,32.39Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startColor="#6E93FF"
|
||||
android:endColor="#3D63E8"
|
||||
android:startX="63.71"
|
||||
android:startY="32.39"
|
||||
android:endX="63.71"
|
||||
android:endY="67.46" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M50.56,51.81A13.15,13.15 0 0,1 76.86,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FF6B6B" />
|
||||
<path
|
||||
android:pathData="M53.06,51.81A10.65,10.65 0 0,1 74.36,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFC107" />
|
||||
<path
|
||||
android:pathData="M55.57,51.81A8.14,8.14 0 0,1 71.85,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#4CAF50" />
|
||||
<path
|
||||
android:pathData="M58.07,51.81A5.64,5.64 0 0,1 69.35,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#29B6F6" />
|
||||
<path
|
||||
android:pathData="M61.99,51.81a1.72,1.72 0 1,0 3.44,0a1.72,1.72 0 1,0 -3.44,0z"
|
||||
android:fillColor="#7C4DFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M41.79,43.67Q32.39,42.10 24.57,45.55"
|
||||
android:strokeWidth="4.07"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M41.16,50.56Q30.51,49.93 23.00,54.31"
|
||||
android:strokeWidth="4.07"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M41.79,57.44Q33.02,58.70 26.76,63.71"
|
||||
android:strokeWidth="4.07"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M49.62,66.21C47.42,71.97 44.92,73.10 41.79,75.61C46.49,74.35 50.87,71.54 54.94,66.84Z"
|
||||
android:fillColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M53.68,32.39H73.73A11.27,11.27 0 0,1 85,43.66V56.19A11.27,11.27 0 0,1 73.73,67.46H53.68A11.27,11.27 0 0,1 42.41,56.19V43.66A11.27,11.27 0 0,1 53.68,32.39Z"
|
||||
android:strokeWidth="3"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M50.56,51.81A13.15,13.15 0 0,1 76.86,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M53.06,51.81A10.65,10.65 0 0,1 74.36,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M55.57,51.81A8.14,8.14 0 0,1 71.85,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M58.07,51.81A5.64,5.64 0 0,1 69.35,51.81"
|
||||
android:strokeWidth="1.88"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeColor="#FFFFFF" />
|
||||
<path
|
||||
android:pathData="M61.99,51.81a1.72,1.72 0 1,0 3.44,0a1.72,1.72 0 1,0 -3.44,0z"
|
||||
android:fillColor="#FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?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" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?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" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -7,21 +7,22 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.google.gms:google-services:4.4.2")
|
||||
classpath("com.google.gms:google-services:4.5.0")
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform") version "2.1.0" apply false
|
||||
kotlin("android") version "2.1.0" apply false
|
||||
kotlin("jvm") version "2.1.0" apply false
|
||||
kotlin("plugin.serialization") version "2.1.0" apply false
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("com.android.library") version "8.7.3" apply false
|
||||
id("org.jetbrains.compose") version "1.7.3" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
|
||||
kotlin("multiplatform") version "2.4.10" apply false
|
||||
kotlin("jvm") version "2.4.10" apply false
|
||||
kotlin("plugin.serialization") version "2.4.10" apply false
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("com.android.kotlin.multiplatform.library") version "9.3.1" apply false
|
||||
id("org.jetbrains.compose") version "1.11.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
|
||||
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
|
||||
id("app.cash.sqldelight") version "2.3.2" apply false
|
||||
}
|
||||
|
||||
tasks.register("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,32 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
val composeVersion = "1.7.3"
|
||||
val composeVersion = "1.11.1"
|
||||
val os = OperatingSystem.current()
|
||||
val arch = System.getProperty("os.arch") ?: "amd64"
|
||||
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"
|
||||
}
|
||||
// The repo-root VERSION file is the single source of truth (bump it to cut
|
||||
// a release); CI can still override with -PappVersion. jpackage requires a
|
||||
// plain semver (no leading "v").
|
||||
val appVersion =
|
||||
(project.findProperty("appVersion") as? String)
|
||||
?: project
|
||||
.file("../../VERSION")
|
||||
.takeIf { it.exists() }
|
||||
?.readText()
|
||||
?.trim()
|
||||
?: "0.1.0"
|
||||
val desktopTarget =
|
||||
when {
|
||||
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
|
||||
os.isWindows -> "windows-x64"
|
||||
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
|
||||
// M6: window-state persistence (window.json).
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
@@ -28,6 +40,16 @@ compose.desktop {
|
||||
}
|
||||
}
|
||||
|
||||
// M9: KCEF (JCEF/Chromium) for HTML artifact previews needs AWT reflection
|
||||
// access on modern JDKs. Applied to the `run` task (JavaExec) and, below, to
|
||||
// the jpackage `--java-options`.
|
||||
afterEvaluate {
|
||||
tasks.withType<JavaExec> {
|
||||
jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
|
||||
jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
|
||||
}
|
||||
}
|
||||
|
||||
// ── M6: native packaging (docs/11 §11.4) ─────────────────────────────────
|
||||
// `./gradlew :desktopApp:jpackage` builds a self-contained app image under
|
||||
// build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage
|
||||
@@ -35,19 +57,21 @@ compose.desktop {
|
||||
|
||||
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
|
||||
|
||||
val fatJar = tasks.register<Jar>("fatJar") {
|
||||
archiveClassifier.set("all")
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
|
||||
from(sourceSets.main.get().output)
|
||||
from({
|
||||
configurations.runtimeClasspath.get()
|
||||
.filter { it.name.endsWith(".jar") }
|
||||
.map { zipTree(it) }
|
||||
}) {
|
||||
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
|
||||
val fatJar =
|
||||
tasks.register<Jar>("fatJar") {
|
||||
archiveClassifier.set("all")
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
|
||||
from(sourceSets.main.get().output)
|
||||
from({
|
||||
configurations.runtimeClasspath
|
||||
.get()
|
||||
.filter { it.name.endsWith(".jar") }
|
||||
.map { zipTree(it) }
|
||||
}) {
|
||||
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage the fat jar into the jpackage input dir. A dedicated Sync task keeps
|
||||
// the Exec task free of task references (configuration-cache compatible) and
|
||||
@@ -63,19 +87,39 @@ tasks.register<Exec>("jpackage") {
|
||||
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
|
||||
val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
|
||||
val inputDir = jpackageInputDir.get().asFile
|
||||
val destDir = layout.buildDirectory.dir("jpackage").get().asFile
|
||||
val destDir =
|
||||
layout.buildDirectory
|
||||
.dir("jpackage")
|
||||
.get()
|
||||
.asFile
|
||||
inputs.dir(inputDir)
|
||||
outputs.dir(destDir)
|
||||
commandLine(
|
||||
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
|
||||
"--name", "iris",
|
||||
"--app-version", "0.1.0",
|
||||
"--vendor", "Iris",
|
||||
"--type", type,
|
||||
"--input", inputDir.absolutePath,
|
||||
"--main-jar", "desktopApp-all.jar",
|
||||
"--main-class", "iris.desktop.MainKt",
|
||||
"--dest", destDir.absolutePath,
|
||||
"--java-options", "-Xmx1g",
|
||||
"--name",
|
||||
"iris",
|
||||
"--app-version",
|
||||
appVersion,
|
||||
"--vendor",
|
||||
"Iris",
|
||||
"--type",
|
||||
type,
|
||||
"--input",
|
||||
inputDir.absolutePath,
|
||||
"--main-jar",
|
||||
"desktopApp-all.jar",
|
||||
"--main-class",
|
||||
"iris.desktop.MainKt",
|
||||
"--icon",
|
||||
file("src/main/resources/icon.png").absolutePath,
|
||||
"--dest",
|
||||
destDir.absolutePath,
|
||||
"--java-options",
|
||||
"-Xmx1g",
|
||||
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
|
||||
"--java-options",
|
||||
"--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
|
||||
"--java-options",
|
||||
"--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import iris.IrisApp
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.DesktopBridge
|
||||
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.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
@@ -28,10 +32,7 @@ import java.awt.event.WindowFocusListener
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
/**
|
||||
* M6: desktop shell (docs/11 §11.2/§11.3).
|
||||
@@ -45,6 +46,11 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||
*/
|
||||
private val windowJson = File(System.getProperty("user.home"), ".iris/window.json")
|
||||
|
||||
// Window/taskbar icon (src/main/resources/icon.png).
|
||||
private class IrisDesktop
|
||||
|
||||
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
||||
|
||||
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
||||
private const val TRAY_OFFLINE = 0xFF9E9E9E.toInt()
|
||||
private const val TRAY_CONNECTING = 0xFFFFC107.toInt()
|
||||
@@ -82,15 +88,37 @@ fun main() {
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
val state = DesktopBridge.controller?.client?.state?.value
|
||||
val (color, tooltip) = when (state) {
|
||||
null,
|
||||
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"
|
||||
}
|
||||
val state =
|
||||
DesktopBridge.controller
|
||||
?.client
|
||||
?.state
|
||||
?.value
|
||||
val (color, tooltip) =
|
||||
when (state) {
|
||||
null,
|
||||
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
|
||||
trayTooltip = tooltip
|
||||
}
|
||||
@@ -107,6 +135,7 @@ fun main() {
|
||||
}
|
||||
|
||||
Window(
|
||||
icon = BitmapPainter(windowIcon),
|
||||
onCloseRequest = {
|
||||
if (closeToTray.get()) {
|
||||
composeWindow?.isVisible = false
|
||||
@@ -120,15 +149,17 @@ fun main() {
|
||||
state = loadWindowState(),
|
||||
) {
|
||||
composeWindow = window
|
||||
window.addWindowFocusListener(object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = true
|
||||
}
|
||||
window.addWindowFocusListener(
|
||||
object : WindowFocusListener {
|
||||
override fun windowGainedFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = true
|
||||
}
|
||||
|
||||
override fun windowLostFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = false
|
||||
}
|
||||
})
|
||||
override fun windowLostFocus(e: WindowEvent) {
|
||||
DesktopBridge.foreground = false
|
||||
}
|
||||
},
|
||||
)
|
||||
IrisApp(store)
|
||||
}
|
||||
}
|
||||
@@ -181,4 +212,4 @@ private fun saveWindowState(window: ComposeWindow?) {
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 29 KiB |
@@ -1,4 +1,13 @@
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
# Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is
|
||||
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
|
||||
# unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget).
|
||||
#
|
||||
# The JDK-21 home is machine-specific, so it is NOT hardcoded here (a
|
||||
# committed path would break every other checkout). Set it per machine via
|
||||
# one of:
|
||||
# - ~/.gradle/gradle.properties -> org.gradle.java.home=/path/to/jdk21
|
||||
# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#262B3A"/>
|
||||
<stop offset="1" stop-color="#14171F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bubble" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#6E93FF"/>
|
||||
<stop offset="1" stop-color="#3D63E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wing" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#E8EAF0"/>
|
||||
<stop offset="1" stop-color="#7E9BFF"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<clipPath id="bgclip">
|
||||
<rect x="16" y="16" width="480" height="480" rx="112"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
|
||||
<g clip-path="url(#bgclip)">
|
||||
<circle cx="312" cy="240" r="190" fill="url(#glow)"/>
|
||||
</g>
|
||||
|
||||
<g fill="none" stroke="url(#wing)" stroke-width="26" stroke-linecap="round">
|
||||
<path d="M 172 192 Q 112 182 62 204"/>
|
||||
<path d="M 168 236 Q 100 232 52 260"/>
|
||||
<path d="M 172 280 Q 116 288 76 320"/>
|
||||
</g>
|
||||
|
||||
<path d="M 222 336 C 208 360 192 380 172 396 C 202 388 230 370 256 340 Z" fill="#3D63E8"/>
|
||||
|
||||
<rect x="176" y="120" width="272" height="224" rx="72" fill="url(#bubble)"/>
|
||||
|
||||
<g fill="none" stroke-width="12" stroke-linecap="round">
|
||||
<path d="M 228 244 A 84 84 0 0 1 396 244" stroke="#FF6B6B"/>
|
||||
<path d="M 244 244 A 68 68 0 0 1 380 244" stroke="#FFC107"/>
|
||||
<path d="M 260 244 A 52 52 0 0 1 364 244" stroke="#4CAF50"/>
|
||||
<path d="M 276 244 A 36 36 0 0 1 348 244" stroke="#29B6F6"/>
|
||||
</g>
|
||||
<circle cx="312" cy="244" r="11" fill="#7C4DFF"/>
|
||||
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,62 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#262B3A"/>
|
||||
<stop offset="1" stop-color="#14171F"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="wingL" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#E8EAF0"/>
|
||||
<stop offset="1" stop-color="#7E9BFF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wingR" x1="1" y1="0" x2="0" y2="0">
|
||||
<stop offset="0" stop-color="#E8EAF0"/>
|
||||
<stop offset="1" stop-color="#7E9BFF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="bgclip">
|
||||
<rect x="16" y="16" width="480" height="480" rx="112"/>
|
||||
</clipPath>
|
||||
<clipPath id="eye">
|
||||
<path d="M 96 272 Q 256 148 416 272 Q 256 396 96 272 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
|
||||
<g clip-path="url(#bgclip)">
|
||||
<circle cx="256" cy="272" r="200" fill="url(#glow)"/>
|
||||
</g>
|
||||
|
||||
<g fill="none" stroke-width="14" stroke-linecap="round">
|
||||
<g stroke="url(#wingL)">
|
||||
<path d="M 88 248 Q 56 236 32 244"/>
|
||||
<path d="M 84 272 Q 52 268 28 280"/>
|
||||
<path d="M 88 296 Q 56 300 36 314"/>
|
||||
</g>
|
||||
<g stroke="url(#wingR)">
|
||||
<path d="M 424 248 Q 456 236 480 244"/>
|
||||
<path d="M 428 272 Q 460 268 484 280"/>
|
||||
<path d="M 424 296 Q 456 300 476 314"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g clip-path="url(#eye)">
|
||||
<path d="M 256 272 L 256 184 A 88 88 0 0 1 332 228 Z" fill="#7C4DFF"/>
|
||||
<path d="M 256 272 L 332 228 A 88 88 0 0 1 332 316 Z" fill="#4F7CFF"/>
|
||||
<path d="M 256 272 L 332 316 A 88 88 0 0 1 256 360 Z" fill="#29B6F6"/>
|
||||
<path d="M 256 272 L 256 360 A 88 88 0 0 1 180 316 Z" fill="#4CAF50"/>
|
||||
<path d="M 256 272 L 180 316 A 88 88 0 0 1 180 228 Z" fill="#FFC107"/>
|
||||
<path d="M 256 272 L 180 228 A 88 88 0 0 1 256 184 Z" fill="#FF6B6B"/>
|
||||
<circle cx="256" cy="272" r="38" fill="#10131C"/>
|
||||
<circle cx="272" cy="254" r="10" fill="#FFFFFF" opacity="0.9"/>
|
||||
<circle cx="242" cy="290" r="5" fill="#FFFFFF" opacity="0.35"/>
|
||||
</g>
|
||||
|
||||
<path d="M 96 272 Q 256 148 416 272 Q 256 396 96 272 Z" fill="none" stroke="#E8EAF0" stroke-width="12" stroke-linejoin="round"/>
|
||||
|
||||
<path d="M 262 372 L 238 414 L 254 412 L 244 444 L 274 398 L 257 400 Z" fill="#FFC107"/>
|
||||
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,40 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#262B3A"/>
|
||||
<stop offset="1" stop-color="#14171F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="planeTop" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#8FB0FF"/>
|
||||
<stop offset="1" stop-color="#5B86FF"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<clipPath id="bgclip">
|
||||
<rect x="16" y="16" width="480" height="480" rx="112"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#bg)"/>
|
||||
<g clip-path="url(#bgclip)">
|
||||
<circle cx="320" cy="220" r="190" fill="url(#glow)"/>
|
||||
</g>
|
||||
|
||||
<g fill="none" stroke-width="16" stroke-linecap="round">
|
||||
<path d="M 60 368 Q 172 348 258 284" stroke="#FF6B6B"/>
|
||||
<path d="M 84 396 Q 196 376 284 312" stroke="#FFC107"/>
|
||||
<path d="M 108 424 Q 220 404 308 340" stroke="#4CAF50"/>
|
||||
<path d="M 132 452 Q 244 432 332 368" stroke="#29B6F6"/>
|
||||
</g>
|
||||
|
||||
<path d="M 396 116 L 216 212 L 296 258 Z" fill="url(#planeTop)"/>
|
||||
<path d="M 396 116 L 296 258 L 316 326 Z" fill="#4F7CFF"/>
|
||||
<path d="M 216 212 L 296 258 L 316 326 Z" fill="#2A3550"/>
|
||||
|
||||
<path d="M 436 78 L 441 89 L 452 94 L 441 99 L 436 110 L 431 99 L 420 94 L 431 89 Z" fill="#E8EAF0" opacity="0.9"/>
|
||||
<path d="M 458 143 L 461 149 L 467 152 L 461 155 L 458 161 L 455 155 L 449 152 L 455 149 Z" fill="#E8EAF0" opacity="0.6"/>
|
||||
|
||||
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="#2A2E3B" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 193 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#262B3A"/>
|
||||
<stop offset="1" stop-color="#14171F"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bubble" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#6E93FF"/>
|
||||
<stop offset="1" stop-color="#3D63E8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="wing" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#E8EAF0"/>
|
||||
<stop offset="1" stop-color="#7E9BFF"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#4F7CFF" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#4F7CFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="512" height="512" fill="url(#bg)"/>
|
||||
<circle cx="308.92" cy="240.64" r="162.17" fill="url(#glow)"/>
|
||||
|
||||
<g fill="none" stroke="url(#wing)" stroke-width="22.19" stroke-linecap="round">
|
||||
<path d="M 189.42 199.67 Q 138.21 191.13 95.53 209.91"/>
|
||||
<path d="M 186.01 237.22 Q 127.97 233.81 87.01 257.71"/>
|
||||
<path d="M 189.42 274.78 Q 141.63 281.61 107.48 308.92"/>
|
||||
</g>
|
||||
|
||||
<path d="M 232.10 322.58 C 220.15 343.06 206.49 360.13 189.42 373.79 C 215.03 366.96 238.93 351.60 261.12 325.99 Z" fill="#3D63E8"/>
|
||||
|
||||
<rect x="192.84" y="138.21" width="232.16" height="191.19" rx="61.45" fill="url(#bubble)"/>
|
||||
|
||||
<g fill="none" stroke-width="10.24" stroke-linecap="round">
|
||||
<path d="M 237.22 244.05 A 71.70 71.70 0 0 1 380.62 244.05" stroke="#FF6B6B"/>
|
||||
<path d="M 250.88 244.05 A 58.04 58.04 0 0 1 366.96 244.05" stroke="#FFC107"/>
|
||||
<path d="M 264.54 244.05 A 44.38 44.38 0 0 1 353.30 244.05" stroke="#4CAF50"/>
|
||||
<path d="M 278.19 244.05 A 30.73 30.73 0 0 1 339.65 244.05" stroke="#29B6F6"/>
|
||||
</g>
|
||||
<circle cx="308.92" cy="244.05" r="9.39" fill="#7C4DFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -10,6 +10,8 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
// KCEF (desktop WebView backend) pulls jogamp artifacts from here.
|
||||
maven("https://jogamp.org/deployment/maven")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,192 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
kotlin("multiplatform")
|
||||
kotlin("plugin.serialization")
|
||||
id("com.android.library")
|
||||
id("com.android.kotlin.multiplatform.library")
|
||||
id("org.jetbrains.compose")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
|
||||
id("app.cash.sqldelight")
|
||||
}
|
||||
|
||||
val composeVersion = "1.7.3"
|
||||
val coroutinesVersion = "1.9.0"
|
||||
val serializationVersion = "1.7.3"
|
||||
val okhttpVersion = "4.12.0"
|
||||
val composeVersion = "1.11.1"
|
||||
// material3 and material-icons-extended are published on their own cadence and
|
||||
// lag the core artifacts; use each one's latest stable release.
|
||||
val material3Version = "1.9.0"
|
||||
val materialIconsVersion = "1.7.3"
|
||||
val coroutinesVersion = "1.11.0"
|
||||
val serializationVersion = "1.11.0"
|
||||
val okhttpVersion = "5.5.0"
|
||||
// 2.0.x is the first line built against Compose 1.8+ (1.9.40 was the last
|
||||
// 1.7.x-compatible release). HTML artifact previews (M9): Android uses the
|
||||
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
|
||||
val webviewVersion = "2.0.3"
|
||||
val kcefVersion = "2025.03.23"
|
||||
// 0.34.0+ moved to Compose 1.8+ (0.33.0 was the last 1.7.x / Kotlin 2.1.x
|
||||
// release). Provides GFM tables, bold/italic/underscore, and the -code module
|
||||
// for language-aware syntax highlighting (Highlights).
|
||||
val markdownVersion = "0.44.0"
|
||||
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
|
||||
val sqldelightVersion = "2.3.2"
|
||||
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
|
||||
val cameraxVersion = "1.5.1"
|
||||
val mlKitVersion = "16.1.1"
|
||||
|
||||
// ── 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 {
|
||||
androidTarget()
|
||||
jvm("desktop")
|
||||
android {
|
||||
namespace = "iris.shared"
|
||||
compileSdk = 37
|
||||
minSdk = 29
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
withHostTest { }
|
||||
}
|
||||
jvm("desktop") {
|
||||
// Desktop runs on the Java 21 runtime (see gradle.properties
|
||||
// org.gradle.java.home); target 21 so the Markdown renderer 0.44.0
|
||||
// (Java-21 bytecode) loads. Android keeps its JVM 17 target above.
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
|
||||
val jvmMain by creating { dependsOn(commonMain.get()) }
|
||||
val androidMain by getting { dependsOn(jvmMain) }
|
||||
val desktopMain by getting { dependsOn(jvmMain) }
|
||||
// Host tests shared by the android (withHostTest) and desktop targets
|
||||
// (e.g. the SQLDelight cache DB tests, which need the JDBC driver).
|
||||
val jvmTest by creating { dependsOn(commonTest.get()) }
|
||||
val androidHostTest by getting { dependsOn(jvmTest) }
|
||||
val desktopTest by getting { dependsOn(jvmTest) }
|
||||
|
||||
commonMain.dependencies {
|
||||
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
|
||||
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
||||
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
|
||||
implementation("org.jetbrains.compose.material3:material3:$material3Version")
|
||||
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
|
||||
// Full Material icon set (Icons.Filled.ContentCopy for the code
|
||||
// copy buttons); -core only ships a small subset.
|
||||
implementation("org.jetbrains.compose.material:material-icons-extended:$materialIconsVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
|
||||
// OkHttp is a JVM library; both targets here are JVM-based
|
||||
// (androidTarget + jvm("desktop")), so it resolves on both.
|
||||
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
|
||||
// Markdown rendering for chat bubbles (M8): core parser/renderer,
|
||||
// Material 3 themed defaults, and the -code module for syntax
|
||||
// highlighting of fenced code blocks (Highlights).
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion")
|
||||
implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion")
|
||||
// M9: HTML artifact previews — WebView composable (platform WebView
|
||||
// on Android, KCEF/JCEF on desktop).
|
||||
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
|
||||
// Local cache DB runtime (generated code from commonMain/sqldelight).
|
||||
implementation("app.cash.sqldelight:runtime:$sqldelightVersion")
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
// JDBC SQLite driver: in-memory DB for the cache tests.
|
||||
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
|
||||
}
|
||||
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
|
||||
// webview library exposes it transitively, but we reference KCEF
|
||||
// directly (init + progress) so declare it explicitly.
|
||||
desktopMain.dependencies {
|
||||
implementation("dev.datlag:kcef:$kcefVersion")
|
||||
// Local cache DB: JDBC SQLite driver (file in ~/.iris).
|
||||
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
|
||||
}
|
||||
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||
androidMain.dependencies {
|
||||
// M7: EncryptedSharedPreferences for the pairing token
|
||||
// (docs/09 §9.7; AndroidSecureStore).
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.media3:media3-exoplayer:1.3.1")
|
||||
implementation("androidx.media3:media3-ui:1.3.1")
|
||||
implementation("androidx.media3:media3-exoplayer:1.11.0")
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
// SAF picker (rememberLauncherForActivityResult).
|
||||
implementation("androidx.activity:activity-compose:1.9.2")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
|
||||
// uses the camera2 CameraX backend and ML Kit's barcode model.
|
||||
implementation("androidx.camera:camera-core:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-view:$cameraxVersion")
|
||||
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
|
||||
// M5: FCM push (inert without a Firebase project / google-services.json;
|
||||
// the ntfy listener is the fallback). The google-services plugin is
|
||||
// applied conditionally in the app module.
|
||||
implementation("com.google.firebase:firebase-messaging:23.1.2")
|
||||
implementation("com.google.firebase:firebase-messaging:25.1.2")
|
||||
// Local cache DB: Android SQLite driver (app database dir).
|
||||
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "iris.shared"
|
||||
compileSdk = 34
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
sqldelight {
|
||||
databases {
|
||||
create("IrisDatabase") {
|
||||
packageName.set("iris.db")
|
||||
}
|
||||
}
|
||||
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.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem as M3MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import iris.data.MediaItem
|
||||
import iris.media.extForMime
|
||||
import iris.protocol.KIND_VIDEO
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import androidx.media3.common.MediaItem as M3MediaItem
|
||||
|
||||
/** App context holder (set in MainActivity.onCreate). */
|
||||
object AndroidEnv {
|
||||
@@ -59,30 +59,60 @@ object AndroidEnv {
|
||||
@Composable
|
||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
onPicked(null)
|
||||
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) }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
onPicked(null)
|
||||
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)
|
||||
}
|
||||
onPicked(PickedFile(target.absolutePath, name, mime, target.length()))
|
||||
} catch (e: Exception) {
|
||||
onPicked(null)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Button(onClick = { launcher.launch("*/*") }) { Text("Pick a file") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryDisplayName(context: Context, uri: Uri): String? {
|
||||
@Composable
|
||||
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
onPicked(null)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
|
||||
}
|
||||
|
||||
private fun queryDisplayName(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
): String? {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { c ->
|
||||
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (idx >= 0 && c.moveToFirst()) return c.getString(idx)
|
||||
@@ -94,16 +124,99 @@ actual fun mediaCacheBaseDir(): String =
|
||||
AndroidEnv.context.externalCacheDir?.absolutePath
|
||||
?: AndroidEnv.context.cacheDir.absolutePath
|
||||
|
||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
BitmapFactory.decodeFile(path)?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadScaledImage(
|
||||
path: String,
|
||||
maxSize: Int,
|
||||
): ImageBitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
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 {
|
||||
BitmapFactory.decodeFile(path)?.asImageBitmap()
|
||||
AndroidEnv.context.assets
|
||||
.open("backdrops/$id.jpg")
|
||||
.use { it.readBytes() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun encodeImageAsBase64(
|
||||
path: String,
|
||||
maxSize: Int,
|
||||
): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
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 }
|
||||
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
|
||||
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||
actual fun MediaPlayerView(
|
||||
media: MediaItem,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val path = media.localPath
|
||||
if (path == null) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
@@ -123,7 +236,10 @@ actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoPlayerView(path: String, modifier: Modifier) {
|
||||
private fun VideoPlayerView(
|
||||
path: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
@@ -137,21 +253,27 @@ private fun VideoPlayerView(path: String, modifier: Modifier) {
|
||||
},
|
||||
update = {},
|
||||
onRelease = { view -> view.player?.release() },
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) {
|
||||
private fun AudioPlayerView(
|
||||
path: String,
|
||||
filename: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val player = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(M3MediaItem.fromUri(path.toUri()))
|
||||
prepare()
|
||||
val player =
|
||||
remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(M3MediaItem.fromUri(path.toUri()))
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(player) { onDispose { player.release() } }
|
||||
|
||||
var playing by remember { mutableStateOf(false) }
|
||||
@@ -169,9 +291,10 @@ private fun AudioPlayerView(path: String, filename: String, modifier: Modifier)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
@@ -202,17 +325,21 @@ private fun fmt(seconds: Float): String {
|
||||
return "%d:%02d".format(s / 60, s % 60)
|
||||
}
|
||||
|
||||
actual fun openDocument(path: String, mime: String) {
|
||||
actual fun openDocument(
|
||||
path: String,
|
||||
mime: String,
|
||||
) {
|
||||
val context = AndroidEnv.context
|
||||
try {
|
||||
val file = File(path)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mime)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mime)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, "No app to open ${mime}", Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, "No app to open $mime", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package iris.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import iris.state.IrisController
|
||||
@@ -11,6 +13,33 @@ actual fun setActiveController(controller: Any?) {
|
||||
AppBridge.controller = controller as? IrisController
|
||||
}
|
||||
|
||||
actual fun syncNtfyListener(backend: String) {
|
||||
val context = AndroidEnv.context
|
||||
val intent = Intent(context, NtfyListenerService::class.java)
|
||||
// L-18: reuse one AndroidSecureStore instead of rebuilding it (and
|
||||
// re-running EncryptedSharedPreferences.create + migration) on every call.
|
||||
val store = ntfyStore(context)
|
||||
if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} else {
|
||||
// FCM (or unknown) backend: no persistent listener, no permanent
|
||||
// notification. stopService is a no-op when it isn't running.
|
||||
context.stopService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private val ntfyStoreLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cachedNtfyStore: AndroidSecureStore? = null
|
||||
|
||||
private fun ntfyStore(context: Context): AndroidSecureStore {
|
||||
cachedNtfyStore?.let { return it }
|
||||
return synchronized(ntfyStoreLock) {
|
||||
cachedNtfyStore ?: AndroidSecureStore(context).also { cachedNtfyStore = it }
|
||||
}
|
||||
}
|
||||
|
||||
actual fun postSystemNotification(
|
||||
chatId: String?,
|
||||
chatName: String?,
|
||||
@@ -19,7 +48,7 @@ actual fun postSystemNotification(
|
||||
threadId: String?,
|
||||
) {
|
||||
val context = AndroidEnv.context
|
||||
val id = chatId ?: "android:default"
|
||||
val id = chatId ?: "default"
|
||||
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
@@ -27,4 +56,4 @@ actual fun postSystemNotification(
|
||||
return
|
||||
}
|
||||
IrisNotifications.post(context, id, chatName, title, body, threadId)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import android.os.Build
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import iris.data.SecureStore
|
||||
import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -14,18 +18,22 @@ import java.util.UUID
|
||||
* values are migrated on first run (read old key, write encrypted, delete
|
||||
* old key) so an upgrade never loses the pairing.
|
||||
*/
|
||||
class AndroidSecureStore(context: Context) : SecureStore {
|
||||
class AndroidSecureStore(
|
||||
context: Context,
|
||||
) : SecureStore {
|
||||
private val appContext = context.applicationContext
|
||||
private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
SECURE_PREFS_NAME,
|
||||
MasterKey.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
private val prefs: SharedPreferences =
|
||||
EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
SECURE_PREFS_NAME,
|
||||
MasterKey
|
||||
.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
init {
|
||||
migratePlainValues()
|
||||
@@ -49,7 +57,8 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
}
|
||||
if (migrated) editor.apply()
|
||||
// The plain store must not keep a copy of any value.
|
||||
plainPrefs.edit()
|
||||
plainPrefs
|
||||
.edit()
|
||||
.remove(KEY_URL)
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_DEVICE_ID)
|
||||
@@ -68,11 +77,24 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
|
||||
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
|
||||
get() {
|
||||
var id = prefs.getString(KEY_DEVICE_ID, null)
|
||||
if (id.isNullOrBlank()) {
|
||||
id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
|
||||
id = "dev_" +
|
||||
UUID
|
||||
.randomUUID()
|
||||
.toString()
|
||||
.replace("-", "")
|
||||
.take(16)
|
||||
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
|
||||
}
|
||||
return id
|
||||
@@ -97,13 +119,99 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
|
||||
|
||||
override fun savePairing(url: String, token: String) {
|
||||
override var pushBackend: String
|
||||
get() = prefs.getString(KEY_PUSH_BACKEND, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_PUSH_BACKEND, value).apply()
|
||||
|
||||
override var threadsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
|
||||
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
|
||||
|
||||
override var toolDetail: String
|
||||
get() = prefs.getString(KEY_TOOL_DETAIL, "truncated").orEmpty().ifBlank { "truncated" }
|
||||
set(value) = prefs.edit().putString(KEY_TOOL_DETAIL, value).apply()
|
||||
|
||||
override var streamingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
||||
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
|
||||
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
||||
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
|
||||
|
||||
override var userBubbleColor: Int
|
||||
get() = prefs.getInt(KEY_USER_BUBBLE_COLOR, UserTheme.DEFAULT_USER_BUBBLE)
|
||||
set(value) = prefs.edit().putInt(KEY_USER_BUBBLE_COLOR, value).apply()
|
||||
|
||||
override var agentBubbleColor: Int
|
||||
get() = prefs.getInt(KEY_AGENT_BUBBLE_COLOR, UserTheme.DEFAULT_AGENT_BUBBLE)
|
||||
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
|
||||
|
||||
override var backgroundMode: String
|
||||
get() =
|
||||
prefs
|
||||
.getString(KEY_BACKGROUND_MODE, BackgroundMode.Image.name.lowercase())
|
||||
.orEmpty()
|
||||
.ifBlank { BackgroundMode.Image.name.lowercase() }
|
||||
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
|
||||
|
||||
override var backgroundColor: Int
|
||||
get() = prefs.getInt(KEY_BACKGROUND_COLOR, UserTheme.DEFAULT_BACKGROUND)
|
||||
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
|
||||
|
||||
override var backgroundImagePath: String
|
||||
get() =
|
||||
prefs
|
||||
.getString(KEY_BACKGROUND_IMAGE_PATH, Backdrop.DEFAULT.path)
|
||||
.orEmpty()
|
||||
.ifBlank { Backdrop.DEFAULT.path }
|
||||
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
|
||||
|
||||
override var fontSizeScale: Float
|
||||
get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f)
|
||||
set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply()
|
||||
|
||||
override var runtimeFooterEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_RUNTIME_FOOTER_ENABLED, false)
|
||||
set(value) = prefs.edit().putBoolean(KEY_RUNTIME_FOOTER_ENABLED, value).apply()
|
||||
|
||||
override var runtimeFooterFields: String
|
||||
get() = prefs.getString(KEY_RUNTIME_FOOTER_FIELDS, "").orEmpty()
|
||||
set(value) = prefs.edit().putString(KEY_RUNTIME_FOOTER_FIELDS, value).apply()
|
||||
|
||||
override fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
) {
|
||||
serverUrl = url
|
||||
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() {
|
||||
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
|
||||
// M-10: clearing pairing must also wipe the device identity + push
|
||||
// state, otherwise a re-pair to a different gateway would keep the old
|
||||
// deviceId/syncCursor/ntfyTopic and the server would treat the new
|
||||
// pairing as the same device.
|
||||
prefs
|
||||
.edit()
|
||||
.remove(KEY_URL)
|
||||
.remove(KEY_TOKEN)
|
||||
.remove(KEY_DEVICE_TOKEN)
|
||||
.remove(KEY_DEVICE_ID)
|
||||
.remove(KEY_SYNC_CURSOR)
|
||||
.remove(KEY_FCM_TOKEN)
|
||||
.remove(KEY_NTFY_TOPIC)
|
||||
.remove(KEY_NTFY_SERVER)
|
||||
.remove(KEY_PUSH_BACKEND)
|
||||
.remove(KEY_PINNED_CERT)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@@ -111,10 +219,26 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
const val SECURE_PREFS_NAME = "iris_secure"
|
||||
const val KEY_URL = "server_url"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_DEVICE_TOKEN = "device_token"
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_SYNC_CURSOR = "sync_cursor"
|
||||
const val KEY_FCM_TOKEN = "fcm_token"
|
||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||
const val KEY_PUSH_BACKEND = "push_backend"
|
||||
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
|
||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||
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_USER_BUBBLE_COLOR = "user_bubble_color"
|
||||
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
|
||||
const val KEY_BACKGROUND_MODE = "background_mode"
|
||||
const val KEY_BACKGROUND_COLOR = "background_color"
|
||||
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
|
||||
const val KEY_FONT_SIZE_SCALE = "font_size_scale"
|
||||
const val KEY_RUNTIME_FOOTER_ENABLED = "runtime_footer_enabled"
|
||||
const val KEY_RUNTIME_FOOTER_FIELDS = "runtime_footer_fields"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package iris.platform
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
||||
import iris.db.IrisDatabase
|
||||
|
||||
actual fun appDataDir(): String = AndroidEnv.context.filesDir.absolutePath
|
||||
|
||||
// The schema-aware driver creates the schema on first run and applies
|
||||
// pending .sqm migrations on existing DBs (e.g. v1 -> v2: the `tool`
|
||||
// table), stamping PRAGMA user_version along the way.
|
||||
actual fun createCacheDriver(): SqlDriver = AndroidSqliteDriver(IrisDatabase.Schema, AndroidEnv.context, "iris_cache.db")
|
||||
@@ -0,0 +1,53 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.multiplatform.webview.web.WebView
|
||||
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
||||
|
||||
/**
|
||||
* M9: HTML artifact preview on Android — the platform WebView.
|
||||
*
|
||||
* JavaScript is on by default in the library's WebSettings, but interactive
|
||||
* artifacts also need DOM storage (localStorage/sessionStorage), which the
|
||||
* library leaves disabled and which additionally requires the document to sit
|
||||
* on a real origin. Loading with a null base URL puts the page on the opaque
|
||||
* `about:blank` origin, where the storage APIs are inaccessible, so we pin a
|
||||
* real (unresolvable) base URL and switch DOM storage on.
|
||||
*/
|
||||
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
|
||||
|
||||
@Composable
|
||||
actual fun PlatformWebView(
|
||||
html: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
|
||||
state.webSettings.androidWebSettings.domStorageEnabled = true
|
||||
WebView(state, modifier = modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
|
||||
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||
val currentOnBack = rememberUpdatedState(onBack)
|
||||
DisposableEffect(dispatcher) {
|
||||
val callback =
|
||||
dispatcher?.let { d ->
|
||||
val c =
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
currentOnBack.value()
|
||||
}
|
||||
}
|
||||
d.addCallback(c)
|
||||
c
|
||||
}
|
||||
onDispose { callback?.remove() }
|
||||
}
|
||||
return this
|
||||
}
|
||||
@@ -14,7 +14,14 @@ object AppBridge {
|
||||
@Volatile
|
||||
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
|
||||
var foreground: Boolean = false
|
||||
}
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
controller?.setForeground(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,39 +4,59 @@ import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import iris.net.GatewayClient
|
||||
|
||||
/**
|
||||
* M5: FCM handler (docs/08 §8.1).
|
||||
*
|
||||
* - [onNewToken]: persist the rotated token and push it to the server via
|
||||
* `fcm.register` (so the next push targets the current token).
|
||||
* - [onMessageReceived]: the data payload drives a silent sync. When the app
|
||||
* is foregrounded the WS path already delivered the frame (in-app banner),
|
||||
* so we only post a system notification when backgrounded.
|
||||
* - [onMessageReceived]: posts a system notification from the data payload.
|
||||
* When the app is foregrounded the SSE path already delivered the frame
|
||||
* (in-app banner), so we only post a notification when backgrounded.
|
||||
*
|
||||
* Inert without a Firebase project (no google-services.json): the service is
|
||||
* declared in the manifest but never receives messages, and the app falls
|
||||
* back to the ntfy listener.
|
||||
*/
|
||||
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
val store = AndroidSecureStore(applicationContext)
|
||||
store.fcmToken = token
|
||||
// Push the rotation to the server if we're connected.
|
||||
// Push the rotation to the server if we're connected (the ntfy topic
|
||||
// rides along so a wiped registry recovers both push tokens).
|
||||
AppBridge.controller?.client?.sendFrame(
|
||||
iris.protocol.fcmRegisterFrame(fcmToken = token),
|
||||
iris.protocol.fcmRegisterFrame(
|
||||
fcmToken = token,
|
||||
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
// Foreground + live SSE: the in-app banner already showed this.
|
||||
if (AppBridge.foreground) return
|
||||
// Live SSE: the frame arrives over the stream and the controller
|
||||
// mirrors it to a system notification itself — posting here would
|
||||
// duplicate it (docs/08 §8.7).
|
||||
if (AppBridge.controller
|
||||
?.client
|
||||
?.state
|
||||
?.value is GatewayClient.State.Connected
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Backgrounded/killed: FCM already displayed the `notification`
|
||||
// payload on our behalf (the data payload only carries sync
|
||||
// metadata). Posting again would show a second notification with a
|
||||
// different id. Data-only messages (no notification payload) are the
|
||||
// exception: the app must display them itself.
|
||||
if (message.notification != null) return
|
||||
val data = message.data
|
||||
val chatId = data["chat_id"] ?: "android:default"
|
||||
val chatId = data["chat_id"] ?: "default"
|
||||
val threadId = data["thread_id"]
|
||||
val title = data["title"] ?: "Iris"
|
||||
val body = data["body"] ?: data["title"].orEmpty()
|
||||
// Foreground + live WS: the in-app banner already showed this.
|
||||
if (AppBridge.foreground) return
|
||||
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@@ -44,4 +64,4 @@ class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
IrisNotifications.post(applicationContext, chatId, null, title, body, threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
@@ -21,15 +20,22 @@ object IrisNotifications {
|
||||
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
|
||||
private const val NOTIF_ID_BASE = 1_000_000
|
||||
|
||||
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
// L-31: notification channel ids are capped at 64 chars (Android limit) and
|
||||
// are user-visible, so a long server-provided chatId must be truncated.
|
||||
private fun channelIdFor(chatId: String): String = (CHANNEL_PREFIX + chatId).take(64)
|
||||
|
||||
fun ensureChannel(
|
||||
context: Context,
|
||||
chatId: String,
|
||||
chatName: String? = null,
|
||||
) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val id = CHANNEL_PREFIX + chatId
|
||||
val id = channelIdFor(chatId)
|
||||
val name = chatName ?: chatId
|
||||
if (nm.getNotificationChannel(id) == null) {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||
.apply { description = "Iris messages for $name" }
|
||||
.apply { description = "Iris messages for $name" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -43,23 +49,28 @@ object IrisNotifications {
|
||||
threadId: String?,
|
||||
) {
|
||||
ensureChannel(context, chatId, chatName)
|
||||
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
|
||||
val intent = Intent(ACTION_OPEN_CHAT).apply {
|
||||
setPackage(context.packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra("chat_id", chatId)
|
||||
if (threadId != null) putExtra("thread_id", threadId)
|
||||
}
|
||||
val pi = PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
// L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
|
||||
// to the same notification id and clobber each other.
|
||||
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff)
|
||||
val intent =
|
||||
Intent(ACTION_OPEN_CHAT).apply {
|
||||
setPackage(context.packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra("chat_id", chatId)
|
||||
if (threadId != null) putExtra("thread_id", threadId)
|
||||
}
|
||||
val pi =
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(
|
||||
id,
|
||||
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
|
||||
NotificationCompat
|
||||
.Builder(context, channelIdFor(chatId))
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
@@ -69,4 +80,4 @@ object IrisNotifications {
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,13 @@ import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import iris.protocol.IrisJson
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -28,18 +30,31 @@ import java.util.concurrent.TimeUnit
|
||||
*
|
||||
* A foreground service that subscribes to this device's ntfy topic and posts
|
||||
* a system notification for each push. The structured payload rides in the
|
||||
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
|
||||
* is the short preview. When the app is foregrounded the WS path already
|
||||
* delivered the frame, so the service skips posting to avoid a duplicate.
|
||||
* `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
|
||||
* body is the short preview. When the app is foregrounded the SSE path
|
||||
* already delivered the frame, so the service skips posting to avoid a
|
||||
* duplicate.
|
||||
*
|
||||
* The stream is reconnected with capped exponential backoff when it drops
|
||||
* (EOF, network error, or a non-2xx response) — `START_STICKY` alone only
|
||||
* restarts the service after process death, not after a failed read.
|
||||
*/
|
||||
class NtfyListenerService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var streamJob: Job? = null
|
||||
private val client = OkHttpClient.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
|
||||
.build()
|
||||
private val client =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
// ntfy sends keep-alive comments every ~10 s; a 60 s read timeout
|
||||
// detects a half-open connection instead of hanging forever.
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
startForeground(NOTIF_ID, foregroundNotification())
|
||||
streamJob?.cancel()
|
||||
streamJob = scope.launch { stream() }
|
||||
@@ -60,47 +75,79 @@ class NtfyListenerService : Service() {
|
||||
if (topic.isBlank()) return
|
||||
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
|
||||
val url = "$server/$topic"
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) return
|
||||
val body = resp.body ?: return
|
||||
val source = body.source()
|
||||
var data: String? = null
|
||||
var title: String? = null
|
||||
var msgBody: String? = null
|
||||
while (!source.exhausted()) {
|
||||
val line = source.readUtf8Line() ?: break
|
||||
when {
|
||||
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
|
||||
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
|
||||
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
|
||||
line.isEmpty() -> {
|
||||
// Event boundary: process the accumulated message.
|
||||
data?.let { handleData(it, title, msgBody) }
|
||||
data = null
|
||||
title = null
|
||||
msgBody = null
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.build()
|
||||
var backoff = 1_000L
|
||||
while (true) {
|
||||
try {
|
||||
client.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) {
|
||||
IrisLog.w("ntfy stream HTTP ${resp.code}")
|
||||
} else {
|
||||
val body = resp.body ?: return
|
||||
readEvents(body.source())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.w("ntfy stream dropped: ${e.message}")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Stream dropped; the service is START_STICKY so the system
|
||||
// restarts it. If it keeps failing, the WS path still works.
|
||||
// Stream ended (EOF, error, or non-2xx): back off and reconnect.
|
||||
delay(backoff)
|
||||
backoff = (backoff * 2).coerceAtMost(30_000L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
|
||||
val data = try {
|
||||
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
/**
|
||||
* Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF;
|
||||
* do NOT use `source.exhausted()` here — it reads until EOF and would
|
||||
* block forever on a live stream.
|
||||
*/
|
||||
private fun readEvents(source: okio.BufferedSource) {
|
||||
var data: String? = null
|
||||
var 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")
|
||||
// The short preview rides in the SSE `data:` field; fall back to the
|
||||
// X-Title, then a generic label.
|
||||
@@ -126,11 +173,12 @@ class NtfyListenerService : Service() {
|
||||
LISTENER_CHANNEL,
|
||||
"Iris push listener",
|
||||
NotificationManager.IMPORTANCE_MIN,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
|
||||
return NotificationCompat
|
||||
.Builder(context, LISTENER_CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Iris")
|
||||
.setContentText("Listening for messages")
|
||||
@@ -146,5 +194,4 @@ class NtfyListenerService : Service() {
|
||||
}
|
||||
|
||||
/** Read a string field from a JSON object (null when absent / not a string). */
|
||||
private fun JsonObject?.str(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.content
|
||||
private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
|
||||
@@ -1,3 +1,3 @@
|
||||
package iris.platform
|
||||
|
||||
actual val isDesktop: Boolean = false
|
||||
actual val isDesktop: Boolean = false
|
||||
@@ -0,0 +1,49 @@
|
||||
package iris.platform
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
|
||||
* scanned text (or null on cancel) to [onResult].
|
||||
*/
|
||||
@Composable
|
||||
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val text =
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
onResult(text)
|
||||
}
|
||||
Button(onClick = {
|
||||
val activity = context.resolveActivity()
|
||||
if (activity != null) {
|
||||
launcher.launch(Intent(activity, QrScanActivity::class.java))
|
||||
}
|
||||
}) {
|
||||
Text("Scan QR")
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
|
||||
private fun Context.resolveActivity(): Activity? =
|
||||
when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.resolveActivity()
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package iris.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
|
||||
/**
|
||||
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
|
||||
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
|
||||
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
|
||||
*
|
||||
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
|
||||
* stopped in [onDestroy].
|
||||
*/
|
||||
class QrScanActivity : ComponentActivity() {
|
||||
companion object {
|
||||
/** Intent extra carrying the scanned QR text. */
|
||||
const val EXTRA_QR = "qr"
|
||||
}
|
||||
|
||||
private lateinit var previewView: PreviewView
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var settled = false
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) startCamera() else finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
previewView =
|
||||
PreviewView(this).apply {
|
||||
layoutParams =
|
||||
ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
setContentView(previewView)
|
||||
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (!settled) setResult(Activity.RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
startCamera()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCamera() {
|
||||
val future = ProcessCameraProvider.getInstance(this)
|
||||
future.addListener(
|
||||
{
|
||||
val provider = future.get()
|
||||
cameraProvider = provider
|
||||
val preview =
|
||||
Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val analyzer =
|
||||
ImageAnalysis
|
||||
.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
try {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
this,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
analyzer,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// No usable camera (e.g. headless emulator): bail out.
|
||||
finish()
|
||||
return@addListener
|
||||
}
|
||||
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
|
||||
analyzeImage(proxy)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(this),
|
||||
)
|
||||
}
|
||||
|
||||
private fun analyzeImage(proxy: ImageProxy) {
|
||||
val mediaImage = proxy.image
|
||||
if (mediaImage == null) {
|
||||
proxy.close()
|
||||
return
|
||||
}
|
||||
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
|
||||
BarcodeScanning
|
||||
.getClient()
|
||||
.process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
|
||||
if (text != null) finishWithResult(text)
|
||||
}.addOnCompleteListener { proxy.close() }
|
||||
}
|
||||
|
||||
private fun finishWithResult(text: String) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cameraProvider?.unbindAll()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package iris.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier = this
|
||||
@@ -1,37 +1,47 @@
|
||||
package iris
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import iris.data.SecureStore
|
||||
import iris.net.GatewayClient
|
||||
import iris.platform.setActiveController
|
||||
import iris.state.IrisController
|
||||
import iris.ui.ArtifactPreviewOverlay
|
||||
import iris.ui.screens.ChatScreen
|
||||
import iris.ui.screens.ConnectScreen
|
||||
import iris.ui.screens.ConnectingScreen
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.IrisTheme
|
||||
import iris.util.hostFromUrl
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.ui.theme.rememberBackgroundImage
|
||||
import iris.util.PairLink
|
||||
|
||||
/**
|
||||
* Root composable shared by the Android and Desktop shells.
|
||||
*
|
||||
* M1: routes between the Connect screen (unpaired / auth failed) and the
|
||||
* Chat screen (paired). Later milestones add the channel list, search,
|
||||
* settings, and media (docs/10-android-app.md).
|
||||
* Routes between the Connect screen (unpaired / auth failed) and the main
|
||||
* app (paired), which hosts the channel list, chat, search, settings, and
|
||||
* media (docs/10-android-app.md).
|
||||
*/
|
||||
@Composable
|
||||
fun IrisApp(
|
||||
store: SecureStore,
|
||||
deepLinkChatId: String? = null,
|
||||
deepLinkThreadId: String? = null,
|
||||
deepLinkPair: PairLink? = null,
|
||||
) {
|
||||
val controller = remember(store) { IrisController(store) }
|
||||
DisposableEffect(controller) {
|
||||
@@ -49,25 +59,95 @@ fun IrisApp(
|
||||
}
|
||||
}
|
||||
val state by controller.client.state.collectAsState()
|
||||
val theme by controller.theme.collectAsState()
|
||||
val fontScale by controller.fontSizeScale.collectAsState()
|
||||
val baseDensity = LocalDensity.current
|
||||
|
||||
IrisTheme {
|
||||
Surface(modifier = Modifier.background(IrisColors.background)) {
|
||||
val s = state
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected ->
|
||||
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||
is GatewayClient.State.AuthFailed ->
|
||||
ConnectScreen(
|
||||
controller,
|
||||
prefillUrl = store.serverUrl,
|
||||
prefillToken = store.token,
|
||||
initialError = "Pairing rejected: ${s.message}",
|
||||
)
|
||||
// M7: initial connect in flight — dedicated screen, not an empty chat.
|
||||
GatewayClient.State.Connecting ->
|
||||
ConnectingScreen(hostFromUrl(store.serverUrl))
|
||||
else -> ChatScreen(controller)
|
||||
// Font-size setting (Settings → Appearance): a multiplier on top of the
|
||||
// system font scale. fontScale affects sp text only — dp layout is
|
||||
// untouched, so the UI keeps its proportions at any size.
|
||||
CompositionLocalProvider(
|
||||
LocalUserTheme provides theme,
|
||||
LocalDensity provides
|
||||
Density(
|
||||
density = baseDensity.density,
|
||||
fontScale = baseDensity.fontScale * fontScale,
|
||||
),
|
||||
) {
|
||||
// The color goes through Surface's `color` parameter: a
|
||||
// Modifier.background on the Surface would be painted *under* the
|
||||
// Surface's own colorScheme.surface fill and never show through.
|
||||
// contentColor is pinned to the app's light text color — Surface
|
||||
// would otherwise derive it from the user's background (black
|
||||
// text on a light background), which would also darken text that
|
||||
// sits on the app's own dark panels.
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = theme.background,
|
||||
contentColor = IrisColors.onBackground,
|
||||
) {
|
||||
val bgImage = rememberBackgroundImage(theme)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val bmp = bgImage
|
||||
if (bmp != null) {
|
||||
Image(
|
||||
bitmap = bmp,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
val s = state
|
||||
// QR pairing (docs/20): an iris://pair deep link prefills
|
||||
// the Connect screen, taking priority over stored creds.
|
||||
val pairUrl = deepLinkPair?.url ?: store.serverUrl
|
||||
val pairToken = deepLinkPair?.token ?: store.token
|
||||
when (s) {
|
||||
GatewayClient.State.Disconnected -> {
|
||||
key(deepLinkPair) {
|
||||
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
|
||||
}
|
||||
}
|
||||
|
||||
is GatewayClient.State.AuthFailed -> {
|
||||
key(deepLinkPair) {
|
||||
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
|
||||
// status bubble + connection banner show the link state
|
||||
// without blocking the view (M7's full-screen spinner is gone).
|
||||
else -> {
|
||||
ChatScreen(controller)
|
||||
}
|
||||
}
|
||||
// M9: full-screen HTML artifact preview (opened from an
|
||||
// artifact card in the chat); covers everything while open.
|
||||
ArtifactPreviewOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,32 @@ class ChannelStore {
|
||||
_channels.value = sorted(channels)
|
||||
}
|
||||
|
||||
/** Restore the directory from the persistent local cache on startup
|
||||
* (offline: the drawer is populated before the gateway connection is
|
||||
* up). The server re-seeds it on connect (`hello.ack`). No-op when
|
||||
* [channels] is empty (first launch). */
|
||||
fun loadFromCache(channels: List<ChannelInfo>) {
|
||||
if (channels.isNotEmpty()) _channels.value = sorted(channels)
|
||||
}
|
||||
|
||||
/** Reconcile a server frame into the cache. */
|
||||
fun onFrame(frame: Frame) {
|
||||
when (frame.type) {
|
||||
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame)
|
||||
TYPE_CHANNEL_DELETED -> remove(frame)
|
||||
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> {
|
||||
upsert(frame)
|
||||
}
|
||||
|
||||
TYPE_CHANNEL_DELETED -> {
|
||||
remove(frame)
|
||||
}
|
||||
|
||||
TYPE_CHANNEL_LIST -> {
|
||||
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
|
||||
}
|
||||
else -> Unit
|
||||
|
||||
else -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,12 +65,64 @@ class ChannelStore {
|
||||
|
||||
private fun remove(frame: Frame) {
|
||||
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
|
||||
_channels.value = _channels.value.filter { it.chatId != p.chatId }
|
||||
// A channel delete also removes its threads (child entries); a thread
|
||||
// delete removes just that thread. Both are hard deletes server-side.
|
||||
_channels.value =
|
||||
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
|
||||
}
|
||||
|
||||
// ── Optimistic local updates (M-7) ────────────────────────────────────
|
||||
//
|
||||
// The gateway only broadcasts channel.created/renamed/deleted — there are
|
||||
// no favorite/icon/automation/default events. So a toggle sent by THIS
|
||||
// device would not update its own UI until a full channel.list re-fetch.
|
||||
// These apply the change locally (optimistically); a later channel.list /
|
||||
// hello.ack re-seed reconciles any divergence (e.g. a server rejection).
|
||||
|
||||
/** Toggle the cosmetic favorite flag locally. */
|
||||
fun setFavorite(
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
) = update(chatId) { it.copy(favorite = on) }
|
||||
|
||||
/** Toggle the automation flag locally. */
|
||||
fun setAutomation(
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
) = update(chatId) { it.copy(automation = on) }
|
||||
|
||||
/** Set the icon (base64) and/or avatar color locally; null clears a field. */
|
||||
fun setIcon(
|
||||
chatId: String,
|
||||
icon: String?,
|
||||
color: String?,
|
||||
) = update(chatId) { it.copy(icon = icon, color = color) }
|
||||
|
||||
/** Make [chatId] the default channel locally (clearing the previous one). */
|
||||
fun setDefault(chatId: String) {
|
||||
_channels.value =
|
||||
sorted(
|
||||
_channels.value.map {
|
||||
when {
|
||||
it.chatId == chatId -> it.copy(isDefault = true)
|
||||
it.isDefault -> it.copy(isDefault = false)
|
||||
else -> it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun update(
|
||||
chatId: String,
|
||||
transform: (ChannelInfo) -> ChannelInfo,
|
||||
) {
|
||||
_channels.value = sorted(_channels.value.map { if (it.chatId == chatId) transform(it) else it })
|
||||
}
|
||||
|
||||
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
||||
list.sortedWith(
|
||||
compareByDescending<ChannelInfo> { it.isDefault }
|
||||
.thenByDescending { it.favorite }
|
||||
.thenBy { it.kind != "thread" }
|
||||
.thenBy { it.name.lowercase() },
|
||||
)
|
||||
@@ -61,20 +130,16 @@ class ChannelStore {
|
||||
// ── UI helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Channels (default + user channels) for the drawer/rail. */
|
||||
fun channelsForDrawer(): List<ChannelInfo> =
|
||||
_channels.value.filter { it.kind != "thread" }
|
||||
fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" }
|
||||
|
||||
/** Threads under a channel, for the topic switcher. */
|
||||
fun threadsFor(chatId: String): List<ChannelInfo> =
|
||||
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
|
||||
fun threadsFor(chatId: String): List<ChannelInfo> = _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
|
||||
|
||||
fun byId(chatId: String): ChannelInfo? =
|
||||
_channels.value.firstOrNull { it.chatId == chatId }
|
||||
fun byId(chatId: String): ChannelInfo? = _channels.value.firstOrNull { it.chatId == chatId }
|
||||
|
||||
fun defaultChannel(): ChannelInfo? =
|
||||
_channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
|
||||
fun defaultChannel(): ChannelInfo? = _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
|
||||
|
||||
fun clear() {
|
||||
_channels.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package iris.data
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import iris.db.IrisDatabase
|
||||
import iris.protocol.ChannelInfo
|
||||
import iris.protocol.IrisJson
|
||||
|
||||
/**
|
||||
* Persistent local cache (SQLite via SQLDelight — the locked "Local DB (KMP)"
|
||||
* decision, docs/16; schema docs/10 §10.7). The server is authoritative; this
|
||||
* is a cache that lets the app start instantly and read chats offline:
|
||||
*
|
||||
* - [loadLanes] / [loadChannels] restore the in-memory stores on startup,
|
||||
* before the gateway connection is up (snappy start, offline reading).
|
||||
* - [saveLanes] / [saveChannels] snapshot the in-memory stores (debounced by
|
||||
* the controller) so every reconciled frame survives a process death.
|
||||
* - [metaGet] / [metaPut] hold small UI state (last-viewed lane).
|
||||
*
|
||||
* [MessageItem]s and [ToolItem]s are persisted; live streaming state and
|
||||
* local system notices are ephemeral. Rows are JSON payloads keyed by
|
||||
* (lane, id), so the schema does not drift with the model fields.
|
||||
*
|
||||
* All access is synchronized: the debounced save collectors run on the
|
||||
* controller scope while [dispose] may flush from the UI thread.
|
||||
*/
|
||||
class ChatDb(
|
||||
driver: SqlDriver,
|
||||
) {
|
||||
private val db = IrisDatabase(driver)
|
||||
private val json = IrisJson.instance
|
||||
private val lock = Any()
|
||||
|
||||
// ── messages ──────────────────────────────────────────────────────────
|
||||
|
||||
/** All persisted lanes (lane key -> items: messages ordered by ts with
|
||||
* tool cards interleaved at their anchored position). */
|
||||
fun loadLanes(): Map<String, List<ChatItem>> =
|
||||
synchronized(lock) {
|
||||
val messages = linkedMapOf<String, MutableList<ChatItem>>()
|
||||
for (row in db.cacheQueries.allMessages().executeAsList()) {
|
||||
val item = decodeMessage(row.payload) ?: continue
|
||||
messages.getOrPut(row.lane) { mutableListOf() }.add(item)
|
||||
}
|
||||
val tools = linkedMapOf<String, MutableList<ToolItem>>()
|
||||
for (row in db.cacheQueries.allTools().executeAsList()) {
|
||||
val item = decodeTool(row.payload) ?: continue
|
||||
tools.getOrPut(row.lane) { mutableListOf() }.add(item)
|
||||
}
|
||||
(messages.keys + tools.keys).distinct().associateWith { lane ->
|
||||
val items: MutableList<ChatItem> = messages[lane].orEmpty().toMutableList()
|
||||
// Insert each tool card after its anchor message (the message
|
||||
// it followed live). Cards sharing an anchor keep their seq
|
||||
// order; a card whose anchor is gone (deleted message) falls
|
||||
// to the end of the lane. The lastPos cache assumes anchors
|
||||
// are monotonically non-decreasing per lane (true for the
|
||||
// onToolStart anchor rule: last non-streaming message) — a
|
||||
// tool anchored to an EARLIER message processed after a
|
||||
// later-anchored one would be misplaced.
|
||||
val lastPos = mutableMapOf<String, Int>()
|
||||
for (tool in tools[lane].orEmpty()) {
|
||||
val anchor = tool.anchorId
|
||||
val pos =
|
||||
if (anchor == null) {
|
||||
-1
|
||||
} else {
|
||||
lastPos.getOrPut(anchor) { items.indexOfFirst { it.id == anchor } }
|
||||
}
|
||||
if (pos >= 0) {
|
||||
items.add(pos + 1, tool)
|
||||
if (anchor != null) lastPos[anchor] = pos + 1
|
||||
} else {
|
||||
items.add(tool)
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the whole message + tool cache with [lanes] (atomic snapshot).
|
||||
* Local system notices are skipped (ephemeral). */
|
||||
fun saveLanes(lanes: Map<String, List<ChatItem>>) {
|
||||
synchronized(lock) {
|
||||
db.transaction {
|
||||
db.cacheQueries.clearMessages()
|
||||
db.cacheQueries.clearTools()
|
||||
for ((lane, items) in lanes) {
|
||||
var toolSeq = 0
|
||||
for (item in items) {
|
||||
when (item) {
|
||||
is MessageItem -> {
|
||||
if (!item.isSystem) {
|
||||
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
|
||||
}
|
||||
}
|
||||
|
||||
is ToolItem -> {
|
||||
db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item))
|
||||
}
|
||||
|
||||
// Picker cards ride in the message table (JSON
|
||||
// payload; decodeMessage picks the type back out).
|
||||
is PickerItem -> {
|
||||
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── channels ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The persisted channel directory (channels + threads). */
|
||||
fun loadChannels(): List<ChannelInfo> =
|
||||
synchronized(lock) {
|
||||
val list = mutableListOf<ChannelInfo>()
|
||||
for (row in db.cacheQueries.allChannels().executeAsList()) {
|
||||
try {
|
||||
list.add(json.decodeFromString(row.payload))
|
||||
} catch (_: Exception) {
|
||||
// Corrupt row — skip it; the server re-seeds on connect.
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/** Replace the whole channel directory (atomic snapshot). */
|
||||
fun saveChannels(channels: List<ChannelInfo>) {
|
||||
synchronized(lock) {
|
||||
db.transaction {
|
||||
db.cacheQueries.clearChannels()
|
||||
for (c in channels) {
|
||||
db.cacheQueries.upsertChannel(c.chatId, json.encodeToString(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── meta ──────────────────────────────────────────────────────────────
|
||||
|
||||
fun metaGet(key: String): String? =
|
||||
synchronized(lock) {
|
||||
db.cacheQueries.metaGet(key).executeAsOneOrNull()
|
||||
}
|
||||
|
||||
fun metaPut(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
db.cacheQueries.metaPut(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wipe the whole cache (forget pairing → a different gateway). */
|
||||
fun clearAll() {
|
||||
synchronized(lock) {
|
||||
db.transaction {
|
||||
db.cacheQueries.clearMessages()
|
||||
db.cacheQueries.clearTools()
|
||||
db.cacheQueries.clearChannels()
|
||||
db.cacheQueries.clearMeta()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeMessage(payload: String): ChatItem? =
|
||||
try {
|
||||
json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
|
||||
} catch (_: Exception) {
|
||||
// Not a message payload — a picker card (MessageItem requires
|
||||
// "role", PickerItem requires "title": the two never cross-decode).
|
||||
try {
|
||||
json.decodeFromString<PickerItem>(payload)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeTool(payload: String): ToolItem? =
|
||||
try {
|
||||
json.decodeFromString<ToolItem>(payload).sanitizeForRestore()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** A restored message is never mid-flight: a streaming bubble is
|
||||
* finalized (the sync replay finalizes it for real on reconnect) and a
|
||||
* pending send becomes failed (tap to retry) — the gateway never
|
||||
* acknowledged it before the process died. */
|
||||
private fun MessageItem.sanitizeForRestore(): MessageItem =
|
||||
copy(
|
||||
streaming = false,
|
||||
pending = false,
|
||||
status = if (status == MsgStatus.Pending) MsgStatus.Failed else status,
|
||||
)
|
||||
|
||||
/** A restored tool card is never mid-flight: an open card (the process
|
||||
* died before tool.end) is closed as interrupted, mirroring
|
||||
* [ChatStore.finalizeInterrupted]. */
|
||||
private fun ToolItem.sanitizeForRestore(): ToolItem = if (done) this else copy(done = true, ok = false)
|
||||
}
|
||||
@@ -2,16 +2,26 @@ package iris.data
|
||||
|
||||
/**
|
||||
* Pairing settings storage. The token is a secret: platform actuals keep it
|
||||
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
|
||||
* SharedPreferences for M1 dev, file on desktop).
|
||||
* in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
|
||||
* encrypted file on desktop).
|
||||
*/
|
||||
interface SecureStore {
|
||||
/** ws(s)://host:port/ws */
|
||||
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
||||
var serverUrl: String
|
||||
|
||||
/** ANDROID_TOKEN presented in the hello frame. */
|
||||
/** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */
|
||||
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). */
|
||||
val deviceId: String
|
||||
|
||||
@@ -30,6 +40,59 @@ interface SecureStore {
|
||||
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
|
||||
var ntfyServer: String
|
||||
|
||||
fun savePairing(url: String, token: String)
|
||||
/** Push backend of the paired gateway ("fcm"/"ntfy"; from hello.ack
|
||||
* server_caps; empty when unknown — decides whether the ntfy listener
|
||||
* foreground service runs at all). */
|
||||
var pushBackend: String
|
||||
|
||||
/** UI setting: show threads (topics) in the chat view. */
|
||||
var threadsEnabled: Boolean
|
||||
|
||||
/** UI setting: tool verbosity — "everything" / "truncated" / "nothing". */
|
||||
var toolDetail: String
|
||||
|
||||
/** UI setting: stream assistant replies live (token by token). */
|
||||
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. */
|
||||
var reasoningAutoCollapse: Boolean
|
||||
|
||||
/** UI setting: user bubble color (ARGB int). */
|
||||
var userBubbleColor: Int
|
||||
|
||||
/** UI setting: agent bubble color (ARGB int). */
|
||||
var agentBubbleColor: Int
|
||||
|
||||
/** UI setting: background mode — "color" or "image". */
|
||||
var backgroundMode: String
|
||||
|
||||
/** UI setting: background color (ARGB int). */
|
||||
var backgroundColor: Int
|
||||
|
||||
/** UI setting: background image path (empty when unused). */
|
||||
var backgroundImagePath: String
|
||||
|
||||
/** UI setting: text size multiplier (1.0 = default; applied on top of the
|
||||
* system font scale). */
|
||||
var fontSizeScale: Float
|
||||
|
||||
/** UI setting: show the runtime-metadata footer under final assistant
|
||||
* messages (model / context % / cwd / latency / cost). */
|
||||
var runtimeFooterEnabled: Boolean
|
||||
|
||||
/** UI setting: which runtime-footer fields to show, as a comma-separated
|
||||
* list in display order (e.g. "model,context_pct,cwd"). Empty = the
|
||||
* default set. Valid keys: model, context_pct, cwd, latency, cost. */
|
||||
var runtimeFooterFields: String
|
||||
|
||||
fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
)
|
||||
|
||||
fun clear()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package iris.media
|
||||
|
||||
/** A readable local file (expect/actual; JVM impl in jvmMain). */
|
||||
expect class FileSource(path: String) : AutoCloseable {
|
||||
/** Total size in bytes. */
|
||||
fun size(): Long
|
||||
|
||||
/** Read up to [buf.size] bytes into [buf]; returns bytes read or -1 at EOF. */
|
||||
fun read(buf: ByteArray): Int
|
||||
}
|
||||
@@ -6,32 +6,43 @@ import iris.protocol.KIND_IMAGE
|
||||
import iris.protocol.KIND_VIDEO
|
||||
|
||||
/** Map a MIME type to a media kind (docs/07 §7.1). */
|
||||
fun kindFromMime(mime: String): String = when {
|
||||
mime.startsWith("image/") -> KIND_IMAGE
|
||||
mime.startsWith("video/") -> KIND_VIDEO
|
||||
mime.startsWith("audio/") -> KIND_AUDIO
|
||||
else -> KIND_DOCUMENT
|
||||
}
|
||||
fun kindFromMime(mime: String): String =
|
||||
when {
|
||||
mime.startsWith("image/") -> KIND_IMAGE
|
||||
mime.startsWith("video/") -> KIND_VIDEO
|
||||
mime.startsWith("audio/") -> KIND_AUDIO
|
||||
else -> KIND_DOCUMENT
|
||||
}
|
||||
|
||||
/** Best-effort file extension for a MIME type (cache file naming). */
|
||||
fun extForMime(mime: String): String = when {
|
||||
mime == "image/jpeg" -> ".jpg"
|
||||
mime == "image/png" -> ".png"
|
||||
mime == "image/webp" -> ".webp"
|
||||
mime == "image/gif" -> ".gif"
|
||||
mime == "image/heic" -> ".heic"
|
||||
mime == "image/heif" -> ".heif"
|
||||
mime == "video/mp4" -> ".mp4"
|
||||
mime == "video/webm" -> ".webm"
|
||||
mime == "video/quicktime" -> ".mov"
|
||||
mime == "audio/mpeg" -> ".mp3"
|
||||
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
|
||||
mime == "audio/ogg" -> ".ogg"
|
||||
mime == "audio/wav" -> ".wav"
|
||||
mime == "audio/flac" -> ".flac"
|
||||
mime == "audio/aac" -> ".aac"
|
||||
mime == "application/pdf" -> ".pdf"
|
||||
mime == "application/zip" -> ".zip"
|
||||
mime == "text/plain" -> ".txt"
|
||||
else -> ".bin"
|
||||
}
|
||||
fun extForMime(mime: String): String =
|
||||
when {
|
||||
mime == "image/jpeg" -> ".jpg"
|
||||
mime == "image/png" -> ".png"
|
||||
mime == "image/webp" -> ".webp"
|
||||
mime == "image/gif" -> ".gif"
|
||||
mime == "image/heic" -> ".heic"
|
||||
mime == "image/heif" -> ".heif"
|
||||
mime == "video/mp4" -> ".mp4"
|
||||
mime == "video/webm" -> ".webm"
|
||||
mime == "video/quicktime" -> ".mov"
|
||||
mime == "audio/mpeg" -> ".mp3"
|
||||
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
|
||||
mime == "audio/ogg" -> ".ogg"
|
||||
mime == "audio/wav" -> ".wav"
|
||||
mime == "audio/flac" -> ".flac"
|
||||
mime == "audio/aac" -> ".aac"
|
||||
mime == "application/pdf" -> ".pdf"
|
||||
mime == "application/zip" -> ".zip"
|
||||
mime == "text/plain" -> ".txt"
|
||||
else -> ".bin"
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a server-provided media id before it is used in a file path or a
|
||||
* `GET /v1/media/{id}` URL (M-2 / S-1). The id comes from the gateway's
|
||||
* `media.offer` frame; a value like `../../x` would write outside the media
|
||||
* directory and break the pull URL. Only a conservative token charset is
|
||||
* accepted.
|
||||
*/
|
||||
fun isValidMediaId(id: String): Boolean = id.length in 1..128 && id.all { it.isLetterOrDigit() || it == '_' || it == '-' }
|
||||
@@ -1,66 +1,48 @@
|
||||
package iris.net
|
||||
|
||||
import iris.data.SecureStore
|
||||
import iris.media.FileSource
|
||||
import iris.media.Sha256
|
||||
import iris.protocol.ChannelInfo
|
||||
import iris.protocol.ErrorPayload
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.HelloAckPayload
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.MediaPullEndPayload
|
||||
import iris.protocol.MediaUploadAckPayload
|
||||
import iris.protocol.ServerCaps
|
||||
import iris.protocol.TYPE_ERROR
|
||||
import iris.protocol.TYPE_HELLO_ACK
|
||||
import iris.protocol.TYPE_MEDIA_PULL_END
|
||||
import iris.protocol.TYPE_MEDIA_UPLOAD_ACK
|
||||
import iris.protocol.TYPE_PONG
|
||||
import iris.protocol.helloFrame
|
||||
import iris.protocol.mediaPullFrame
|
||||
import iris.protocol.mediaUploadEndFrame
|
||||
import iris.protocol.mediaUploadStartFrame
|
||||
import iris.protocol.messageSendFrame
|
||||
import iris.protocol.pingFrame
|
||||
import iris.protocol.syncFrame
|
||||
import iris.util.IrisLog
|
||||
import iris.util.nowMillis
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
|
||||
* HTTP client for the hermes iris gateway (docs/19).
|
||||
*
|
||||
* - connect + hello (real auth leg), hello.ack
|
||||
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
||||
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
||||
*
|
||||
* - connect: health probe + SSE hello (the HTTP hello.ack)
|
||||
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
|
||||
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
|
||||
* - events: server frames (minus hello.ack) on [events]
|
||||
* - request/response correlation by id (M2+ consumers)
|
||||
* - events: server frames on [events]
|
||||
* - correlation: the POST body carries the synchronous reply (e.g. read
|
||||
* receipt, errors); everything else arrives on the event stream, and the
|
||||
* app reconciles by frame id (docs/19 §19.7)
|
||||
*/
|
||||
class GatewayClient(
|
||||
private val scope: CoroutineScope,
|
||||
@@ -68,10 +50,30 @@ class GatewayClient(
|
||||
) {
|
||||
sealed interface State {
|
||||
data object Disconnected : 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 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)
|
||||
@@ -84,32 +86,79 @@ class GatewayClient(
|
||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
// TLS: the pinning trust manager wraps the platform default (CA-signed
|
||||
// certs behave as before); a self-signed gateway cert is accepted only
|
||||
// after the user confirms its fingerprint (docs/09 §9.4). The pin is
|
||||
// read live from the store so a fresh confirm takes effect without
|
||||
// rebuilding the client.
|
||||
private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint }
|
||||
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
|
||||
.build()
|
||||
|
||||
private var connectJob: Job? = null
|
||||
private var socket: WebSocket? = null
|
||||
private var nextRequestId = 1
|
||||
|
||||
// Incremented from the SSE callback thread (onHttpHello) and the UI
|
||||
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
|
||||
// request/response correlation breaks.
|
||||
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
|
||||
|
||||
// Written by poke() (UI thread) and the connect loop (Default).
|
||||
@Volatile
|
||||
private var attempt = 0
|
||||
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
|
||||
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
||||
|
||||
// M4: binary frames (media upload chunks / pull stream) have no per-frame
|
||||
// id, so at most one binary session is active per socket. The gateway
|
||||
// allows one upload per connection; pull is request/response.
|
||||
private sealed interface BinarySession {
|
||||
data class Pulling(
|
||||
val requestId: Int,
|
||||
val chunks: Channel<ByteArray>,
|
||||
val end: CompletableDeferred<Frame>,
|
||||
) : BinarySession
|
||||
}
|
||||
// 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
|
||||
|
||||
private var binarySession: BinarySession? = null
|
||||
// True once a connection has been established this session; reset by
|
||||
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
|
||||
// drop) so the UI can show the right status without a blocking screen.
|
||||
private var hasConnected = false
|
||||
|
||||
// M4: only one pull may be in flight at a time (binarySession is a single
|
||||
// slot). Serialize concurrent offers so their byte streams don't interleave.
|
||||
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
|
||||
// the resume cursor (SSE id / outbox high-water mark), updated from the
|
||||
// SSE/poll callback threads.
|
||||
private var http: HttpGateway? = null
|
||||
|
||||
@Volatile
|
||||
private var httpCursor: Long = 0
|
||||
private var sseFailures = 0
|
||||
private var usingLongPoll = false
|
||||
|
||||
// Last hello.ack payload — used to restore State.Connected after a
|
||||
// reconnect state race in the connect loop. Written by the SSE callback
|
||||
// thread, read by the long-poll loop.
|
||||
@Volatile
|
||||
private var lastAck: HelloAckPayload? = null
|
||||
|
||||
// Epoch ms of the last frame delivered by the receive stream (SSE or
|
||||
// long-poll). The watchdog uses this to detect a STALE stream: a live
|
||||
// connection (heartbeats / poll answers keep it open, so the read timeout
|
||||
// never fires) that has stopped delivering frames — the state a gateway
|
||||
// restart can leave the app in, where sent messages sit at "sending…"
|
||||
// because their echo is never delivered (issue #6). Set when the receive
|
||||
// loop (re)starts so the first window isn't treated as stale.
|
||||
@Volatile
|
||||
private var lastFrameMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Fired promptly the moment the SSE hello (hello.ack) is received — on
|
||||
* every (re)connect. Used for time-critical work that must not wait for
|
||||
* the state collector, which can be starved for seconds during app
|
||||
* startup (Dispatchers.Default) and would push a history request past a
|
||||
* flaky network's window. Set before [start].
|
||||
*/
|
||||
var onHelloAck: ((State.Connected) -> Unit)? = null
|
||||
|
||||
// Only one pull may be in flight at a time. Serialize concurrent offers so
|
||||
// their byte streams don't interleave.
|
||||
private val pullMutex = Mutex()
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
@@ -118,16 +167,38 @@ class GatewayClient(
|
||||
fun start() {
|
||||
if (connectJob?.isActive == true) return
|
||||
attempt = 0
|
||||
hasConnected = false
|
||||
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() {
|
||||
connectJob?.cancel()
|
||||
connectJob = null
|
||||
socket?.close(1000, "client shutdown")
|
||||
socket = null
|
||||
_state.value = State.Disconnected
|
||||
http?.close()
|
||||
http = null
|
||||
httpCursor = 0
|
||||
sseFailures = 0
|
||||
usingLongPoll = false
|
||||
lastAck = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when the app returns to the foreground: if the connect loop is
|
||||
* between attempts (backing off after failed health probes — up to 30 s),
|
||||
* wake it so it re-probes immediately instead of making the user wait out
|
||||
* the backoff with a "Connecting…" banner. No-op while connected.
|
||||
*/
|
||||
fun poke() {
|
||||
if (_state.value is State.Connected) return
|
||||
attempt = 0
|
||||
wakeRequested = true
|
||||
}
|
||||
|
||||
/** Re-pair: stop, then start fresh (used after saving new settings). */
|
||||
@@ -139,174 +210,294 @@ class GatewayClient(
|
||||
private suspend fun connectLoop() {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val url = store.serverUrl.trim()
|
||||
val token = store.token
|
||||
// Per-device token when the gateway minted one (docs/09 §9.3),
|
||||
// else the shared IRIS_TOKEN (bootstrap).
|
||||
val token = store.deviceToken.ifBlank { store.token }
|
||||
if (url.isBlank() || token.isBlank()) {
|
||||
_state.value = State.Disconnected
|
||||
return
|
||||
}
|
||||
_state.value = if (attempt == 0) State.Connecting else State.Reconnecting
|
||||
val dial = dial(url, token)
|
||||
when (val result = dial.result) {
|
||||
is DialResult.AuthFailed -> {
|
||||
_state.value = State.AuthFailed(result.message)
|
||||
dial.socket.close(1000, "auth failed")
|
||||
return
|
||||
}
|
||||
DialResult.Connected -> {
|
||||
attempt = 0
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
dial.closed.await()
|
||||
if (!currentCoroutineContext().isActive) return
|
||||
// socket dropped -> loop again (Reconnecting)
|
||||
}
|
||||
is DialResult.Failed -> {
|
||||
attempt++
|
||||
delay(backoffMs(attempt))
|
||||
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
||||
val gw = httpGateway() ?: continue
|
||||
// Health probe: if the gateway is alive, open the SSE receive loop
|
||||
// (which delivers the hello.ack). Otherwise back off and retry.
|
||||
val healthOk =
|
||||
try {
|
||||
gw.health()
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
false
|
||||
}
|
||||
if (!healthOk) {
|
||||
attempt++
|
||||
backoffOrWake(backoffMs(attempt))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dial (one connect + hello) ────────────────────────────────────────
|
||||
|
||||
private sealed interface DialResult {
|
||||
data object Connected : DialResult
|
||||
data class AuthFailed(val message: String) : DialResult
|
||||
data class Failed(val message: String) : DialResult
|
||||
}
|
||||
|
||||
private data class Dial(
|
||||
val result: DialResult,
|
||||
val socket: WebSocket,
|
||||
val closed: CompletableDeferred<Unit>,
|
||||
)
|
||||
|
||||
private suspend fun dial(url: String, token: String): Dial {
|
||||
val closed = CompletableDeferred<Unit>()
|
||||
val helloAck = CompletableDeferred<HelloAckPayload>()
|
||||
val authError = CompletableDeferred<String>()
|
||||
val fail = CompletableDeferred<String>()
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
val ws = client.newWebSocket(
|
||||
request,
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
helloFrame(
|
||||
token = token,
|
||||
deviceId = store.deviceId,
|
||||
deviceName = store.deviceName,
|
||||
fcmToken = store.fcmToken.ifBlank { null },
|
||||
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||
).toWire(),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
attempt = 0
|
||||
hasConnected = true
|
||||
sseFailures = 0
|
||||
usingLongPoll = false
|
||||
httpCursor = store.syncCursor
|
||||
lastFrameMs = nowMs()
|
||||
// Provisional Connected state (previous caps/channels) until the
|
||||
// SSE hello arrives with the real ones.
|
||||
val prev = _state.value
|
||||
_state.value =
|
||||
State.Connected(
|
||||
caps = (prev as? State.Connected)?.caps ?: ServerCaps(),
|
||||
channels = (prev as? State.Connected)?.channels ?: emptyList(),
|
||||
lastPushedCursor = (prev as? State.Connected)?.lastPushedCursor ?: 0,
|
||||
)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
// ── HTTP receive leg ──────────────────────────────────────────────────
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
closed.complete(Unit)
|
||||
}
|
||||
/** Lazily build the HTTP client from the stored URL. Synchronized: two
|
||||
* threads racing the check-then-create would leak a gateway (and its
|
||||
* OkHttp clients). */
|
||||
private fun httpGateway(): HttpGateway? =
|
||||
synchronized(this) {
|
||||
val url = store.serverUrl.trim()
|
||||
val token = store.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 }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
fail.complete(t.message ?: "connection failed")
|
||||
closed.complete(Unit)
|
||||
/**
|
||||
* The HTTP receive loop: SSE by default; after two consecutive SSE open
|
||||
* failures (buffering proxy) it switches to long-poll until the next full
|
||||
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
|
||||
*/
|
||||
private suspend fun httpReceiveLoop(gw: HttpGateway) {
|
||||
var backoff = 1_000L
|
||||
while (currentCoroutineContext().isActive) {
|
||||
if (usingLongPoll) {
|
||||
try {
|
||||
val res = gw.poll(httpCursor)
|
||||
res.frames.forEach { emitHttpFrame(it) }
|
||||
if (res.cursor > httpCursor) httpCursor = res.cursor
|
||||
// The poll answered: the link is back (long-poll has no
|
||||
// hello — restore the Connected state from the last one).
|
||||
restoreConnected()
|
||||
} catch (e: HttpGateway.HttpAuthException) {
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
if (failTls(e)) return
|
||||
markStreamLost()
|
||||
IrisLog.w("http poll failed: ${e.message}")
|
||||
delay(backoff)
|
||||
backoff = minOf(backoff * 2, 15_000)
|
||||
}
|
||||
},
|
||||
)
|
||||
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())
|
||||
} 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
fail.invokeOnCompletion { e ->
|
||||
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
|
||||
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||
_state.value = connected
|
||||
// M5: reconnect catch-up — replay frames parked while offline.
|
||||
val local = store.syncCursor
|
||||
if (local < ack.syncCursor) {
|
||||
val id = nextId()
|
||||
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
|
||||
}
|
||||
val result = withTimeoutOrNull(15_000) { winner.await() }
|
||||
?: DialResult.Failed("timeout waiting for hello.ack")
|
||||
return Dial(result, ws, closed)
|
||||
// Prompt fast path (before the possibly-starved state collector).
|
||||
onHelloAck?.invoke(connected)
|
||||
}
|
||||
|
||||
/** The receive stream just died: don't keep claiming "Connected" while
|
||||
* between attempts (a stale green dot through a Wi-Fi drop). */
|
||||
private fun markStreamLost() {
|
||||
if (_state.value is State.Connected) _state.value = State.Reconnecting
|
||||
}
|
||||
|
||||
/** The receive stream is open again (long-poll answered): the link is
|
||||
* back. Long-poll has no hello, so restore the Connected state from the
|
||||
* last hello.ack (the SSE path gets a fresh one). Fire [onHelloAck] too:
|
||||
* the long-poll path has no `event: hello`, so without this the app's
|
||||
* "on connected" side effects (resend queued offline sends, load the
|
||||
* active lane's history) never run when a gateway restart lands while
|
||||
* the app is on the long-poll fallback — queued messages would sit at
|
||||
* "sending…" forever until an app restart (issue #6). */
|
||||
private fun restoreConnected() {
|
||||
if (_state.value !is State.Reconnecting) return
|
||||
val connected =
|
||||
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
|
||||
?: State.Connected(ServerCaps(), emptyList())
|
||||
_state.value = connected
|
||||
onHelloAck?.invoke(connected)
|
||||
}
|
||||
|
||||
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */
|
||||
private fun emitHttpFrame(frame: Frame) {
|
||||
lastFrameMs = nowMs()
|
||||
if (!_events.tryEmit(frame)) {
|
||||
// The collector is starved beyond the 128-frame buffer: the frame
|
||||
// is dropped. Log it — a silent drop here loses user-visible
|
||||
// content (echoes, deltas, notifications).
|
||||
IrisLog.e("events buffer full — dropped frame ${frame.type} (id=${frame.id})")
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs). */
|
||||
* [mediaRefs] reference completed uploads (POST /v1/media refs).
|
||||
* [autoThread] asks the gateway to mint a fresh thread for the message
|
||||
* (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(
|
||||
chatId: String,
|
||||
text: String,
|
||||
threadId: String? = null,
|
||||
mediaRefs: List<String> = emptyList(),
|
||||
autoThread: Boolean = false,
|
||||
onResult: ((Int) -> Unit)? = null,
|
||||
) {
|
||||
val ws = socket ?: return
|
||||
val id = nextRequestId++
|
||||
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs).toWire())
|
||||
if (_state.value !is State.Connected) {
|
||||
onResult?.invoke(0)
|
||||
return
|
||||
}
|
||||
val id = nextId()
|
||||
scope.launch {
|
||||
val res =
|
||||
httpGateway()?.postFrame(
|
||||
messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread),
|
||||
)
|
||||
// The synchronous reply (e.g. the read receipt, or an error frame
|
||||
// on 4xx) comes back in the POST body, not on the event stream —
|
||||
// deliver it or it is lost (docs/19 §19.7).
|
||||
handlePostResult(res)
|
||||
onResult?.invoke(res?.status ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── M4: media upload / pull ───────────────────────────────────────────
|
||||
// ── Media upload / pull ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a local file as media (docs/07 §7.2): media.upload.start,
|
||||
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's
|
||||
* media_ref (for message.send media_refs) on success.
|
||||
* Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
|
||||
* Returns the server's media_ref (for message.send media_refs) on success.
|
||||
*/
|
||||
suspend fun uploadMedia(
|
||||
path: String,
|
||||
@@ -315,163 +506,140 @@ class GatewayClient(
|
||||
filename: String,
|
||||
mediaRef: String,
|
||||
): Result<String> {
|
||||
val ws = socket ?: return Result.failure(IllegalStateException("not connected"))
|
||||
val source = FileSource(path)
|
||||
val size = source.size()
|
||||
if (size <= 0) {
|
||||
source.close()
|
||||
return Result.failure(IllegalStateException("empty file"))
|
||||
}
|
||||
val id = nextRequestId++
|
||||
val reply = CompletableDeferred<Frame>()
|
||||
pending[id] = reply
|
||||
try {
|
||||
ws.send(mediaUploadStartFrame(id, mediaRef, kind, mime, filename, size).toWire())
|
||||
val sha = Sha256()
|
||||
source.use {
|
||||
val buf = ByteArray(UPLOAD_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = it.read(buf)
|
||||
if (n < 0) break
|
||||
if (n == 0) continue
|
||||
sha.update(buf, 0, n)
|
||||
ws.send(buf.copyOfRange(0, n).toByteString())
|
||||
}
|
||||
}
|
||||
ws.send(mediaUploadEndFrame(id, mediaRef, sha.hex()).toWire())
|
||||
val frame = withTimeout(UPLOAD_TIMEOUT_MS) { reply.await() }
|
||||
return when (frame.type) {
|
||||
TYPE_MEDIA_UPLOAD_ACK -> {
|
||||
val p = frame.payloadAs<MediaUploadAckPayload>()
|
||||
if (p != null && p.ok) Result.success(p.mediaRef)
|
||||
else Result.failure(IllegalStateException("upload rejected by server"))
|
||||
}
|
||||
TYPE_ERROR -> {
|
||||
val e = frame.payloadAs<ErrorPayload>()
|
||||
Result.failure(IllegalStateException(e?.message ?: "upload failed"))
|
||||
}
|
||||
else -> Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
} finally {
|
||||
pending.remove(id)
|
||||
}
|
||||
val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
|
||||
return http.uploadMedia(path, mime, kind, filename, mediaRef)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until
|
||||
* media.pull.end. Each chunk is handed to [onChunk] (write to cache).
|
||||
* Pull offered media via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
|
||||
* chunk is handed to [onChunk] (write to cache).
|
||||
*/
|
||||
suspend fun pullMedia(mediaId: String, onChunk: suspend (ByteArray) -> Unit): Result<Unit> =
|
||||
suspend fun pullMedia(
|
||||
mediaId: String,
|
||||
onChunk: suspend (ByteArray) -> Unit,
|
||||
): Result<Unit> =
|
||||
pullMutex.withLock {
|
||||
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
||||
val id = nextRequestId++
|
||||
val chunks = Channel<ByteArray>(Channel.UNLIMITED)
|
||||
val end = CompletableDeferred<Frame>()
|
||||
pending[id] = end
|
||||
binarySession = BinarySession.Pulling(id, chunks, end)
|
||||
try {
|
||||
ws.send(mediaPullFrame(id, mediaId).toWire())
|
||||
val frame = withTimeout(PULL_TIMEOUT_MS) {
|
||||
for (chunk in chunks) onChunk(chunk)
|
||||
end.await()
|
||||
}
|
||||
when (frame.type) {
|
||||
TYPE_MEDIA_PULL_END -> {
|
||||
val p = frame.payloadAs<MediaPullEndPayload>()
|
||||
if (p != null && p.ok) Result.success(Unit)
|
||||
else Result.failure(IllegalStateException("pull failed"))
|
||||
}
|
||||
TYPE_ERROR -> {
|
||||
val e = frame.payloadAs<ErrorPayload>()
|
||||
Result.failure(IllegalStateException(e?.message ?: "pull failed"))
|
||||
}
|
||||
else -> Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
pending.remove(id)
|
||||
chunks.cancel()
|
||||
val s = binarySession
|
||||
if (s is BinarySession.Pulling && s.requestId == id) binarySession = null
|
||||
}
|
||||
val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
||||
http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */
|
||||
const val UPLOAD_CHUNK_BYTES = 256 * 1024
|
||||
const val UPLOAD_TIMEOUT_MS = 120_000L
|
||||
const val PULL_TIMEOUT_MS = 300_000L
|
||||
/** No frames (or keep-alive comments) from the receive stream for
|
||||
* this long (gateway still
|
||||
* healthy) = stale stream: force a fresh (re)connect. 3 min keeps an
|
||||
* idle app from churning while still recovering a stuck stream well
|
||||
* before a user notices (issue #6). */
|
||||
const val STALE_STREAM_TIMEOUT_MS = 180_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an arbitrary frame with a fresh request id (fire-and-forget).
|
||||
* The server replies (or broadcasts) a frame carrying the same id; the
|
||||
* app reconciles from [events]. Returns the id used, or -1 if not connected.
|
||||
* Send an arbitrary frame with a fresh request id (fire-and-forget). The
|
||||
* server replies (or broadcasts) a frame carrying the same id; the app
|
||||
* reconciles from [events]. Returns the id used, or -1 if not connected.
|
||||
*/
|
||||
fun sendFrame(frame: Frame): Int {
|
||||
val ws = socket ?: return -1
|
||||
val id = nextRequestId++
|
||||
ws.send(frame.copy(id = id).toWire())
|
||||
return id
|
||||
}
|
||||
|
||||
/** Send a ping (heartbeat). */
|
||||
fun ping() {
|
||||
socket?.send(pingFrame().toWire())
|
||||
}
|
||||
|
||||
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
|
||||
fun isStale(timeoutMs: Long = 60_000): Boolean =
|
||||
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
|
||||
|
||||
fun reapStale() {
|
||||
if (isStale()) {
|
||||
socket?.close(1000, "heartbeat timeout")
|
||||
if (_state.value !is State.Connected) return -1
|
||||
val id = nextId()
|
||||
scope.launch {
|
||||
val res = httpGateway()?.postFrame(frame.copy(id = id))
|
||||
// Single-frame responses (commands.catalog, channel.list, search,
|
||||
// history, sync, errors) come back in the POST body, not on the
|
||||
// event stream — deliver it or it is lost (docs/19 §19.7).
|
||||
handlePostResult(res)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ── One-shot hello test (Connect screen) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
|
||||
* Exercises the auth leg, not just TCP (docs/10 §10.8).
|
||||
* Real connection test: health probe + SSE open. The auth leg is proven
|
||||
* by the stream being accepted (200 vs 401) — we do NOT wait for the
|
||||
* hello event, because the server replays the outbox (up to 72 h of
|
||||
* frames) before it and a large outbox would time out a healthy gateway
|
||||
* (docs/10 §10.8).
|
||||
*/
|
||||
suspend fun testHello(url: String, token: String): Result<Unit> {
|
||||
val dial = dial(url, token)
|
||||
return when (val result = dial.result) {
|
||||
DialResult.Connected -> {
|
||||
dial.socket.close(1000, "test complete")
|
||||
Result.success(Unit)
|
||||
}
|
||||
is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message))
|
||||
is DialResult.Failed -> Result.failure(IllegalStateException(result.message))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Heartbeat job ─────────────────────────────────────────────────────
|
||||
|
||||
fun startHeartbeat() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(20_000)
|
||||
if (_state.value is State.Connected) {
|
||||
ping()
|
||||
reapStale()
|
||||
suspend fun testHello(
|
||||
url: String,
|
||||
token: String,
|
||||
): Result<Unit> {
|
||||
val gw =
|
||||
HttpGateway(
|
||||
client,
|
||||
HttpGateway.deriveHttpUrl(url),
|
||||
// An already-paired device presents its per-device token
|
||||
// (docs/09 §9.3); a fresh pairing falls back to the entered
|
||||
// shared token (bootstrap).
|
||||
token = { store.deviceToken.ifBlank { token } },
|
||||
store.deviceId,
|
||||
deviceName = store.deviceName,
|
||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
||||
)
|
||||
return try {
|
||||
if (!gw.health()) {
|
||||
Result.failure(IllegalStateException("gateway unreachable"))
|
||||
} else {
|
||||
// Open the SSE stream briefly: 200 = auth leg proven, 401 =
|
||||
// bad token. Don't wait for the hello (outbox replay first).
|
||||
val opened = CompletableDeferred<Unit>()
|
||||
val job =
|
||||
scope.launch {
|
||||
try {
|
||||
gw.events(
|
||||
cursor = store.syncCursor,
|
||||
onOpen = { opened.complete(Unit) },
|
||||
onHello = { },
|
||||
onFrame = { },
|
||||
onCursor = { },
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
opened.completeExceptionally(e)
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (withTimeoutOrNull(10_000) { opened.await() } == null) {
|
||||
Result.failure(IllegalStateException("timeout opening the event stream"))
|
||||
} else {
|
||||
Result.success(Unit)
|
||||
}
|
||||
} 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Backoff that wakes early when [poke] is called (app foregrounded). */
|
||||
private suspend fun backoffOrWake(ms: Long) {
|
||||
var remaining = ms
|
||||
while (remaining > 0 && currentCoroutineContext().isActive) {
|
||||
delay(minOf(remaining, 500L))
|
||||
if (wakeRequested) {
|
||||
wakeRequested = false
|
||||
return
|
||||
}
|
||||
remaining -= 500L
|
||||
}
|
||||
}
|
||||
|
||||
private fun backoffMs(attempt: Int): Long {
|
||||
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
||||
val capped = minOf(base, 30_000L)
|
||||
return capped + Random.nextLong(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
|
||||
private fun nowMs(): Long = nowMillis()
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package iris.net
|
||||
|
||||
import iris.AppVersion
|
||||
import iris.media.Sha256
|
||||
import iris.media.isValidMediaId
|
||||
import iris.protocol.ErrorPayload
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.HelloAckPayload
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.MediaUploadAckPayload
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* HTTP transport client (docs/19) — the only transport.
|
||||
*
|
||||
* The app sends over `POST /v1/frame` and receives over SSE
|
||||
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
|
||||
* media travels via `POST/GET /v1/media`.
|
||||
*
|
||||
* [events] is ONE SSE connection attempt (blocking read on
|
||||
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
|
||||
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
|
||||
* outbox cursor, so resume is just the last seen id).
|
||||
*/
|
||||
class HttpGateway(
|
||||
private val client: OkHttpClient,
|
||||
private val baseUrl: String,
|
||||
/** Live auth-token provider, read per request: the per-device token
|
||||
* (docs/09 §9.3) when the gateway minted one, else the shared
|
||||
* IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is
|
||||
* picked up without rebuilding the client. */
|
||||
private val token: () -> String,
|
||||
private val deviceId: String,
|
||||
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
|
||||
* upserts it into the device registry on every SSE open — the HTTP
|
||||
* equivalent of the old WS hello upsert). */
|
||||
private val deviceName: String? = null,
|
||||
/** Live push-token providers, read per request so a rotated FCM token or
|
||||
* a fresh ntfy topic is picked up without rebuilding the client. */
|
||||
private val fcmToken: () -> String? = { null },
|
||||
private val ntfyTopic: () -> String? = { null },
|
||||
) {
|
||||
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
|
||||
* with the same token can't succeed. */
|
||||
class HttpAuthException : IOException("unauthorized (HTTP 401)")
|
||||
|
||||
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
|
||||
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
|
||||
// with extended call timeouts (see the *Client() helpers below).
|
||||
private val healthClient: OkHttpClient = client.healthClient()
|
||||
private val streamClient: OkHttpClient = client.streamClient()
|
||||
private val pollClient: OkHttpClient = client.pollClient()
|
||||
private val mediaClient: OkHttpClient = client.mediaClient()
|
||||
|
||||
/** Close the per-purpose clients (and their idle connections). The
|
||||
* shared base [client] is owned by the caller. */
|
||||
fun close() {
|
||||
healthClient.dispatcher.executorService.shutdown()
|
||||
streamClient.dispatcher.executorService.shutdown()
|
||||
pollClient.dispatcher.executorService.shutdown()
|
||||
mediaClient.dispatcher.executorService.shutdown()
|
||||
healthClient.connectionPool.evictAll()
|
||||
streamClient.connectionPool.evictAll()
|
||||
pollClient.connectionPool.evictAll()
|
||||
mediaClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
/** POST /v1/frame result. [frame] is the handler's synchronous reply
|
||||
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
|
||||
* 202 accept-and-ack. */
|
||||
data class PostResult(
|
||||
val ok: Boolean,
|
||||
val status: Int,
|
||||
val frame: Frame?,
|
||||
)
|
||||
|
||||
/** Long-poll result: new high-water [cursor] + frames with cursor >
|
||||
* the requested one (may be empty at timeout). */
|
||||
data class PollResult(
|
||||
val cursor: Long,
|
||||
val frames: List<Frame>,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val JSON = "application/json".toMediaType()
|
||||
|
||||
/** Default port of the gateway's HTTP leg (WS default is 8790). */
|
||||
const val DEFAULT_PORT = 8791
|
||||
|
||||
/** Media transfer chunk (docs/07 §7.5). */
|
||||
private const val MEDIA_CHUNK_BYTES = 256 * 1024
|
||||
|
||||
/**
|
||||
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
|
||||
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
|
||||
* a different service, so the port is always replaced with the
|
||||
* HTTP leg's default. Pure function (unit-tested).
|
||||
*/
|
||||
fun deriveHttpUrl(wsUrl: String): String {
|
||||
val u = wsUrl.trim()
|
||||
val (scheme, rest) =
|
||||
when {
|
||||
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
|
||||
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
|
||||
else -> return u // already http(s)
|
||||
}
|
||||
val authority = rest.substringBefore('/')
|
||||
val host = authority.substringBefore(':')
|
||||
return "$scheme://$host:$DEFAULT_PORT"
|
||||
}
|
||||
}
|
||||
|
||||
private fun authHeaders(): Headers {
|
||||
val b =
|
||||
Headers
|
||||
.Builder()
|
||||
.add("Authorization", "Bearer ${token()}")
|
||||
.add("X-Iris-Device", deviceId)
|
||||
// Device registration (docs/19): the gateway upserts name + push
|
||||
// tokens from these headers on every SSE open (COALESCE — absent
|
||||
// headers never clobber a newer fcm.register value).
|
||||
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
|
||||
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
|
||||
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
|
||||
// Release version (repo-root VERSION baked in at build time); the
|
||||
// gateway stores it in the device registry (docs/04 hello.ack note).
|
||||
b.add("X-Iris-App-Version", AppVersion.VERSION)
|
||||
return b.build()
|
||||
}
|
||||
|
||||
/** Parse a response body as a protocol frame (null when not a frame,
|
||||
* e.g. the plain `{"ok":true}` ack). */
|
||||
private fun parseFrame(body: String): Frame? =
|
||||
try {
|
||||
if (body.startsWith("{")) {
|
||||
val obj = IrisJson.instance.parseToJsonElement(body)
|
||||
if (obj.jsonObject.containsKey("type")) {
|
||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Liveness probe (unauthenticated by design). True on 200. */
|
||||
suspend fun health(): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/health")
|
||||
.build()
|
||||
healthClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
response.body?.close()
|
||||
response.code == 200
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
|
||||
* (with the synchronous reply frame when the handler sent one); 4xx ->
|
||||
* the error frame as the body. Network failures (timeout, reset, DNS —
|
||||
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
|
||||
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
|
||||
* callers are fire-and-forget coroutines — an uncaught exception here
|
||||
* kills the app process.
|
||||
*/
|
||||
suspend fun postFrame(frame: Frame): PostResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/frame")
|
||||
.headers(authHeaders())
|
||||
.post(wire.toRequestBody(JSON))
|
||||
.build()
|
||||
try {
|
||||
client
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseFrame(body)
|
||||
PostResult(response.isSuccessful, response.code, parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
|
||||
PostResult(ok = false, status = 0, frame = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One SSE connection attempt: outbox catch-up from [cursor], then live
|
||||
* frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
|
||||
* leg is proven; the server may still replay a large outbox before the
|
||||
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
|
||||
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
|
||||
* cursor) when present; [onKeepAlive] for SSE comment lines (the
|
||||
* heartbeat) so callers can count keep-alives toward liveness. Returns
|
||||
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
|
||||
* on the IO thread.
|
||||
*/
|
||||
suspend fun events(
|
||||
cursor: Long,
|
||||
onOpen: (() -> Unit)? = null,
|
||||
onHello: (HelloAckPayload) -> Unit,
|
||||
onFrame: (Frame) -> Unit,
|
||||
onCursor: (Long) -> Unit,
|
||||
onKeepAlive: (() -> Unit)? = null,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/events?cursor=$cursor")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
streamClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (response.code == 401) throw HttpAuthException()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("SSE open failed: HTTP ${response.code}")
|
||||
}
|
||||
onOpen?.invoke()
|
||||
val source = response.body?.source() ?: throw IOException("empty SSE body")
|
||||
var eventId: String? = null
|
||||
val dataLines = mutableListOf<String>()
|
||||
while (true) {
|
||||
val line = source.readUtf8Line() ?: break // EOF
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (dataLines.isNotEmpty()) {
|
||||
val data = dataLines.joinToString("\n")
|
||||
try {
|
||||
val frame =
|
||||
IrisJson.instance.decodeFromString(
|
||||
Frame.serializer(),
|
||||
data,
|
||||
)
|
||||
when (eventId) {
|
||||
"hello" -> {
|
||||
onHello(
|
||||
frame.payloadAs<HelloAckPayload>()
|
||||
?: HelloAckPayload(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
onFrame(frame)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
|
||||
}
|
||||
}
|
||||
eventId = null
|
||||
dataLines.clear()
|
||||
}
|
||||
|
||||
line.startsWith(":") -> {
|
||||
// heartbeat comment
|
||||
onKeepAlive?.invoke()
|
||||
}
|
||||
|
||||
line.startsWith("id:") -> {
|
||||
line
|
||||
.removePrefix("id:")
|
||||
.trim()
|
||||
.toLongOrNull()
|
||||
?.let(onCursor)
|
||||
}
|
||||
|
||||
line.startsWith("event:") -> {
|
||||
eventId = line.removePrefix("event:").trim()
|
||||
}
|
||||
|
||||
line.startsWith("data:") -> {
|
||||
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
|
||||
* Returns the new high-water cursor + any frames with cursor > [cursor].
|
||||
*/
|
||||
suspend fun poll(cursor: Long): PollResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/poll?cursor=$cursor")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
pollClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (response.code == 401) throw HttpAuthException()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("poll failed: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
|
||||
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
|
||||
val frames =
|
||||
obj["frames"]
|
||||
?.jsonArray
|
||||
?.mapNotNull { el ->
|
||||
try {
|
||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.orEmpty()
|
||||
PollResult(newCursor, frames)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
|
||||
* with the whole file as the body and the metadata in `X-Iris-Media-*`
|
||||
* headers (sha256 precomputed in a first pass). Returns the server's
|
||||
* media_ref (for message.send media_refs) on success.
|
||||
*/
|
||||
suspend fun uploadMedia(
|
||||
path: String,
|
||||
mime: String,
|
||||
kind: String,
|
||||
filename: String,
|
||||
mediaRef: String,
|
||||
): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = File(path)
|
||||
if (!file.isFile() || file.length() <= 0) {
|
||||
return@withContext Result.failure(IllegalStateException("empty file"))
|
||||
}
|
||||
val sha = Sha256()
|
||||
file.inputStream().use { ins ->
|
||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = ins.read(buf)
|
||||
if (n < 0) break
|
||||
if (n > 0) sha.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/media")
|
||||
.headers(
|
||||
authHeaders()
|
||||
.newBuilder()
|
||||
.add("X-Iris-Media-Ref", mediaRef)
|
||||
.add("X-Iris-Media-Kind", kind)
|
||||
.add("X-Iris-Media-Filename", filename)
|
||||
.add("X-Iris-Media-Sha256", sha.hex())
|
||||
.build(),
|
||||
).post(file.asRequestBody(mime.toMediaType()))
|
||||
.build()
|
||||
try {
|
||||
mediaClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
val frame = parseFrame(body)
|
||||
if (response.isSuccessful) {
|
||||
val p = frame?.payloadAs<MediaUploadAckPayload>()
|
||||
if (p != null && p.ok) {
|
||||
Result.success(p.mediaRef)
|
||||
} else {
|
||||
Result.failure(IllegalStateException("upload rejected by server"))
|
||||
}
|
||||
} else {
|
||||
val e = frame?.payloadAs<ErrorPayload>()
|
||||
Result.failure(
|
||||
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Network failure mid-upload: report, don't throw (the caller
|
||||
// is a fire-and-forget coroutine — an uncaught exception kills
|
||||
// the app process).
|
||||
IrisLog.w("media upload failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
|
||||
* response body is the file, streamed to [onChunk] (write to cache).
|
||||
*/
|
||||
suspend fun pullMedia(
|
||||
mediaId: String,
|
||||
onChunk: suspend (ByteArray) -> Unit,
|
||||
): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Server-controlled id: reject path-traversal / URL-breaking
|
||||
// values before they reach the request line (M-2 / S-1).
|
||||
if (!isValidMediaId(mediaId)) {
|
||||
return@withContext Result.failure(IllegalStateException("invalid media id"))
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/media/$mediaId")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
try {
|
||||
mediaClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
|
||||
Result.failure(
|
||||
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
val source = response.body?.source() ?: throw IOException("empty body")
|
||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = source.read(buf)
|
||||
if (n < 0) break
|
||||
if (n == 0) continue
|
||||
onChunk(buf.copyOfRange(0, n))
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Network failure before/while opening the pull: report, don't
|
||||
// throw (fire-and-forget caller — uncaught = process death).
|
||||
IrisLog.w("media pull failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
|
||||
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
|
||||
* (25 s) — it would kill both receive paths while they are simply waiting
|
||||
* for the next byte, so the streaming clients override it. The read timeout
|
||||
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
|
||||
* comment every 15 s; a healthy poll answers within 25 s).
|
||||
*/
|
||||
|
||||
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
|
||||
* heartbeat (detects a dead connection within 45 s). */
|
||||
internal fun OkHttpClient.streamClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(45_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
|
||||
* hold + 15 s margin. */
|
||||
internal fun OkHttpClient.pollClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(40_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
internal fun OkHttpClient.healthClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(2_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Media transfers (upload/pull) can take a while on large files. */
|
||||
internal fun OkHttpClient.mediaClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(300_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
@@ -0,0 +1,96 @@
|
||||
package iris.net
|
||||
|
||||
import iris.protocol.IrisJson
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Latest-release check (docs/04 hello.ack note): the repo is public on Gitea,
|
||||
* so the app can ask for the newest release tag without any token. Settings
|
||||
* shows "vX.Y.Z available" when the running build is older.
|
||||
*
|
||||
* Best-effort by design: any failure (offline, DNS, rate limit) just yields
|
||||
* `null` — the check must never surface an error in the UI.
|
||||
*/
|
||||
object ReleaseCheck {
|
||||
const val LATEST_RELEASE_URL =
|
||||
"https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest"
|
||||
|
||||
/**
|
||||
* One shared client for the process lifetime: an OkHttpClient owns a
|
||||
* thread pool and connection pool, so it must not be rebuilt per screen
|
||||
* open (and never needs explicit shutdown — the pools idle out). Short
|
||||
* timeouts so a black-holed network can't linger the LaunchedEffect.
|
||||
*/
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* The latest release version (tag without the leading "v"), or `null`
|
||||
* when the check could not be performed.
|
||||
*/
|
||||
suspend fun latestVersion(): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(LATEST_RELEASE_URL)
|
||||
.get()
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).execute().use { response: Response ->
|
||||
if (!response.isSuccessful) {
|
||||
IrisLog.d("ReleaseCheck: HTTP ${response.code}")
|
||||
return@withContext null
|
||||
}
|
||||
val body = response.body?.string() ?: return@withContext null
|
||||
val tag =
|
||||
IrisJson
|
||||
.instance
|
||||
.parseToJsonElement(body)
|
||||
.jsonObject
|
||||
.get("tag_name")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
.orEmpty()
|
||||
tag.removePrefix("v").ifBlank { null }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.d("ReleaseCheck: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [latest] is a newer release than [running]. Numeric
|
||||
* component-wise comparison (so "0.1.10" > "0.1.9"); anything that does
|
||||
* not parse as dotted numbers is treated as "not newer" — the hint is
|
||||
* best-effort and must never fire for dev builds ahead of the latest
|
||||
* release.
|
||||
*/
|
||||
fun isNewer(
|
||||
latest: String,
|
||||
running: String,
|
||||
): Boolean {
|
||||
val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
|
||||
val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
|
||||
if (a.isEmpty() || b.isEmpty()) return false
|
||||
val n = maxOf(a.size, b.size)
|
||||
for (i in 0 until n) {
|
||||
val x = a.getOrElse(i) { 0 }
|
||||
val y = b.getOrElse(i) { 0 }
|
||||
if (x != y) return x > y
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package iris.net
|
||||
|
||||
import java.security.KeyStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
|
||||
*
|
||||
* A gateway behind `IRIS_HTTP_CERT` may present a
|
||||
* self-signed certificate the platform doesn't trust. Instead of forcing the
|
||||
* user to install it into the system trust store, the app shows the
|
||||
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
|
||||
* the user confirms it, the fingerprint is pinned in secure storage and
|
||||
* [PinningTrustManager] accepts exactly that certificate from then on.
|
||||
*
|
||||
* Hostname verification is NOT bypassed: OkHttp runs its own hostname check
|
||||
* on top of the trust manager, so a pinned cert still has to match the URL's
|
||||
* host. A *changed* certificate (different fingerprint) fails again with
|
||||
* [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH
|
||||
* host key.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The gateway presented a certificate the platform doesn't trust and that
|
||||
* doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the
|
||||
* user must confirm. Thrown by [PinningTrustManager] during the handshake;
|
||||
* the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired].
|
||||
*/
|
||||
class TlsFingerprintRequired(
|
||||
val fingerprint: String,
|
||||
) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)")
|
||||
|
||||
/**
|
||||
* SHA-256 of the certificate's DER encoding, colon-separated byte pairs
|
||||
* (SSH host-key style) — the string the user verifies on the gateway host.
|
||||
*/
|
||||
fun certFingerprint(cert: X509Certificate): String =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest(cert.encoded)
|
||||
.joinToString(":") { String.format("%02X", it) }
|
||||
|
||||
/**
|
||||
* Wraps the platform default trust manager:
|
||||
* - CA-signed certs behave exactly as before (the default manager decides).
|
||||
* - A cert the default manager REJECTS is accepted only when its fingerprint
|
||||
* equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh
|
||||
* pin is picked up without rebuilding the client).
|
||||
* - Anything else fails with [TlsFingerprintRequired] carrying the
|
||||
* presented fingerprint, so the UI can offer the confirm dialog.
|
||||
*/
|
||||
class PinningTrustManager(
|
||||
private val pinnedFingerprint: () -> String,
|
||||
) : X509TrustManager {
|
||||
private val default: X509TrustManager = defaultTrustManager()
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) {
|
||||
default.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) {
|
||||
try {
|
||||
default.checkServerTrusted(chain, authType)
|
||||
} catch (e: CertificateException) {
|
||||
val fp = certFingerprint(chain.first())
|
||||
if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return
|
||||
throw TlsFingerprintRequired(fp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The platform default server trust manager (the JDK's CA store). */
|
||||
fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager)
|
||||
?: error("no X509TrustManager in the default trust store")
|
||||
}
|
||||
|
||||
/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */
|
||||
fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory {
|
||||
val ctx = SSLContext.getInstance("TLS")
|
||||
ctx.init(null, arrayOf<TrustManager>(tm), SecureRandom())
|
||||
return ctx.socketFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport
|
||||
* exception: the trust manager throws it during the handshake and the
|
||||
* JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when
|
||||
* the failure has nothing to do with an untrusted gateway certificate.
|
||||
*/
|
||||
fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? {
|
||||
var t: Throwable? = e
|
||||
var depth = 0
|
||||
while (t != null && depth < 10) {
|
||||
if (t is TlsFingerprintRequired) return t
|
||||
t = t.cause
|
||||
depth++
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package iris.platform
|
||||
|
||||
/** True on the desktop target (M6: big-screen layout, shortcuts, tray). */
|
||||
expect val isDesktop: Boolean
|
||||
expect val isDesktop: Boolean
|
||||
@@ -20,15 +20,64 @@ data class PickedFile(
|
||||
@Composable
|
||||
expect fun MediaFilePicker(onPicked: (PickedFile?) -> Unit)
|
||||
|
||||
/**
|
||||
* Image-only file picker (SAF on Android, FileChooser on desktop). The picked
|
||||
* image is copied into the app's persistent storage; [onPicked] receives the
|
||||
* staged file, or null when the user cancels.
|
||||
*/
|
||||
@Composable
|
||||
expect fun ImageFilePicker(onPicked: (PickedFile?) -> Unit)
|
||||
|
||||
/** Base directory for the media cache (app-specific cache dir). */
|
||||
expect fun mediaCacheBaseDir(): String
|
||||
|
||||
/** Load an image from a local path for display. Null on failure. */
|
||||
expect suspend fun loadMediaImage(path: String): ImageBitmap?
|
||||
|
||||
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
|
||||
expect suspend fun loadScaledImage(
|
||||
path: String,
|
||||
maxSize: Int,
|
||||
): ImageBitmap?
|
||||
|
||||
/**
|
||||
* Decode an image from raw bytes (bundled backdrops), scaled to at most
|
||||
* [maxSize] px on the longest edge. Null on failure.
|
||||
*/
|
||||
expect suspend fun decodeImageBytes(
|
||||
bytes: ByteArray,
|
||||
maxSize: Int,
|
||||
): ImageBitmap?
|
||||
|
||||
/**
|
||||
* Read a bundled backdrop image (shipped in the app) by its [id] (the file
|
||||
* name without extension, e.g. `pexels-yunszyveli-12368637`). Android reads it
|
||||
* from `assets/backdrops/`, desktop from the classpath. Null on failure.
|
||||
*/
|
||||
expect fun readBackdropBytes(id: String): ByteArray?
|
||||
|
||||
/**
|
||||
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
|
||||
* the longest edge. Null on failure. Used for channel icons (stored on the
|
||||
* gateway, synced across devices).
|
||||
*/
|
||||
expect suspend fun encodeImageAsBase64(
|
||||
path: String,
|
||||
maxSize: Int,
|
||||
): String?
|
||||
|
||||
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
|
||||
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
|
||||
|
||||
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
|
||||
@Composable
|
||||
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
|
||||
expect fun MediaPlayerView(
|
||||
media: MediaItem,
|
||||
modifier: Modifier,
|
||||
)
|
||||
|
||||
/** Open a document with the system handler (best effort). */
|
||||
expect fun openDocument(path: String, mime: String)
|
||||
expect fun openDocument(
|
||||
path: String,
|
||||
mime: String,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ package iris.platform
|
||||
* The controller (commonMain) needs to know whether the app is in the
|
||||
* foreground (to decide between an in-app banner and a system notification)
|
||||
* and to post a system notification when a `notification` frame arrives while
|
||||
* the app is backgrounded but the WS is still live.
|
||||
* the app is backgrounded but the gateway connection is still live.
|
||||
*/
|
||||
|
||||
/** True when the app's UI is visible (Android: activity resumed). */
|
||||
@@ -28,4 +28,13 @@ expect fun postSystemNotification(
|
||||
* Register the active controller with the platform bridge (Android: so the
|
||||
* FCM / ntfy services can reach it). No-op on desktop.
|
||||
*/
|
||||
expect fun setActiveController(controller: Any?)
|
||||
expect fun setActiveController(controller: Any?)
|
||||
|
||||
/**
|
||||
* Align the ntfy listener with the paired gateway's push backend (Android:
|
||||
* start/stop the listener foreground service — and with it the permanent
|
||||
* "Listening for messages" notification; no-op on desktop). The listener
|
||||
* only runs when the gateway actually pushes via ntfy; FCM gateways need
|
||||
* no persistent listener.
|
||||
*/
|
||||
expect fun syncNtfyListener(backend: String)
|
||||
@@ -0,0 +1,13 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/**
|
||||
* A "Scan QR" button that launches the platform QR scanner and delivers the
|
||||
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
|
||||
*
|
||||
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
|
||||
* (the Connect screen hides it there).
|
||||
*/
|
||||
@Composable
|
||||
expect fun QrScanButton(onResult: (String?) -> Unit)
|
||||
@@ -0,0 +1,13 @@
|
||||
package iris.platform
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
|
||||
/**
|
||||
* Persistent app-data directory. Unlike the media cache dir, it is not wiped
|
||||
* by the system or by the user clearing the app cache — it holds the SQLite
|
||||
* local cache (messages / channels / meta).
|
||||
*/
|
||||
expect fun appDataDir(): String
|
||||
|
||||
/** Open the SQLite driver for the local cache database. */
|
||||
expect fun createCacheDriver(): SqlDriver
|
||||
@@ -0,0 +1,18 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* M9: renders [html] in an in-app WebView with JavaScript enabled.
|
||||
*
|
||||
* Platform actuals: Android → the platform WebView; desktop → JCEF (KCEF),
|
||||
* whose Chromium binary is downloaded on first use (the caller shows a
|
||||
* progress indicator until it is ready).
|
||||
*/
|
||||
@Composable
|
||||
expect fun PlatformWebView(html: String, modifier: Modifier)
|
||||
|
||||
/** Consumes the system back button (Android) while applied; no-op elsewhere. */
|
||||
@Composable
|
||||
expect fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier
|
||||
@@ -3,6 +3,7 @@ package iris.protocol
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -11,48 +12,51 @@ import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
|
||||
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
|
||||
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
|
||||
* progress/end, commentary, reasoning (on message / message.stop).
|
||||
* See docs/04-wire-protocol.md. Defines all frame types and payloads:
|
||||
* hello/hello.ack, message (send + streaming), tool cards, commentary,
|
||||
* reasoning, channels, media, notifications, sync, and error frames.
|
||||
*/
|
||||
|
||||
const val PROTOCOL_VERSION = 1
|
||||
|
||||
object IrisJson {
|
||||
val instance: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
val instance: Json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame type constants ────────────────────────────────────────────────
|
||||
|
||||
const val TYPE_HELLO = "hello"
|
||||
const val TYPE_HELLO_ACK = "hello.ack"
|
||||
const val TYPE_MESSAGE = "message"
|
||||
const val TYPE_MESSAGE_SEND = "message.send"
|
||||
const val TYPE_ERROR = "error"
|
||||
const val TYPE_PING = "ping"
|
||||
const val TYPE_PONG = "pong"
|
||||
const val TYPE_TYPING = "typing"
|
||||
|
||||
// M2 — streaming / tools / commentary
|
||||
const val TYPE_MESSAGE_START = "message.start"
|
||||
const val TYPE_MESSAGE_UPDATE = "message.update"
|
||||
const val TYPE_MESSAGE_STOP = "message.stop"
|
||||
|
||||
// Message deletion (app requests; broadcast to all devices)
|
||||
const val TYPE_MESSAGE_DELETE = "message.delete"
|
||||
const val TYPE_MESSAGE_DELETED = "message.deleted"
|
||||
const val TYPE_TOOL_START = "tool.start"
|
||||
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
||||
const val TYPE_TOOL_END = "tool.end"
|
||||
|
||||
// Agent todo list (live planning state; ephemeral, never outboxed — a
|
||||
// reconnecting device re-learns it from the snapshot after hello.ack).
|
||||
const val TYPE_TODO_UPDATE = "todo.update"
|
||||
|
||||
const val TYPE_COMMENTARY = "commentary"
|
||||
|
||||
// M4 — media (upload / offer / pull)
|
||||
const val TYPE_MEDIA_UPLOAD_START = "media.upload.start"
|
||||
const val TYPE_MEDIA_UPLOAD_END = "media.upload.end"
|
||||
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
|
||||
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
||||
const val TYPE_MEDIA_OFFER = "media.offer"
|
||||
const val TYPE_MEDIA_PULL = "media.pull"
|
||||
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||
|
||||
// M5 — push / notifications / read receipt / gateway status
|
||||
const val TYPE_NOTIFICATION = "notification"
|
||||
@@ -64,6 +68,9 @@ const val TYPE_STATUS = "status"
|
||||
const val TYPE_CHANNEL_CREATE = "channel.create"
|
||||
const val TYPE_CHANNEL_RENAME = "channel.rename"
|
||||
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
||||
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||
const val TYPE_CHANNEL_ICON = "channel.icon"
|
||||
const val TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
|
||||
const val TYPE_CHANNEL_DELETE = "channel.delete"
|
||||
const val TYPE_CHANNEL_CREATED = "channel.created"
|
||||
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||
@@ -71,23 +78,22 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted"
|
||||
const val TYPE_CHANNEL_LIST = "channel.list"
|
||||
const val TYPE_SEARCH = "search"
|
||||
const val TYPE_SEARCH_RESULTS = "search.results"
|
||||
|
||||
// Slash-command catalog (the composer's "/" drawer)
|
||||
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
|
||||
|
||||
// Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
|
||||
const val TYPE_PICKER_CHOICE = "picker.choice"
|
||||
const val TYPE_PICKER_SELECT = "picker.select"
|
||||
const val TYPE_SYNC = "sync"
|
||||
const val TYPE_SYNC_DONE = "sync.done"
|
||||
|
||||
// ── 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"
|
||||
const val TYPE_HISTORY = "history"
|
||||
|
||||
// M4 — media kinds (docs/07 §7.1)
|
||||
const val KIND_IMAGE = "image"
|
||||
const val KIND_AUDIO = "audio"
|
||||
const val KIND_VIDEO = "video"
|
||||
const val KIND_DOCUMENT = "document"
|
||||
const val KIND_VOICE = "voice"
|
||||
|
||||
// ── Roles ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -103,6 +109,11 @@ data class Frame(
|
||||
val type: String,
|
||||
@SerialName("chat_id") val chatId: String? = null,
|
||||
@SerialName("thread_id") val threadId: String? = null,
|
||||
/** Outbox cursor this frame was parked under. Set only on frames
|
||||
* replayed by `sync` (live frames carry none) — the app skips
|
||||
* re-notifying replayed frames with `cursor <= lastPushedCursor`
|
||||
* (they already woke the device via push, docs/08 §8.7). */
|
||||
val cursor: Long? = null,
|
||||
val payload: JsonElement = JsonObject(emptyMap()),
|
||||
) {
|
||||
/** Payload as a JSON object (the wire format); parse per-type with
|
||||
@@ -115,18 +126,6 @@ data class Frame(
|
||||
}
|
||||
}
|
||||
|
||||
// ── hello (app -> server) ───────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class HelloPayload(
|
||||
val token: String,
|
||||
@SerialName("device_id") val deviceId: String,
|
||||
@SerialName("device_name") val deviceName: String,
|
||||
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
|
||||
@SerialName("fcm_token") val fcmToken: String? = null,
|
||||
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||
)
|
||||
|
||||
// ── hello.ack (server -> app) ───────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -136,9 +135,13 @@ data class ServerCaps(
|
||||
val tools: Boolean = false,
|
||||
val media: Boolean = false,
|
||||
val search: Boolean = false,
|
||||
/** The gateway answers `commands.catalog` (the composer's "/" drawer). */
|
||||
@SerialName("commands_catalog") val commandsCatalog: Boolean = false,
|
||||
val push: String = "fcm",
|
||||
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
||||
val pickers: Boolean = false,
|
||||
/** Release version of the gateway plugin (repo-root VERSION file). */
|
||||
@SerialName("app_version") val appVersion: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -149,6 +152,24 @@ data class ChannelInfo(
|
||||
@SerialName("is_default") val isDefault: Boolean = false,
|
||||
@SerialName("parent_chat_id") val parentChatId: String? = null,
|
||||
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);
|
||||
* the name is a derived title, upgraded by the LLM via channel.renamed. */
|
||||
val auto: Boolean = false,
|
||||
/** Cosmetic favorite flag (sorts to the top of the channel list). */
|
||||
val favorite: Boolean = false,
|
||||
/** Cosmetic icon: a base64-encoded image (PNG/JPEG), or null for the
|
||||
* auto-generated letter avatar. */
|
||||
val icon: String? = null,
|
||||
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
|
||||
* auto-generated name-hash color. */
|
||||
val color: String? = null,
|
||||
/** Automation channel: read-only for the user. It only receives
|
||||
* gateway-originated output (cron jobs, webhooks); the app hides the
|
||||
* composer and the gateway rejects direct sends into it. */
|
||||
val automation: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -156,10 +177,35 @@ data class HelloAckPayload(
|
||||
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
|
||||
@SerialName("sync_cursor") val syncCursor: Long = 0,
|
||||
val channels: List<ChannelInfo> = emptyList(),
|
||||
/** M5: highest outbox cursor already delivered to THIS device via the
|
||||
* push backend (0 = never). Sync-replayed frames at/below it must not
|
||||
* re-post system notifications (dedupe, docs/08 §8.7). */
|
||||
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
|
||||
/** 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) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Structured runtime metadata for the app's footer (mirror of the gateway's
|
||||
* `runtime` object). The gateway ALWAYS sends this on final assistant
|
||||
* messages; whether/what is shown is a per-app setting (Settings → Runtime
|
||||
* footer), NOT a hermes config. Fields are absent when the data is
|
||||
* unavailable (local models have no cost, etc.).
|
||||
*/
|
||||
@Serializable
|
||||
data class RuntimeMeta(
|
||||
val model: String? = null,
|
||||
@SerialName("context_pct") val contextPct: Int? = null,
|
||||
val cwd: String? = null,
|
||||
val latency: Double? = null,
|
||||
val cost: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessagePayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
@@ -169,6 +215,7 @@ data class MessagePayload(
|
||||
@SerialName("reply_to") val replyTo: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val runtime: RuntimeMeta? = null,
|
||||
val ts: Long? = null,
|
||||
val media: List<MediaRef> = emptyList(),
|
||||
)
|
||||
@@ -185,21 +232,6 @@ data class MediaRef(
|
||||
val filename: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaUploadStartPayload(
|
||||
@SerialName("media_ref") val mediaRef: String,
|
||||
val kind: String,
|
||||
val mime: String,
|
||||
val filename: String,
|
||||
val size: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaUploadEndPayload(
|
||||
@SerialName("media_ref") val mediaRef: String,
|
||||
@SerialName("sha256") val sha256: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaUploadAckPayload(
|
||||
val ok: Boolean,
|
||||
@@ -216,14 +248,6 @@ data class MediaOfferPayload(
|
||||
@SerialName("message_id") val messageId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaPullPayload(
|
||||
@SerialName("media_id") val mediaId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MediaPullEndPayload(val ok: Boolean)
|
||||
|
||||
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -245,6 +269,7 @@ data class MessageStopPayload(
|
||||
val reasoning: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val runtime: RuntimeMeta? = null,
|
||||
val ts: Long? = null,
|
||||
)
|
||||
|
||||
@@ -256,6 +281,9 @@ data class ToolStartPayload(
|
||||
val name: String,
|
||||
val preview: String? = null,
|
||||
val args: JsonElement? = null,
|
||||
/** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji);
|
||||
* absent for unknown tools — the UI falls back to its default. */
|
||||
val emoji: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -274,6 +302,30 @@ data class ToolEndPayload(
|
||||
@SerialName("output_preview") val outputPreview: String? = null,
|
||||
)
|
||||
|
||||
// ── todo.update (server -> app): the agent's live todo list ────────────────
|
||||
|
||||
/** One item of the agent's todo list (hermes `todo` tool). [status] is one
|
||||
* of [TODO_PENDING], [TODO_IN_PROGRESS], [TODO_COMPLETED], [TODO_CANCELLED]. */
|
||||
@Serializable
|
||||
data class TodoItem(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val status: String,
|
||||
)
|
||||
|
||||
const val TODO_PENDING = "pending"
|
||||
const val TODO_IN_PROGRESS = "in_progress"
|
||||
const val TODO_COMPLETED = "completed"
|
||||
const val TODO_CANCELLED = "cancelled"
|
||||
|
||||
/** The agent's FULL current todo list for a lane (last-write-wins; the
|
||||
* gateway emits it whenever the `todo` tool completes — the tool result is
|
||||
* authoritative even for merge writes — and as a snapshot on connect). */
|
||||
@Serializable
|
||||
data class TodoUpdatePayload(
|
||||
val todos: List<TodoItem> = emptyList(),
|
||||
)
|
||||
|
||||
// ── M2: commentary frame (server -> app) ────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
@@ -289,18 +341,23 @@ data class MessageSendPayload(
|
||||
val text: String,
|
||||
@SerialName("reply_to") val replyTo: String? = null,
|
||||
@SerialName("media_refs") val mediaRefs: List<String> = emptyList(),
|
||||
/** Ask the gateway to mint a fresh thread for this message (auto-
|
||||
* threading; only honored in a channel's flat lane, docs/06 §6.3). */
|
||||
@SerialName("auto_thread") val autoThread: Boolean = false,
|
||||
)
|
||||
|
||||
// ── typing / error / ping ───────────────────────────────────────────────
|
||||
// ── typing / error ──────────────────────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class TypingPayload(val on: Boolean)
|
||||
data class TypingPayload(
|
||||
val on: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ErrorPayload(val code: String, val message: String)
|
||||
|
||||
@Serializable
|
||||
data class PingPayload(val ts: Long? = null)
|
||||
data class ErrorPayload(
|
||||
val code: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
// ── M3: channel directory (app -> server requests) ──────────────────────
|
||||
|
||||
@@ -312,13 +369,37 @@ data class ChannelCreatePayload(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelRenamePayload(val name: String)
|
||||
data class ChannelRenamePayload(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
|
||||
data class ChannelFavoritePayload(
|
||||
val on: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String)
|
||||
data class ChannelSetAutomationPayload(
|
||||
val on: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelIconPayload(
|
||||
/** base64-encoded image, or null to clear the icon. */
|
||||
val icon: String? = null,
|
||||
/** "#RRGGBB" avatar color, or null to clear it. */
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelListPayload(
|
||||
val channels: List<ChannelInfo> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChannelDeletedPayload(
|
||||
@SerialName("chat_id") val chatId: String,
|
||||
)
|
||||
|
||||
// ── M3: search ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -348,23 +429,97 @@ data class SearchResultsPayload(
|
||||
val hits: List<SearchHit> = emptyList(),
|
||||
)
|
||||
|
||||
// ── commands.catalog (slash-command catalog for the "/" drawer) ──────────
|
||||
|
||||
/** One slash command from the gateway catalog. [name]/[aliases] carry the
|
||||
* leading slash ("/new", "/reset"); the app fuzzy-matches client-side. */
|
||||
@Serializable
|
||||
data class SlashCommand(
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
@SerialName("args_hint") val argsHint: String = "",
|
||||
val category: String = "",
|
||||
val aliases: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CommandsCatalogPayload(
|
||||
val commands: List<SlashCommand> = emptyList(),
|
||||
)
|
||||
|
||||
// ── picker.choice / picker.select (interactive slash-command menus) ────────
|
||||
|
||||
/** One option in a choice picker. [isCurrent] marks the active value (the
|
||||
* UI shows a ✓ on it, like Telegram's inline keyboards). */
|
||||
@Serializable
|
||||
data class PickerChoice(
|
||||
val value: String,
|
||||
val label: String = value,
|
||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
/** An interactive choice picker (one tap → one value), sent by finite-choice
|
||||
* slash commands (/reasoning, /fast, …). The app renders it as a card with
|
||||
* buttons and answers via [pickerSelectFrame]. */
|
||||
@Serializable
|
||||
data class PickerChoicePayload(
|
||||
@SerialName("picker_id") val pickerId: String,
|
||||
val title: String,
|
||||
val choices: List<PickerChoice> = emptyList(),
|
||||
)
|
||||
|
||||
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
data class SyncPayload(val cursor: Long)
|
||||
data class SyncPayload(
|
||||
val cursor: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SyncDonePayload(val cursor: Long)
|
||||
data class SyncDonePayload(
|
||||
val cursor: Long,
|
||||
)
|
||||
|
||||
// ── history (full message history for a chat/thread) ────────────────────
|
||||
|
||||
/** A final message in a history page (user echo / assistant reply). */
|
||||
@Serializable
|
||||
data class HistoryMessage(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
val role: String,
|
||||
val text: String,
|
||||
val reasoning: String? = null,
|
||||
val model: String? = null,
|
||||
val tokens: Int? = null,
|
||||
val runtime: RuntimeMeta? = null,
|
||||
val ts: Long? = null,
|
||||
// Nullable: the schema says array, but older gateways sent
|
||||
// `"media": null` for streaming finals — a null there used to break
|
||||
// deserialization of the whole history page (silently dropping it).
|
||||
val media: List<MediaRef>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HistoryPayload(
|
||||
val messages: List<HistoryMessage> = emptyList(),
|
||||
@SerialName("has_more") val hasMore: Boolean = false,
|
||||
@SerialName("oldest_message_id") val oldestMessageId: String? = null,
|
||||
)
|
||||
|
||||
// ── message deletion ──────────────────────────────────────────────────────
|
||||
|
||||
/** The message(s) removed from a chat/thread (response + broadcast). */
|
||||
@Serializable
|
||||
data class MessageDeletedPayload(
|
||||
@SerialName("message_ids") val messageIds: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
// ── M5: push / notifications ────────────────────────────────────────────
|
||||
|
||||
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
||||
const val NOTIF_MESSAGE = "message"
|
||||
const val NOTIF_APPROVAL = "approval"
|
||||
const val NOTIF_CLARIFY = "clarify"
|
||||
const val NOTIF_CRON = "cron"
|
||||
const val NOTIF_CHANNEL = "channel"
|
||||
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
|
||||
|
||||
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */
|
||||
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
|
||||
@@ -393,83 +548,126 @@ data class ReadReceiptPayload(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StatusPayload(val state: String)
|
||||
data class StatusPayload(
|
||||
val state: String,
|
||||
)
|
||||
|
||||
// ── Frame builders ──────────────────────────────────────────────────────
|
||||
|
||||
fun helloFrame(
|
||||
token: String,
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
fcmToken: String? = null,
|
||||
ntfyTopic: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_HELLO,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
HelloPayload.serializer(),
|
||||
HelloPayload(
|
||||
token = token,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName,
|
||||
fcmToken = fcmToken,
|
||||
ntfyTopic = ntfyTopic,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
fun messageSendFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
text: String,
|
||||
threadId: String? = null,
|
||||
mediaRefs: List<String> = emptyList(),
|
||||
autoThread: Boolean = false,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MESSAGE_SEND,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MessageSendPayload.serializer(),
|
||||
MessageSendPayload(text = text, mediaRefs = mediaRefs),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
MessageSendPayload.serializer(),
|
||||
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
|
||||
),
|
||||
)
|
||||
|
||||
fun pingFrame(): Frame =
|
||||
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
|
||||
|
||||
// ── M3 frame builders ───────────────────────────────────────────────────
|
||||
|
||||
fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame =
|
||||
fun channelCreateFrame(
|
||||
id: Int,
|
||||
name: String,
|
||||
kind: String = "channel",
|
||||
parentChatId: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_CREATE,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelCreatePayload.serializer(),
|
||||
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelCreatePayload.serializer(),
|
||||
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
|
||||
fun channelRenameFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
name: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_RENAME,
|
||||
chatId = chatId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
ChannelRenamePayload.serializer(),
|
||||
ChannelRenamePayload(name = name),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelRenamePayload.serializer(),
|
||||
ChannelRenamePayload(name = name),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
|
||||
fun channelSetDefaultFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
|
||||
|
||||
fun channelDeleteFrame(id: Int, chatId: String): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
|
||||
fun channelFavoriteFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_FAVORITE,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelFavoritePayload.serializer(),
|
||||
ChannelFavoritePayload(on = on),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelListFrame(id: Int): Frame =
|
||||
Frame(id = id, type = TYPE_CHANNEL_LIST)
|
||||
fun channelSetAutomationFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
on: Boolean,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_SET_AUTOMATION,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelSetAutomationPayload.serializer(),
|
||||
ChannelSetAutomationPayload(on = on),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelIconFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
icon: String?,
|
||||
color: String?,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_CHANNEL_ICON,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ChannelIconPayload.serializer(),
|
||||
ChannelIconPayload(icon = icon, color = color),
|
||||
),
|
||||
)
|
||||
|
||||
fun channelDeleteFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
): Frame = Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
|
||||
|
||||
fun channelListFrame(id: Int): Frame = Frame(id = id, type = TYPE_CHANNEL_LIST)
|
||||
|
||||
fun searchFrame(
|
||||
id: Int,
|
||||
@@ -483,68 +681,101 @@ fun searchFrame(
|
||||
type = TYPE_SEARCH,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
SearchPayload.serializer(),
|
||||
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
|
||||
),
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
SearchPayload.serializer(),
|
||||
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
|
||||
),
|
||||
)
|
||||
|
||||
fun syncFrame(id: Int, cursor: Long): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_SYNC,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
SyncPayload.serializer(),
|
||||
SyncPayload(cursor = cursor),
|
||||
),
|
||||
)
|
||||
/** Request the gateway's slash-command catalog (the composer's "/" drawer).
|
||||
* Answered by a `commands.catalog` frame carrying the same id. */
|
||||
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
|
||||
|
||||
// ── M4 frame builders ────────────────────────────────────────────────────
|
||||
|
||||
fun mediaUploadStartFrame(
|
||||
/** 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,
|
||||
mediaRef: String,
|
||||
kind: String,
|
||||
mime: String,
|
||||
filename: String,
|
||||
size: Long,
|
||||
pickerId: String,
|
||||
value: String,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_UPLOAD_START,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadStartPayload.serializer(),
|
||||
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
|
||||
),
|
||||
type = TYPE_PICKER_SELECT,
|
||||
payload =
|
||||
buildJsonObject {
|
||||
put("picker_id", pickerId)
|
||||
put("value", value)
|
||||
},
|
||||
)
|
||||
|
||||
fun mediaUploadEndFrame(id: Int, mediaRef: String, sha256: String): Frame =
|
||||
fun syncFrame(
|
||||
id: Int,
|
||||
cursor: Long,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_UPLOAD_END,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MediaUploadEndPayload.serializer(),
|
||||
MediaUploadEndPayload(mediaRef, sha256),
|
||||
),
|
||||
type = TYPE_SYNC,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
SyncPayload.serializer(),
|
||||
SyncPayload(cursor = cursor),
|
||||
),
|
||||
)
|
||||
|
||||
fun mediaPullFrame(id: Int, mediaId: String): Frame =
|
||||
/** Request a page of full message history for a chat/thread (initial open /
|
||||
* restart restore). [beforeMessageId] returns the page older than that id
|
||||
* (null = newest page). */
|
||||
fun historyFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
threadId: String? = null,
|
||||
beforeMessageId: String? = null,
|
||||
limit: Int = 50,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MEDIA_PULL,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
MediaPullPayload.serializer(),
|
||||
MediaPullPayload(mediaId),
|
||||
),
|
||||
type = TYPE_HISTORY,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload =
|
||||
buildJsonObject {
|
||||
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
|
||||
put("limit", limit)
|
||||
},
|
||||
)
|
||||
|
||||
/** Request deletion of the given message(s) in a chat/thread. The server
|
||||
* removes them from the outbox and broadcasts `message.deleted`. */
|
||||
fun messageDeleteFrame(
|
||||
id: Int,
|
||||
chatId: String,
|
||||
messageIds: List<String>,
|
||||
threadId: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
id = id,
|
||||
type = TYPE_MESSAGE_DELETE,
|
||||
chatId = chatId,
|
||||
threadId = threadId,
|
||||
payload =
|
||||
buildJsonObject {
|
||||
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
|
||||
},
|
||||
)
|
||||
|
||||
// ── M5 frame builders ───────────────────────────────────────────────────
|
||||
|
||||
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
|
||||
fun fcmRegisterFrame(
|
||||
fcmToken: String? = null,
|
||||
ntfyTopic: String? = null,
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_FCM_REGISTER,
|
||||
payload = IrisJson.instance.encodeToJsonElement(
|
||||
FcmRegisterPayload.serializer(),
|
||||
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
||||
),
|
||||
)
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
FcmRegisterPayload.serializer(),
|
||||
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package iris.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Opens [onRightClick] on a right (secondary) mouse-button press. Desktop-only
|
||||
* behavior; on touch platforms this is a no-op (the context menu is opened via
|
||||
* long-press instead).
|
||||
*/
|
||||
expect fun Modifier.rightClick(onRightClick: () -> Unit): Modifier
|
||||
@@ -0,0 +1,212 @@
|
||||
package iris.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.CodeOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.snipme.highlights.Highlights
|
||||
import iris.platform.PlatformWebView
|
||||
import iris.platform.handleSystemBack
|
||||
import iris.ui.theme.IrisColors
|
||||
|
||||
/**
|
||||
* M9: HTML artifact support. When the agent emits a complete HTML document
|
||||
* (HTML + CSS + JS) in a fenced code block, the bubble renders an artifact
|
||||
* card instead of plain highlighted code. "Preview" opens the document in a
|
||||
* full-screen in-app WebView with JavaScript enabled, so interactive scripts
|
||||
* run inside the app.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True when a fenced code block should be rendered as a runnable HTML
|
||||
* artifact: an explicit ```html / ```htm language tag, or an untagged block
|
||||
* that starts with a complete HTML document.
|
||||
*/
|
||||
fun isHtmlArtifact(language: String?, code: String): Boolean {
|
||||
val lang = language?.trim()?.lowercase()
|
||||
if (lang == "html" || lang == "htm") return true
|
||||
if (lang != null) return false
|
||||
val head = code.trimStart().take(64).lowercase()
|
||||
return head.startsWith("<!doctype html") || head.startsWith("<html")
|
||||
}
|
||||
|
||||
/** The document's <title> (card / overlay header), or null when absent. */
|
||||
fun htmlTitle(code: String): String? =
|
||||
TITLE_REGEX.find(code)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private val TITLE_REGEX =
|
||||
Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||
|
||||
/** The artifact currently open in the full-screen preview (null = closed). */
|
||||
data class ArtifactPreviewData(val html: String, val title: String?)
|
||||
|
||||
/**
|
||||
* Process-wide preview state: the artifact card (deep in the message list)
|
||||
* writes here, the overlay at the app root reads it.
|
||||
*/
|
||||
object ArtifactPreview {
|
||||
private val _current = mutableStateOf<ArtifactPreviewData?>(null)
|
||||
val current: State<ArtifactPreviewData?> get() = _current
|
||||
|
||||
fun show(html: String, title: String?) {
|
||||
_current.value = ArtifactPreviewData(html, title)
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
_current.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact card shown in place of a highlighted HTML code block: title row
|
||||
* with a Preview button (opens the full-screen overlay) and a code toggle
|
||||
* (reveals the highlighted source with its copy button).
|
||||
*/
|
||||
@Composable
|
||||
fun HtmlArtifactCard(
|
||||
code: String,
|
||||
style: TextStyle,
|
||||
highlights: Highlights.Builder,
|
||||
) {
|
||||
val title = remember(code) { htmlTitle(code) }
|
||||
var showCode by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = IrisColors.panel,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 6.dp, end = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
tint = IrisColors.statusGreen,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title ?: "HTML artifact",
|
||||
color = IrisColors.textBright,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "Interactive HTML preview",
|
||||
color = IrisColors.textMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { ArtifactPreview.show(code, title) },
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 12.dp,
|
||||
vertical = 4.dp,
|
||||
),
|
||||
) {
|
||||
Text("Preview", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
IconButton(onClick = { showCode = !showCode }) {
|
||||
Icon(
|
||||
imageVector = if (showCode) Icons.Filled.CodeOff else Icons.Filled.Code,
|
||||
contentDescription = if (showCode) "Hide code" else "Show code",
|
||||
tint = IrisColors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showCode) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
CodeBlockWithCopy(code = code, language = "html", style = style, highlights = highlights)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen artifact preview overlay (rendered at the app root). Covers the
|
||||
* whole window; the header close button and the system back button (Android)
|
||||
* dismiss it.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtifactPreviewOverlay() {
|
||||
val preview by ArtifactPreview.current
|
||||
if (preview == null) return
|
||||
val data = preview!!
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(IrisColors.background)
|
||||
.handleSystemBack { ArtifactPreview.hide() },
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 2.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
tint = IrisColors.statusGreen,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = data.title ?: "HTML preview",
|
||||
modifier = Modifier.weight(1f),
|
||||
color = IrisColors.textBright,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
IconButton(onClick = { ArtifactPreview.hide() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Close preview",
|
||||
tint = IrisColors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = IrisColors.divider)
|
||||
PlatformWebView(html = data.html, modifier = Modifier.fillMaxSize().weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package iris.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.LinkInteractionListener
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.StreamingMarkdownState
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
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.launch
|
||||
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
|
||||
* tables, task-list checkboxes, and fenced code blocks with language-aware
|
||||
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
||||
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
||||
* 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
|
||||
fun MarkdownText(
|
||||
text: String,
|
||||
color: Color,
|
||||
fontSize: TextUnit,
|
||||
modifier: Modifier = Modifier,
|
||||
// M9: while the message is still streaming, HTML code blocks render as
|
||||
// plain highlighted code — the artifact card (and its runnable preview)
|
||||
// only appears once the message is complete.
|
||||
isStreaming: Boolean = false,
|
||||
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
||||
) {
|
||||
// Static path: transform once per text change (leading blanks stripped,
|
||||
// single newlines → hard breaks).
|
||||
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
|
||||
// Whether this bubble has ever been streamed. Static history messages
|
||||
// skip the reveal pipeline entirely.
|
||||
val hasStreamed = remember { mutableStateOf(isStreaming) }
|
||||
if (isStreaming) hasStreamed.value = true
|
||||
val pipeline =
|
||||
rememberStreamingMarkdownPipeline(
|
||||
text = text,
|
||||
smoothness = streamSmoothness,
|
||||
active = hasStreamed.value,
|
||||
streaming = isStreaming,
|
||||
)
|
||||
// Keep the streaming renderer until the reveal catches up, even after
|
||||
// message.stop — a fast model that dumps the whole text at once still
|
||||
// plays out the typewriter instead of jumping to the full message.
|
||||
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
|
||||
// The app is always dark-themed, so force the dark highlight palette
|
||||
// (isSystemInDarkTheme() is unreliable on desktop).
|
||||
val highlights =
|
||||
remember {
|
||||
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
||||
}
|
||||
val base = TextStyle(color = color, fontSize = fontSize)
|
||||
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
|
||||
// Inline-code span style (mirrors the library's codeSpanStyle): the
|
||||
// monospace inlineCode typography plus the chip background.
|
||||
val codeSpanStyle =
|
||||
base.copy(fontFamily = FontFamily.Monospace, background = inlineCodeBackground).toSpanStyle()
|
||||
// Brief green flash shown on the chip right after a copy, so the tap is
|
||||
// visible feedback (the clipboard write itself is silent).
|
||||
val copiedCodeSpanStyle =
|
||||
base
|
||||
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
||||
.toSpanStyle()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// The text of the inline code most recently copied (null when idle). Read
|
||||
// inside the annotator so a change re-renders the chip with the flash.
|
||||
val copiedCode = remember { mutableStateOf<String?>(null) }
|
||||
// The default CODE_SPAN handler pads inline code with a leading and a
|
||||
// trailing space, which doubled the whitespace around `code` mid-line.
|
||||
// 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
|
||||
// LinkAnnotation carries the click listener; the URL is never opened.
|
||||
val annotator =
|
||||
markdownAnnotator(
|
||||
annotate = { content, child ->
|
||||
when {
|
||||
child.type == MarkdownElementTypes.CODE_SPAN -> {
|
||||
val children = child.children
|
||||
// Drop the surrounding backtick tokens (present as first/last child).
|
||||
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
||||
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
|
||||
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
|
||||
pushStyle(spanStyle)
|
||||
withLink(
|
||||
LinkAnnotation.Url(
|
||||
url = "iris:copy-code",
|
||||
// Keep every interaction state identical to the chip so
|
||||
// hover/press never restyles the inline code.
|
||||
styles =
|
||||
TextLinkStyles(
|
||||
style = spanStyle,
|
||||
focusedStyle = spanStyle,
|
||||
hoveredStyle = spanStyle,
|
||||
pressedStyle = spanStyle,
|
||||
),
|
||||
linkInteractionListener =
|
||||
LinkInteractionListener {
|
||||
clipboard.setText(AnnotatedString(code))
|
||||
copiedCode.value = code
|
||||
scope.launch {
|
||||
delay(600)
|
||||
copiedCode.value = null
|
||||
}
|
||||
},
|
||||
),
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
val colors =
|
||||
markdownColor(
|
||||
text = color,
|
||||
codeBackground = Color.Black.copy(alpha = 0.8f),
|
||||
inlineCodeBackground = inlineCodeBackground,
|
||||
dividerColor = IrisColors.divider,
|
||||
tableBackground = Color.White.copy(alpha = 0.03f),
|
||||
)
|
||||
val typography =
|
||||
markdownTypography(
|
||||
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
||||
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
||||
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
||||
h4 = base.copy(fontWeight = FontWeight.Bold),
|
||||
h5 = base.copy(fontWeight = FontWeight.Bold),
|
||||
h6 = base.copy(fontWeight = FontWeight.Bold),
|
||||
text = base,
|
||||
code = base.copy(fontSize = fontSize * 0.9f, fontFamily = FontFamily.Monospace),
|
||||
inlineCode = base.copy(fontFamily = FontFamily.Monospace),
|
||||
quote = base.copy(fontStyle = FontStyle.Italic),
|
||||
paragraph = base,
|
||||
ordered = base,
|
||||
bullet = base,
|
||||
list = base,
|
||||
textLink =
|
||||
TextLinkStyles(
|
||||
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
||||
),
|
||||
table = base.copy(fontSize = fontSize * 0.95f),
|
||||
)
|
||||
val components =
|
||||
markdownComponents(
|
||||
codeFence = { model ->
|
||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
if (!useStreaming && isHtmlArtifact(language, code)) {
|
||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||
} else {
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
}
|
||||
}
|
||||
},
|
||||
codeBlock = { model ->
|
||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
if (!useStreaming && isHtmlArtifact(language, code)) {
|
||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||
} else {
|
||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||
}
|
||||
}
|
||||
},
|
||||
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
||||
// Material 3 checkbox instead.
|
||||
checkbox = {
|
||||
com.mikepenz.markdown.m3.elements
|
||||
.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
||||
},
|
||||
)
|
||||
if (useStreaming) {
|
||||
if (pipeline.displayed.isEmpty()) {
|
||||
// No text revealed yet (message.start, first tick pending): show
|
||||
// just the cursor so the bubble is visible immediately.
|
||||
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
|
||||
} else {
|
||||
Markdown(
|
||||
streamingMarkdownState = pipeline.state,
|
||||
modifier = modifier,
|
||||
annotator = annotator,
|
||||
colors = colors,
|
||||
typography = typography,
|
||||
components = components,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// retainState: keep the last formatted output visible while the new
|
||||
// text re-parses (async) — no raw-markdown flash between updates.
|
||||
val state = rememberMarkdownState(displayText, retainState = true)
|
||||
Markdown(
|
||||
markdownState = state,
|
||||
modifier = modifier,
|
||||
annotator = annotator,
|
||||
colors = colors,
|
||||
typography = typography,
|
||||
components = components,
|
||||
loading = { m ->
|
||||
// First parse of a (long) message: show the text so the bubble
|
||||
// never goes blank.
|
||||
Text(displayText, modifier = m, color = color, fontSize = fontSize)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
|
||||
* snapshot from the gateway; it is revealed at a steady rate controlled by
|
||||
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
|
||||
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
|
||||
* keep their AST identity, so Compose never re-lays them out — only the
|
||||
* unstable tail re-renders per tick.
|
||||
*
|
||||
* [active] is false for history messages (never streamed): the reveal is
|
||||
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
|
||||
* indicates the message is still receiving updates; once the reveal catches
|
||||
* up and the message is final, the reveal loop stops.
|
||||
*
|
||||
* Returns the parser state, the currently revealed text (read inside the
|
||||
* composition so reveal ticks recompose the caller), and whether the reveal
|
||||
* has caught up with the target.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberStreamingMarkdownPipeline(
|
||||
text: String,
|
||||
smoothness: Float,
|
||||
active: Boolean,
|
||||
streaming: Boolean,
|
||||
): StreamingPipeline {
|
||||
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
|
||||
// a prefix of the revealed text always maps to a prefix of the target, so
|
||||
// the diff between consecutive reveals is a pure append.
|
||||
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
|
||||
val displayed = remember { mutableStateOf("") }
|
||||
val latestTarget = rememberUpdatedState(target)
|
||||
val latestSmoothness = rememberUpdatedState(smoothness)
|
||||
val latestStreaming = rememberUpdatedState(streaming)
|
||||
// Bumped when the target is rewritten (not extended): the parser state is
|
||||
// recreated via key() below and re-seeded with the full text.
|
||||
val generation = remember { mutableIntStateOf(0) }
|
||||
val state = key(generation.value) { rememberStreamingMarkdownState() }
|
||||
LaunchedEffect(state, active) {
|
||||
if (!active) {
|
||||
// Never streamed (history message): reveal everything at once so
|
||||
// the caller falls back to the static renderer immediately.
|
||||
displayed.value = latestTarget.value
|
||||
return@LaunchedEffect
|
||||
}
|
||||
var last = ""
|
||||
while (true) {
|
||||
delay(STREAM_REVEAL_TICK_MS)
|
||||
val t = latestTarget.value
|
||||
val cur = displayed.value
|
||||
if (cur == t) {
|
||||
// Reveal complete: keep ticking only while the message is
|
||||
// still streaming (more text may arrive); otherwise stop so
|
||||
// finished bubbles don't burn battery.
|
||||
if (!latestStreaming.value) return@LaunchedEffect
|
||||
continue
|
||||
}
|
||||
val step =
|
||||
revealStep(
|
||||
remaining = t.length - cur.length,
|
||||
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
|
||||
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
|
||||
)
|
||||
val next = t.take(cur.length + step)
|
||||
displayed.value = next
|
||||
val chunk = appendChunk(last, next)
|
||||
if (chunk == null) {
|
||||
// Rewritten, not extended: recreate the parser state. The
|
||||
// effect restarts (keyed on [state]) and re-seeds it.
|
||||
generation.value++
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (chunk.isNotEmpty()) state.append(chunk)
|
||||
last = next
|
||||
}
|
||||
}
|
||||
return StreamingPipeline(state, displayed.value, displayed.value == target)
|
||||
}
|
||||
|
||||
/** Result of the streaming reveal pipeline. */
|
||||
private data class StreamingPipeline(
|
||||
val state: StreamingMarkdownState,
|
||||
val displayed: String,
|
||||
val done: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Renders a highlighted code block with a copy button in the top-right corner
|
||||
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
|
||||
* Also used by [HtmlArtifactCard] to reveal the source of an HTML artifact.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CodeBlockWithCopy(
|
||||
code: String,
|
||||
language: String?,
|
||||
style: TextStyle,
|
||||
highlights: Highlights.Builder,
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
Box {
|
||||
MarkdownHighlightedCode(code = code, language = language, highlightsBuilder = highlights, style = style)
|
||||
if (code.isNotBlank()) {
|
||||
IconButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(code)) },
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
// MarkdownHighlightedCode insets its background by 8dp top;
|
||||
// match that so the button sits inside the block, not above it.
|
||||
.padding(top = 8.dp, end = 4.dp)
|
||||
.size(28.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy code",
|
||||
tint = Color.White.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package iris.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -13,11 +11,12 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -27,11 +26,16 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.net.TlsFingerprintRequired
|
||||
import iris.platform.QrScanButton
|
||||
import iris.platform.isDesktop
|
||||
import iris.state.IrisController
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.util.PairLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -44,18 +48,49 @@ fun ConnectScreen(
|
||||
prefillUrl: String = "",
|
||||
prefillToken: String = "",
|
||||
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()
|
||||
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
|
||||
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
||||
// the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
|
||||
var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
|
||||
var token by remember { mutableStateOf(prefillToken) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
@@ -69,17 +104,19 @@ fun ConnectScreen(
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(IrisColors.surface)
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(IrisColors.surface)
|
||||
.padding(16.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
label = { Text("Server URL") },
|
||||
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
||||
// Example LAN URL; https:// works too for TLS gateways.
|
||||
placeholder = { Text("http://192.168.1.10:8791") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -89,26 +126,29 @@ fun ConnectScreen(
|
||||
value = token,
|
||||
onValueChange = { token = it },
|
||||
label = { Text("Pairing token") },
|
||||
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||
placeholder = { Text("IRIS_TOKEN (64 hex)") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (!isDesktop) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
QrScanButton { raw ->
|
||||
if (raw != null) {
|
||||
val link = PairLink.parse(raw)
|
||||
if (link != null) {
|
||||
url = link.url
|
||||
token = link.token
|
||||
} else {
|
||||
error = "Couldn't read that QR code."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
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"
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = { doConnect() },
|
||||
enabled = !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
@@ -127,28 +167,49 @@ fun ConnectScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
|
||||
"Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
|
||||
"hermes gateway setup on the gateway host.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** M7: shown while the initial connection is in flight (state == Connecting). */
|
||||
@Composable
|
||||
fun ConnectingScreen(host: String) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
"Connecting to $host…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
package iris.ui.screens
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import iris.AppVersion
|
||||
import iris.net.ReleaseCheck
|
||||
import iris.platform.ImageFilePicker
|
||||
import iris.platform.decodeImageBytes
|
||||
import iris.platform.loadScaledImage
|
||||
import iris.platform.readBackdropBytes
|
||||
import iris.state.IrisController
|
||||
import iris.state.ToolDetail
|
||||
import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.ui.theme.LocalUserTheme
|
||||
import iris.util.STREAM_SMOOTHNESS_MAX
|
||||
import iris.util.STREAM_SMOOTHNESS_MIN
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Settings screen (post-M7): app-level preferences, opened from the channel
|
||||
* drawer / rail. More settings land here over time.
|
||||
*/
|
||||
|
||||
/** Which color the picker dialog is editing. */
|
||||
private enum class PickerTarget { UserBubble, AgentBubble, Background }
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
controller: IrisController,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
||||
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||
val toolDetail by controller.toolDetail.collectAsState()
|
||||
val fontSizeScale by controller.fontSizeScale.collectAsState()
|
||||
val gatewayVersion by controller.gatewayVersion.collectAsState()
|
||||
val theme = LocalUserTheme.current
|
||||
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)) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
) {
|
||||
HeaderIconButton(onClick = onBack) {
|
||||
BackArrow()
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Settings", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Chat",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("🧵 Threads", fontSize = 14.sp)
|
||||
Text(
|
||||
"Show topics; new messages in General start their own topic (AI-named)",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = threadsEnabled,
|
||||
onCheckedChange = { controller.toggleThreads() },
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("⚡ Streaming", fontSize = 14.sp)
|
||||
Text(
|
||||
"Show replies as they are generated",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = streamingEnabled,
|
||||
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 {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("🧠 Reasoning", fontSize = 14.sp)
|
||||
Text(
|
||||
"Auto-collapse long reasoning blocks",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = reasoningAutoCollapse,
|
||||
onCheckedChange = { controller.toggleReasoningAutoCollapse() },
|
||||
)
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("📊 Runtime footer", fontSize = 14.sp)
|
||||
Text(
|
||||
"Show model, context, workdir, latency and cost under replies",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = runtimeFooterEnabled,
|
||||
onCheckedChange = { controller.toggleRuntimeFooter() },
|
||||
)
|
||||
}
|
||||
if (runtimeFooterEnabled) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Fields to show", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
IrisController.RUNTIME_FIELD_KEYS.forEach { field ->
|
||||
TopicChip(
|
||||
label = runtimeFieldLabel(field),
|
||||
selected = field in runtimeFooterFields,
|
||||
onClick = { controller.toggleRuntimeField(field) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Appearance",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("💬 Your bubble", fontSize = 14.sp)
|
||||
Text(
|
||||
"Color of your message bubbles",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
ColorSwatch(theme.userBubble) { pickerTarget = PickerTarget.UserBubble }
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("🤖 Agent bubble", fontSize = 14.sp)
|
||||
Text(
|
||||
"Color of Hermes' message bubbles",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
ColorSwatch(theme.agentBubble) { pickerTarget = PickerTarget.AgentBubble }
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Text("🖼 Background", fontSize = 14.sp)
|
||||
Text(
|
||||
"Solid color or image behind the chat",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
TopicChip(
|
||||
label = "Color",
|
||||
selected = theme.backgroundMode == BackgroundMode.Color,
|
||||
onClick = { controller.setBackgroundMode(BackgroundMode.Color) },
|
||||
)
|
||||
TopicChip(
|
||||
label = "Image",
|
||||
selected = theme.backgroundMode == BackgroundMode.Image,
|
||||
onClick = { controller.setBackgroundMode(BackgroundMode.Image) },
|
||||
)
|
||||
}
|
||||
if (theme.backgroundMode == BackgroundMode.Color) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Background color", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
}
|
||||
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text("Backdrops", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Backdrop.ALL.forEach { backdrop ->
|
||||
BackdropThumb(
|
||||
backdrop = backdrop,
|
||||
selected = theme.backgroundImagePath == backdrop.path,
|
||||
onClick = {
|
||||
controller.setBackgroundMode(BackgroundMode.Image)
|
||||
controller.setBackgroundImagePath(backdrop.path)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
BackgroundPreview(theme.backgroundImagePath)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (theme.backgroundImagePath.isNullOrBlank()) {
|
||||
"No image selected"
|
||||
} else {
|
||||
Backdrop.fromPath(theme.backgroundImagePath)?.name
|
||||
?: fileNameOf(theme.backgroundImagePath!!)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
ImageFilePicker { picked ->
|
||||
picked?.let { controller.setBackgroundImagePath(it.path) }
|
||||
}
|
||||
}
|
||||
if (!theme.backgroundImagePath.isNullOrBlank()) {
|
||||
TextButton(onClick = { controller.removeBackgroundImage() }) {
|
||||
Text("Remove image", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Reset appearance", fontSize = 14.sp)
|
||||
Text(
|
||||
"Restore the default colors and background",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { controller.resetTheme() }) {
|
||||
Text("Reset", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
Text("🔤 Font size", fontSize = 14.sp)
|
||||
Text(
|
||||
"Scale the text across the app",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Aa", fontSize = 12.sp, color = IrisColors.textDim)
|
||||
Slider(
|
||||
value = fontSizeScale,
|
||||
onValueChange = { controller.setFontSizeScale(it) },
|
||||
valueRange = IrisController.FONT_SCALE_MIN..IrisController.FONT_SCALE_MAX,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text("Aa", fontSize = 18.sp, color = IrisColors.textDim)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
fontSize = 13.sp,
|
||||
color = IrisColors.textSecondary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${(fontSizeScale * 100).roundToInt()}%",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
if (fontSizeScale != 1.0f) {
|
||||
TextButton(onClick = { controller.setFontSizeScale(1.0f) }) {
|
||||
Text("Reset", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Tools",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
SettingsCard {
|
||||
Text("🔧 Tool detail", fontSize = 14.sp)
|
||||
Text(
|
||||
"How much tool activity to show",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
ToolDetail.entries.forEach { d ->
|
||||
TopicChip(
|
||||
label = d.label.replaceFirstChar { it.uppercaseChar() },
|
||||
selected = toolDetail == d,
|
||||
onClick = { controller.setToolDetail(d) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
PickerTarget.UserBubble -> {
|
||||
ColorPickerDialog(
|
||||
title = "Your bubble",
|
||||
initialArgb = theme.userBubbleColor,
|
||||
onApply = { controller.setUserBubbleColor(it) },
|
||||
onConfirm = {
|
||||
controller.setUserBubbleColor(it)
|
||||
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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Material-style back arrow (vector, matches the app's icon weight). */
|
||||
@Composable
|
||||
private fun BackArrow(
|
||||
size: Dp = 22.dp,
|
||||
color: Color = IrisColors.onSurface,
|
||||
) {
|
||||
Canvas(modifier = Modifier.size(size)) {
|
||||
val s = this.size.width / 24f
|
||||
val path =
|
||||
Path().apply {
|
||||
moveTo(20f * s, 11f * s)
|
||||
lineTo(7.83f * s, 11f * s)
|
||||
lineTo(13.42f * s, 5.41f * s)
|
||||
lineTo(12f * s, 4f * s)
|
||||
lineTo(4f * s, 12f * s)
|
||||
lineTo(12f * s, 20f * s)
|
||||
lineTo(13.41f * s, 18.59f * s)
|
||||
lineTo(7.83f * s, 13f * s)
|
||||
lineTo(20f * s, 13f * s)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest ->
|
||||
Text(
|
||||
"New version v$latest available — see the Gitea releases page.",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.statusAmber,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings row container (panel card). */
|
||||
@Composable
|
||||
private fun SettingsCard(content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(IrisColors.panel)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clickable rounded chip showing a color; opens the picker on tap. */
|
||||
@Composable
|
||||
private fun ColorSwatch(
|
||||
color: Color,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
/** Small thumbnail of the current background image (empty panel when none). */
|
||||
@Composable
|
||||
private fun BackgroundPreview(path: String?) {
|
||||
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(path) {
|
||||
bmp =
|
||||
if (path.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
val bytes = Backdrop.fromPath(path)?.let { readBackdropBytes(it.id) }
|
||||
if (bytes != null) {
|
||||
decodeImageBytes(bytes, 128)
|
||||
} else {
|
||||
loadScaledImage(path, 128)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(IrisColors.panel)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
Image(
|
||||
bitmap = b,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thumbnail of a bundled backdrop; highlighted when selected. */
|
||||
@Composable
|
||||
private fun BackdropThumb(
|
||||
backdrop: Backdrop,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var bmp by remember(backdrop.id) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(backdrop.id) {
|
||||
val bytes = readBackdropBytes(backdrop.id)
|
||||
bmp = if (bytes != null) decodeImageBytes(bytes, 128) else null
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(IrisColors.panel)
|
||||
.border(
|
||||
if (selected) 2.dp else 1.dp,
|
||||
if (selected) IrisColors.primary else IrisColors.divider,
|
||||
RoundedCornerShape(8.dp),
|
||||
).clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
Image(
|
||||
bitmap = b,
|
||||
contentDescription = backdrop.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HSV color picker dialog (hue / saturation / brightness sliders).
|
||||
* [onApply] applies the color and keeps the dialog open for further tweaks;
|
||||
* [onConfirm] applies and closes; [onDismiss] closes without applying.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColorPickerDialog(
|
||||
title: String,
|
||||
initialArgb: Int,
|
||||
onApply: (Int) -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val initial = remember { argbToHsv(initialArgb) }
|
||||
var hue by remember { mutableFloatStateOf(initial[0]) }
|
||||
var sat by remember { mutableFloatStateOf(initial[1]) }
|
||||
var bri by remember { mutableFloatStateOf(initial[2]) }
|
||||
val color = Color.hsv(hue, sat, bri)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
|
||||
Slider(value = sat, onValueChange = { sat = it }, valueRange = 0f..1f)
|
||||
Slider(value = bri, onValueChange = { bri = it }, valueRange = 0f..1f)
|
||||
Text(
|
||||
"#%06X".format(color.toArgbInt() and 0xFFFFFF),
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textDim,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onApply(color.toArgbInt()) }) { Text("Apply") }
|
||||
TextButton(onClick = { onConfirm(color.toArgbInt()) }) { Text("OK") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Compose Color → ARGB int (no Color.toArgb in common code). */
|
||||
private fun Color.toArgbInt(): Int {
|
||||
fun c(v: Float) = (v * 255f).roundToInt().coerceIn(0, 255)
|
||||
return (c(alpha) shl 24) or (c(red) shl 16) or (c(green) shl 8) or c(blue)
|
||||
}
|
||||
|
||||
/** ARGB int → [hue (deg), saturation, brightness] (alpha ignored). */
|
||||
private fun argbToHsv(argb: Int): FloatArray {
|
||||
val r = ((argb shr 16) and 0xFF) / 255f
|
||||
val g = ((argb shr 8) and 0xFF) / 255f
|
||||
val b = (argb and 0xFF) / 255f
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
var hue = 0f
|
||||
if (delta != 0f) {
|
||||
hue =
|
||||
when (max) {
|
||||
r -> ((g - b) / delta) % 6f
|
||||
g -> (b - r) / delta + 2f
|
||||
else -> (r - g) / delta + 4f
|
||||
}
|
||||
hue *= 60f
|
||||
if (hue < 0f) hue += 360f
|
||||
}
|
||||
val sat = if (max == 0f) 0f else delta / max
|
||||
return floatArrayOf(hue, sat, max)
|
||||
}
|
||||
|
||||
/** File name from a path (no java.io.File in common code). */
|
||||
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
|
||||
|
||||
/** Human label for a runtime-footer field key (Settings → Runtime footer). */
|
||||
private fun runtimeFieldLabel(field: String): String =
|
||||
when (field) {
|
||||
"model" -> "Model"
|
||||
"context_pct" -> "Context %"
|
||||
"cwd" -> "Workdir"
|
||||
"latency" -> "Latency"
|
||||
"cost" -> "Cost"
|
||||
else -> field
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package iris.ui.theme
|
||||
|
||||
/**
|
||||
* A bundled backdrop image shipped in the app (Android: `androidApp` module
|
||||
* `assets/backdrops/`; desktop: classpath `backdrops/`), loaded via
|
||||
* [iris.platform.readBackdropBytes].
|
||||
*
|
||||
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
|
||||
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
|
||||
* file path. [DEFAULT] is the background for fresh installs.
|
||||
*/
|
||||
data class Backdrop(
|
||||
val id: String,
|
||||
val name: String,
|
||||
) {
|
||||
/** Sentinel path stored in the theme for this bundled backdrop. */
|
||||
val path: String get() = "backdrop://$id"
|
||||
|
||||
companion object {
|
||||
const val PATH_PREFIX = "backdrop://"
|
||||
|
||||
/** Default background for new chats / fresh installs. */
|
||||
val DEFAULT = Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli")
|
||||
|
||||
val ALL: List<Backdrop> =
|
||||
listOf(
|
||||
DEFAULT,
|
||||
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
|
||||
Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
|
||||
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
|
||||
Backdrop("pexels-soubhagya23-8060218", "Soubhagya"),
|
||||
Backdrop("pexels-steve-29390703", "Steve"),
|
||||
)
|
||||
|
||||
/** True when [path] references a bundled backdrop. */
|
||||
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
|
||||
|
||||
/** The bundled backdrop for a sentinel path, or null. */
|
||||
fun fromPath(path: String?): Backdrop? = if (isBackdropPath(path)) ALL.firstOrNull { it.path == path } else null
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,17 @@ package iris.ui.theme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import iris.platform.decodeImageBytes
|
||||
import iris.platform.loadScaledImage
|
||||
import iris.platform.readBackdropBytes
|
||||
|
||||
/**
|
||||
* Centralized palette (M7). Dark is the only theme; every screen reads colors
|
||||
@@ -25,8 +35,6 @@ object IrisColors {
|
||||
val textDim = Color(0xFF8A93A6)
|
||||
|
||||
// Bubbles
|
||||
val bubbleUser = primary
|
||||
val bubbleAssistant = Color(0xFF2A2E3B)
|
||||
val bubbleCommentary = Color(0xFF23262F)
|
||||
|
||||
// Panels, chips, rows
|
||||
@@ -34,11 +42,9 @@ object IrisColors {
|
||||
val chip = Color(0xFF2A2E3B)
|
||||
val chipSelected = Color(0xFF2A3550)
|
||||
val rowSelected = Color(0xFF232838)
|
||||
val reasoningPanel = Color(0xFF1E212B)
|
||||
val divider = Color(0xFF2A2E3B)
|
||||
|
||||
// Status
|
||||
val statusGrey = Color(0xFF9E9E9E)
|
||||
val statusAmber = Color(0xFFFFC107)
|
||||
val statusGreen = Color(0xFF4CAF50)
|
||||
val statusRed = Color(0xFFF44336)
|
||||
@@ -47,16 +53,17 @@ object IrisColors {
|
||||
val errorText = Color(0xFFFF8A80)
|
||||
}
|
||||
|
||||
private val IrisDark = darkColorScheme(
|
||||
background = IrisColors.background,
|
||||
surface = IrisColors.surface,
|
||||
onBackground = IrisColors.onBackground,
|
||||
onSurface = IrisColors.onSurface,
|
||||
onSurfaceVariant = IrisColors.textDim,
|
||||
primary = IrisColors.primary,
|
||||
onPrimary = Color.White,
|
||||
error = IrisColors.statusRed,
|
||||
)
|
||||
private val IrisDark =
|
||||
darkColorScheme(
|
||||
background = IrisColors.background,
|
||||
surface = IrisColors.surface,
|
||||
onBackground = IrisColors.onBackground,
|
||||
onSurface = IrisColors.onSurface,
|
||||
onSurfaceVariant = IrisColors.textDim,
|
||||
primary = IrisColors.primary,
|
||||
onPrimary = Color.White,
|
||||
error = IrisColors.statusRed,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun IrisTheme(content: @Composable () -> Unit) {
|
||||
@@ -69,4 +76,69 @@ fun IrisTheme(content: @Composable () -> Unit) {
|
||||
fun avatarColor(name: String): Color {
|
||||
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
|
||||
return Color.hsv(hue, 0.5f, 0.75f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User-customizable appearance (Settings → Appearance). Colors are stored as
|
||||
* ARGB ints so they can be persisted without Compose types.
|
||||
*/
|
||||
data class UserTheme(
|
||||
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
|
||||
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
|
||||
// Fresh installs start with the default bundled backdrop (Settings →
|
||||
// Appearance to change it).
|
||||
val backgroundMode: BackgroundMode = BackgroundMode.Image,
|
||||
val backgroundColor: Int = DEFAULT_BACKGROUND,
|
||||
val backgroundImagePath: String? = Backdrop.DEFAULT.path,
|
||||
) {
|
||||
val userBubble: Color get() = Color(userBubbleColor)
|
||||
val agentBubble: Color get() = Color(agentBubbleColor)
|
||||
val background: Color get() = Color(backgroundColor)
|
||||
|
||||
companion object {
|
||||
val DEFAULT_USER_BUBBLE: Int = 0xFF4F7CFF.toInt()
|
||||
val DEFAULT_AGENT_BUBBLE: Int = 0xFF2A2E3B.toInt()
|
||||
val DEFAULT_BACKGROUND: Int = 0xFF1B1E28.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
/** Background style: solid color or image. */
|
||||
enum class BackgroundMode { Color, Image }
|
||||
|
||||
/** Current user theme, provided at the app root (IrisApp). */
|
||||
val LocalUserTheme = staticCompositionLocalOf { UserTheme() }
|
||||
|
||||
/** Black or white, whichever reads better on [this]. */
|
||||
fun Color.contrastText(): Color {
|
||||
val luminance = 0.299f * red + 0.587f * green + 0.114f * blue
|
||||
return if (luminance > 0.6f) Color(0xFF1B1E28) else Color.White
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the theme's background image (image mode only), scaled to at most
|
||||
* 2048 px on the longest edge. Bundled backdrops (`backdrop://<id>`) are read
|
||||
* from the app resources; anything else is a user-picked file path. Null in
|
||||
* color mode or on decode failure — the caller then falls back to the solid
|
||||
* background color.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
|
||||
val path = theme.backgroundImagePath
|
||||
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
|
||||
var image by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(wantImage, path) {
|
||||
image =
|
||||
if (!wantImage) {
|
||||
null
|
||||
} else {
|
||||
val backdrop = Backdrop.fromPath(path)
|
||||
val bytes = backdrop?.let { readBackdropBytes(it.id) }
|
||||
if (bytes != null) {
|
||||
decodeImageBytes(bytes, 2048)
|
||||
} else {
|
||||
loadScaledImage(path!!, 2048)
|
||||
}
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* Fuzzy subsequence scoring for the slash-command drawer.
|
||||
*
|
||||
* [fuzzyScore] returns a non-negative rank (lower = better) when every
|
||||
* character of [query] appears in [target] in order (case-insensitive), or
|
||||
* -1 when it does not. Prefix matches rank best (1); a consecutive run of
|
||||
* matched characters ranks better than a gapped one, so "mod" beats "m...d"
|
||||
* for "/model".
|
||||
*/
|
||||
fun fuzzyScore(query: String, target: String): Int {
|
||||
val q = query.lowercase()
|
||||
if (q.isEmpty()) return 0
|
||||
val t = target.lowercase()
|
||||
if (t.startsWith(q)) return 1
|
||||
var qi = 0
|
||||
var score = 0
|
||||
var prevMatch = -2
|
||||
for (i in t.indices) {
|
||||
if (t[i] == q[qi]) {
|
||||
score += if (prevMatch == i - 1) 1 else 4
|
||||
prevMatch = i
|
||||
qi++
|
||||
if (qi == q.length) return score
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package iris.util
|
||||
|
||||
/** Minimal logging. On Android, stdout goes to logcat; on desktop it goes to
|
||||
* the console. Kept dependency-free (common stdlib only). */
|
||||
object IrisLog {
|
||||
private const val TAG = "Iris"
|
||||
|
||||
fun d(msg: String) = println("$TAG: $msg")
|
||||
|
||||
fun w(msg: String) = println("$TAG: $msg")
|
||||
|
||||
fun e(msg: String) = println("$TAG: $msg")
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* Drops the leading newlines / blank lines a model reply often starts with, so
|
||||
* the rendered text hugs the top of the bubble instead of leaving a blank gap.
|
||||
* Leading indentation on the first real line is preserved, so an indented code
|
||||
* block at the very start of a message still parses as code.
|
||||
*/
|
||||
fun String.prepareForMarkdown(): String {
|
||||
val lines = split("\n")
|
||||
var i = 0
|
||||
while (i < lines.size && lines[i].isBlank()) i++
|
||||
return lines.drop(i).joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts single newlines into markdown hard breaks (two trailing spaces) so
|
||||
* they render as line breaks instead of being collapsed into spaces by the
|
||||
* parser (GFM treats a lone newline inside a paragraph as a space). Applied to
|
||||
* both user input (lazy line breaks) and agent replies (models write status
|
||||
* lines and wrapped text expecting a break per line). Lines inside fenced code
|
||||
* blocks (``` / ~~~) are left untouched so the code content is not altered;
|
||||
* blank lines are kept as-is since they already produce paragraph breaks.
|
||||
*/
|
||||
fun String.preserveNewlinesAsHardBreaks(): String {
|
||||
val lines = split("\n")
|
||||
var inCodeBlock = false
|
||||
val out =
|
||||
lines.map { line ->
|
||||
val trimmed = line.trimStart()
|
||||
when {
|
||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||
inCodeBlock = !inCodeBlock
|
||||
line
|
||||
}
|
||||
|
||||
inCodeBlock || line.isBlank() -> {
|
||||
line
|
||||
}
|
||||
|
||||
else -> {
|
||||
line + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
|
||||
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
|
||||
*
|
||||
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
|
||||
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
|
||||
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
|
||||
* renderer feed the diff between consecutive snapshots into an append-only
|
||||
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
|
||||
* invisible at the end of the document, so a finished message renders exactly
|
||||
* like the static transform.
|
||||
*/
|
||||
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
|
||||
val lines = split("\n")
|
||||
var inCodeBlock = false
|
||||
val out =
|
||||
lines.mapIndexed { index, line ->
|
||||
val trimmed = line.trimStart()
|
||||
when {
|
||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||
inCodeBlock = !inCodeBlock
|
||||
line
|
||||
}
|
||||
|
||||
inCodeBlock || line.isBlank() -> {
|
||||
line
|
||||
}
|
||||
|
||||
index == lines.lastIndex -> {
|
||||
line
|
||||
}
|
||||
|
||||
// still being typed
|
||||
else -> {
|
||||
line + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
|
||||
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
|
||||
* content was rewritten, not appended. The gateway sends full text snapshots
|
||||
* on every `message.update`; this converts them into append chunks for the
|
||||
* append-only streaming parser.
|
||||
*/
|
||||
fun appendChunk(
|
||||
last: String,
|
||||
next: String,
|
||||
): String? = if (next.startsWith(last)) next.substring(last.length) else null
|
||||
|
||||
/**
|
||||
* Number of characters to reveal on one streaming tick. The reveal rate comes
|
||||
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
|
||||
* (fast model, reconnect catch-up) everything is revealed at once so the
|
||||
* display never lags far behind the received text.
|
||||
*/
|
||||
fun revealStep(
|
||||
remaining: Int,
|
||||
charsPerSecond: Double,
|
||||
tickSeconds: Double,
|
||||
): Int {
|
||||
if (remaining <= 0) return 0
|
||||
if (remaining > charsPerSecond * 2.0) return remaining
|
||||
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Range of the streaming-smoothness setting (seconds between visible text
|
||||
* updates while streaming; lower = faster/smoother).
|
||||
*/
|
||||
const val STREAM_SMOOTHNESS_MIN = 0.2f
|
||||
const val STREAM_SMOOTHNESS_MAX = 0.8f
|
||||
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
|
||||
|
||||
/**
|
||||
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
|
||||
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
|
||||
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
|
||||
*/
|
||||
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
|
||||
@@ -0,0 +1,97 @@
|
||||
package iris.util
|
||||
|
||||
/**
|
||||
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
|
||||
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
|
||||
* tapped `iris://pair` link (docs/20).
|
||||
*/
|
||||
data class PairLink(
|
||||
val url: String,
|
||||
val token: String,
|
||||
) {
|
||||
companion object {
|
||||
/** Default gateway HTTP port (docs/19). */
|
||||
const val DEFAULT_PORT = 8791
|
||||
|
||||
/**
|
||||
* Parse a pairing link into a [PairLink], or return null on any
|
||||
* malformation.
|
||||
*
|
||||
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
|
||||
* - required query params: `host` (non-empty) and `token` (non-empty).
|
||||
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
|
||||
* - `secure` defaults to `0`; `1` selects https.
|
||||
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
|
||||
*/
|
||||
fun parse(raw: String): PairLink? {
|
||||
val qIdx = raw.indexOf('?')
|
||||
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
|
||||
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
|
||||
|
||||
// authority is "iris://pair": scheme, then "://", then the URI host.
|
||||
val sep = authority.indexOf("://")
|
||||
if (sep <= 0) return null
|
||||
val scheme = authority.substring(0, sep)
|
||||
val uriHost = authority.substring(sep + 3)
|
||||
if (scheme.lowercase() != "iris") return null
|
||||
if (uriHost.lowercase() != "pair") return null
|
||||
|
||||
val params = parseQuery(query)
|
||||
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
|
||||
if (host.isEmpty() || token.isEmpty()) return null
|
||||
|
||||
val portRaw = params["port"]
|
||||
val port =
|
||||
if (portRaw.isNullOrEmpty()) {
|
||||
DEFAULT_PORT
|
||||
} else {
|
||||
portRaw.toIntOrNull() ?: return null
|
||||
}
|
||||
if (port !in 1..65535) return null
|
||||
val secure = params["secure"]?.toIntOrNull() ?: 0
|
||||
val urlScheme = if (secure == 1) "https" else "http"
|
||||
return PairLink("$urlScheme://$host:$port", token)
|
||||
}
|
||||
|
||||
/** Split a `k=v&k=v` query string into a map (values may be empty). */
|
||||
private fun parseQuery(query: String): Map<String, String> {
|
||||
if (query.isEmpty()) return emptyMap()
|
||||
val map = LinkedHashMap<String, String>()
|
||||
for (pair in query.split('&')) {
|
||||
if (pair.isEmpty()) continue
|
||||
val eq = pair.indexOf('=')
|
||||
if (eq < 0) {
|
||||
map[pair] = ""
|
||||
} else {
|
||||
map[pair.substring(0, eq)] = pair.substring(eq + 1)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
|
||||
* — IPs and 64-hex tokens). A `%` that does not start a valid
|
||||
* two-hex-digit escape is kept literally.
|
||||
*/
|
||||
private fun percentDecode(s: String): String {
|
||||
val sb = StringBuilder(s.length)
|
||||
var i = 0
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (c == '%' && i + 2 < s.length) {
|
||||
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
|
||||
if (code != null) {
|
||||
sb.append(code.toChar())
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.append(c)
|
||||
i++
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,5 @@ expect fun formatTime(epochMillis: Long): String
|
||||
/** Local calendar-day key used to detect date changes between messages (M7). */
|
||||
expect fun localDayKey(epochMillis: Long): String
|
||||
|
||||
/** 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() }
|
||||
}
|
||||
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
|
||||
expect fun nowMillis(): Long
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Local cache (docs/10 §10.7, docs/16 "Local DB (KMP) — SQLDelight").
|
||||
-- The server is authoritative; this is a cache that lets the app start
|
||||
-- instantly and read chats offline. Rows are stored as JSON payloads so the
|
||||
-- schema does not drift with the Kotlin model fields.
|
||||
|
||||
CREATE TABLE message (
|
||||
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
|
||||
id TEXT NOT NULL, -- message id (server id, or local<rand>/sys<rand> for optimistic)
|
||||
ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed)
|
||||
payload TEXT NOT NULL, -- serialized MessageItem
|
||||
PRIMARY KEY (lane, id)
|
||||
);
|
||||
|
||||
CREATE TABLE tool (
|
||||
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
|
||||
id TEXT NOT NULL, -- local tool card id (tool<rand>)
|
||||
seq INTEGER NOT NULL, -- card order within the lane (lane position)
|
||||
payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id)
|
||||
PRIMARY KEY (lane, id)
|
||||
);
|
||||
|
||||
CREATE TABLE channel (
|
||||
chat_id TEXT NOT NULL PRIMARY KEY,
|
||||
payload TEXT NOT NULL -- serialized ChannelInfo
|
||||
);
|
||||
|
||||
CREATE TABLE meta (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
allMessages:
|
||||
SELECT lane, id, ts, payload
|
||||
FROM message
|
||||
ORDER BY ts, id;
|
||||
|
||||
upsertMessage:
|
||||
INSERT OR REPLACE INTO message (lane, id, ts, payload)
|
||||
VALUES (?, ?, ?, ?);
|
||||
|
||||
clearMessages:
|
||||
DELETE FROM message;
|
||||
|
||||
allTools:
|
||||
SELECT lane, seq, payload
|
||||
FROM tool
|
||||
ORDER BY lane, seq;
|
||||
|
||||
upsertTool:
|
||||
INSERT OR REPLACE INTO tool (lane, id, seq, payload)
|
||||
VALUES (?, ?, ?, ?);
|
||||
|
||||
clearTools:
|
||||
DELETE FROM tool;
|
||||
|
||||
allChannels:
|
||||
SELECT chat_id, payload
|
||||
FROM channel;
|
||||
|
||||
upsertChannel:
|
||||
INSERT OR REPLACE INTO channel (chat_id, payload)
|
||||
VALUES (?, ?);
|
||||
|
||||
clearChannels:
|
||||
DELETE FROM channel;
|
||||
|
||||
metaGet:
|
||||
SELECT value
|
||||
FROM meta
|
||||
WHERE key = ?;
|
||||
|
||||
metaPut:
|
||||
INSERT OR REPLACE INTO meta (key, value)
|
||||
VALUES (?, ?);
|
||||
|
||||
clearMeta:
|
||||
DELETE FROM meta;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- v1 -> v2: persist tool cards (M-cache: tool cards survive a restart,
|
||||
-- anchored to the message they follow — see ChatDb.loadLanes).
|
||||
CREATE TABLE tool (
|
||||
lane TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (lane, id)
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
package iris.data
|
||||
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.TYPE_TODO_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.TodoItem
|
||||
import iris.protocol.TodoUpdatePayload
|
||||
import iris.protocol.ToolStartPayload
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ChatStoreCacheTest {
|
||||
@Test
|
||||
fun loadFromCachePopulatesLanes() {
|
||||
val store = ChatStore()
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||
MessageItem(id = "m2", role = "assistant", text = "hello", ts = 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadFromCacheEmptyIsNoOp() {
|
||||
val store = ChatStore()
|
||||
store.addPending("hello", "default")
|
||||
store.loadFromCache(emptyMap())
|
||||
assertEquals(1, store.lanes.value["default"]!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadHistoryKeepsToolCardsAtAnchor() {
|
||||
val store = ChatStore()
|
||||
// Lane as restored from the cache: user message, tool card anchored
|
||||
// to it, and the final answer.
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
|
||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
||||
),
|
||||
),
|
||||
)
|
||||
// A history refresh (authoritative messages, no tool cards) must keep
|
||||
// the tool card between the user message and the answer — not push
|
||||
// it to the end.
|
||||
store.loadHistory(
|
||||
"default",
|
||||
listOf(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadHistoryDedupesAndKeepsUnanchoredToolsLast() {
|
||||
val store = ChatStore()
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
|
||||
),
|
||||
),
|
||||
)
|
||||
store.loadHistory(
|
||||
"default",
|
||||
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
|
||||
)
|
||||
// A card whose anchor is unknown falls to the end (degenerate case).
|
||||
assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolStartIdsNeverCollideWithRestoredCards() {
|
||||
val store = ChatStore()
|
||||
// Lane restored from the cache after a restart, holding a persisted
|
||||
// card minted by the PREVIOUS process.
|
||||
store.loadFromCache(
|
||||
mapOf(
|
||||
"default" to
|
||||
listOf<ChatItem>(
|
||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
||||
),
|
||||
),
|
||||
)
|
||||
// A new turn must not re-mint an id the restored lane already holds
|
||||
// (duplicate LazyColumn key / upsert overwrite under the same PK).
|
||||
store.onFrame(
|
||||
Frame(
|
||||
type = TYPE_TOOL_START,
|
||||
chatId = "iris:other",
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
ToolStartPayload.serializer(),
|
||||
ToolStartPayload(index = 0, name = "terminal", preview = "ls"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val ids = store.lanes.value["default"]!!.map { it.id }
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun todoUpdateSetsLaneListLastWriteWins() {
|
||||
val store = ChatStore()
|
||||
assertNull(store.todos.value["default"])
|
||||
store.onFrame(todoFrame("default", listOf(TodoItem("1", "a", "in_progress"))))
|
||||
assertEquals(listOf("1"), store.todos.value["default"]!!.map { it.id })
|
||||
// A second update REPLACES the list (the gateway always sends the
|
||||
// full list), and other lanes are untouched.
|
||||
store.onFrame(
|
||||
todoFrame(
|
||||
"default",
|
||||
listOf(TodoItem("1", "a", "completed"), TodoItem("2", "b", "pending")),
|
||||
),
|
||||
)
|
||||
store.onFrame(todoFrame("chan_7", listOf(TodoItem("9", "z", "pending"))))
|
||||
assertEquals(listOf("1", "2"), store.todos.value["default"]!!.map { it.id })
|
||||
assertEquals("completed", store.todos.value["default"]!![0].status)
|
||||
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
|
||||
// An empty list clears the lane (the agent dropped its plan).
|
||||
store.onFrame(todoFrame("default", emptyList()))
|
||||
assertNull(store.todos.value["default"])
|
||||
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
|
||||
}
|
||||
|
||||
private fun todoFrame(
|
||||
chatId: String,
|
||||
todos: List<TodoItem>,
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_TODO_UPDATE,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
TodoUpdatePayload.serializer(),
|
||||
TodoUpdatePayload(todos = todos),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package iris.data
|
||||
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.PickerChoice
|
||||
import iris.protocol.PickerChoicePayload
|
||||
import iris.protocol.TYPE_PICKER_CHOICE
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ChatStorePickerTest {
|
||||
private fun pickerFrame(
|
||||
pickerId: String,
|
||||
title: String = "Reasoning",
|
||||
chatId: String = "chan_1",
|
||||
): Frame =
|
||||
Frame(
|
||||
type = TYPE_PICKER_CHOICE,
|
||||
chatId = chatId,
|
||||
payload =
|
||||
IrisJson.instance.encodeToJsonElement(
|
||||
PickerChoicePayload.serializer(),
|
||||
PickerChoicePayload(
|
||||
pickerId = pickerId,
|
||||
title = title,
|
||||
choices =
|
||||
listOf(
|
||||
PickerChoice(value = "low", label = "Low"),
|
||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun onPickerChoiceAddsItem() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
val items = store.lanes.value["chan_1"]!!
|
||||
assertEquals(1, items.size)
|
||||
val picker = assertIs<PickerItem>(items[0])
|
||||
assertEquals("pk_1", picker.id)
|
||||
assertEquals("Reasoning", picker.title)
|
||||
assertEquals(2, picker.choices.size)
|
||||
assertNull(picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onPickerChoiceIsIdempotent() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.onFrame(pickerFrame("pk_1")) // duplicate (e.g. outbox re-delivery)
|
||||
val items = store.lanes.value["chan_1"]!!
|
||||
assertEquals(1, items.size)
|
||||
assertEquals("pk_1", items[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerMarksSelected() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_1", "low")
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertEquals("low", picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerIsOneShot() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_1", "low")
|
||||
store.resolvePicker("pk_1", "high") // second tap is ignored
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertEquals("low", picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvePickerUnknownIdIsNoop() {
|
||||
val store = ChatStore()
|
||||
store.onFrame(pickerFrame("pk_1"))
|
||||
store.resolvePicker("pk_missing", "low") // stale select after restart
|
||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
||||
assertNull(picker.selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pickerItemSerializationRoundTrip() {
|
||||
val item =
|
||||
PickerItem(
|
||||
id = "pk_1",
|
||||
title = "Reasoning",
|
||||
choices =
|
||||
listOf(
|
||||
PickerChoice(value = "low", label = "Low"),
|
||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
||||
),
|
||||
ts = 1234L,
|
||||
selected = "high",
|
||||
)
|
||||
val json = IrisJson.instance.encodeToString(PickerItem.serializer(), item)
|
||||
val back = IrisJson.instance.decodeFromString(PickerItem.serializer(), json)
|
||||
assertEquals(item, back)
|
||||
assertTrue(back.choices[1].isCurrent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package iris.data
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* M8: per-lane unread tracking (the data behind the channel-view / header
|
||||
* unread indicator). The increment/clear *policy* (which incoming message is
|
||||
* "being read") lives in IrisController; here we cover the store's count
|
||||
* semantics that the UI and controller build on.
|
||||
*/
|
||||
class ChatStoreUnreadTest {
|
||||
@Test
|
||||
fun markUnreadIncrementsPerLane() {
|
||||
val store = ChatStore()
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
store.markUnread("default")
|
||||
store.markUnread("default")
|
||||
store.markUnread("chan_7")
|
||||
assertEquals(2, store.unreadFor("default"))
|
||||
assertEquals(1, store.unreadFor("chan_7"))
|
||||
// A lane that never had a message stays at 0.
|
||||
assertEquals(0, store.unreadFor("chan_9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markLaneReadClearsOnlyThatLane() {
|
||||
val store = ChatStore()
|
||||
store.markUnread("default")
|
||||
store.markUnread("chan_7")
|
||||
store.markLaneRead("default")
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
// Other lanes are untouched.
|
||||
assertEquals(1, store.unreadFor("chan_7"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markLaneReadIsIdempotent() {
|
||||
val store = ChatStore()
|
||||
store.markLaneRead("default") // no unread -> no-op
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
store.markUnread("default")
|
||||
store.markLaneRead("default")
|
||||
store.markLaneRead("default") // clearing twice is safe
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadMapReflectsCounts() {
|
||||
val store = ChatStore()
|
||||
assertNull(store.unread.value["default"])
|
||||
store.markUnread("default")
|
||||
assertEquals(1, store.unread.value["default"])
|
||||
store.markLaneRead("default")
|
||||
// A cleared lane is removed from the map (absent == 0 unread).
|
||||
assertNull(store.unread.value["default"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearWipesUnread() {
|
||||
val store = ChatStore()
|
||||
store.markUnread("default")
|
||||
store.markUnread("chan_7")
|
||||
store.clear()
|
||||
assertEquals(0, store.unreadFor("default"))
|
||||
assertEquals(0, store.unreadFor("chan_7"))
|
||||
assertEquals(emptyMap(), store.unread.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package iris.net
|
||||
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.MessagePayload
|
||||
import iris.protocol.TYPE_MESSAGE
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* docs/19: unit tests for the HTTP fallback leg client.
|
||||
*
|
||||
* The SSE line parser is the trickiest pure logic (event/id/data fields,
|
||||
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
|
||||
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
|
||||
* pure function (unit-tested). Full transport behavior (health, POST, SSE
|
||||
* catch-up, long-poll, delivery counting) is covered by the gateway-side
|
||||
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
|
||||
*/
|
||||
class HttpGatewayTest {
|
||||
// ── URL derivation ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun deriveHttpUrlReplacesSchemeAndPort() {
|
||||
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
|
||||
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
|
||||
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
|
||||
// No explicit port on the WS URL: still the HTTP leg's default port.
|
||||
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deriveHttpUrlPassesThroughHttpUrls() {
|
||||
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
|
||||
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
|
||||
}
|
||||
|
||||
// ── SSE parser ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun sseParsesHelloAndFrames() {
|
||||
val parser = SseParser()
|
||||
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
|
||||
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
|
||||
val lines =
|
||||
listOf(
|
||||
"event: hello",
|
||||
"data: $hello",
|
||||
"",
|
||||
"id: 7",
|
||||
"event: frame",
|
||||
"data: $frame",
|
||||
"",
|
||||
)
|
||||
var helloCount = 0
|
||||
var frameCount = 0
|
||||
var lastCursor: Long? = null
|
||||
for (line in lines) {
|
||||
parser.feed(
|
||||
line,
|
||||
onHello = { helloCount++ },
|
||||
onFrame = { frameCount++ },
|
||||
onCursor = { lastCursor = it },
|
||||
)
|
||||
}
|
||||
assertEquals(1, helloCount)
|
||||
assertEquals(1, frameCount)
|
||||
assertEquals(7L, lastCursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseIgnoresHeartbeatComments() {
|
||||
val parser = SseParser()
|
||||
var frames = 0
|
||||
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||
assertEquals(0, frames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseMultiLineDataJoinsWithNewline() {
|
||||
val parser = SseParser()
|
||||
var frame: Frame? = null
|
||||
// SSE data may span multiple `data:` lines; the parser must join
|
||||
// them with "\n" so the reassembled JSON still decodes. Split at a
|
||||
// legal JSON whitespace point (right after a comma, between tokens).
|
||||
val full =
|
||||
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
|
||||
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
|
||||
val l1 = full.substring(0, cut)
|
||||
val l2 = full.substring(cut)
|
||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
|
||||
assertEquals("a\nb", frame?.payloadAsText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseTracksLastEventIdAcrossFrames() {
|
||||
val parser = SseParser()
|
||||
val ids = mutableListOf<Long>()
|
||||
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
|
||||
for (id in listOf(1L, 2L, 3L)) {
|
||||
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
|
||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||
}
|
||||
assertEquals(listOf(1L, 2L, 3L), ids)
|
||||
assertEquals(3L, parser.lastEventId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseMalformedLineDoesNotThrow() {
|
||||
val parser = SseParser()
|
||||
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||
// No exception, no frame emitted for the malformed data.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sseDecodesFramePayload() {
|
||||
val parser = SseParser()
|
||||
var text: String? = null
|
||||
val frame =
|
||||
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
|
||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
|
||||
assertEquals("hello", text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
|
||||
|
||||
/**
|
||||
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
|
||||
* terminators); a blank line dispatches the buffered event. [lastEventId]
|
||||
* is the most recent `id:` field (the outbox cursor) — the resume point for
|
||||
* a reconnect.
|
||||
*/
|
||||
class SseParser {
|
||||
var lastEventId: Long? = null
|
||||
private set
|
||||
|
||||
private var eventId: String? = null
|
||||
private val dataLines = mutableListOf<String>()
|
||||
|
||||
fun feed(
|
||||
line: String,
|
||||
onHello: (String) -> Unit,
|
||||
onFrame: (Frame) -> Unit,
|
||||
onCursor: (Long) -> Unit,
|
||||
) {
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (dataLines.isNotEmpty()) {
|
||||
val data = dataLines.joinToString("\n")
|
||||
val frame =
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(Frame.serializer(), data)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
if (frame != null) {
|
||||
when (eventId) {
|
||||
"hello" -> onHello(data)
|
||||
else -> onFrame(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
eventId = null
|
||||
dataLines.clear()
|
||||
}
|
||||
|
||||
line.startsWith(":") -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
// comment / heartbeat
|
||||
line.startsWith("id:") -> {
|
||||
val id = line.removePrefix("id:").trim().toLongOrNull()
|
||||
if (id != null) {
|
||||
lastEventId = id
|
||||
onCursor(id)
|
||||
}
|
||||
}
|
||||
|
||||
line.startsWith("event:") -> {
|
||||
eventId = line.removePrefix("event:").trim()
|
||||
}
|
||||
|
||||
line.startsWith("data:") -> {
|
||||
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text
|
||||
@@ -0,0 +1,163 @@
|
||||
package iris.net
|
||||
|
||||
import com.sun.net.httpserver.HttpsConfigurator
|
||||
import com.sun.net.httpserver.HttpsServer
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.util.Base64
|
||||
import javax.net.ssl.KeyManagerFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real
|
||||
* TLS handshake: a local HTTPS server presents the embedded self-signed
|
||||
* certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like
|
||||
* [GatewayClient] (pinning socket factory + trust manager).
|
||||
*
|
||||
* 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the
|
||||
* presented fingerprint from the nested exception.
|
||||
* 2. After "confirming" (setting the pin): the SAME client connects — the
|
||||
* pin is read live, no client rebuild.
|
||||
*
|
||||
* The embedded key is a throwaway test key, not a secret.
|
||||
*/
|
||||
class TlsPinningIntegrationTest {
|
||||
private companion object {
|
||||
// Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname
|
||||
// verifier requires a SAN; CN-only certs are rejected even when pinned).
|
||||
val CERT_PEM =
|
||||
"""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL
|
||||
BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z
|
||||
NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG
|
||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF
|
||||
/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C
|
||||
Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT
|
||||
qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs
|
||||
PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a
|
||||
GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7
|
||||
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd
|
||||
WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm
|
||||
EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3
|
||||
Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE
|
||||
dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw
|
||||
zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0
|
||||
Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU=
|
||||
-----END CERTIFICATE-----
|
||||
""".trimIndent()
|
||||
|
||||
val KEY_PEM =
|
||||
"""
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G
|
||||
uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc
|
||||
fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H
|
||||
eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7
|
||||
bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC
|
||||
qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7
|
||||
OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5
|
||||
PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd
|
||||
sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r
|
||||
yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g
|
||||
WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN
|
||||
ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE
|
||||
BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f
|
||||
utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem
|
||||
Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK
|
||||
ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu
|
||||
OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H
|
||||
yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w
|
||||
Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6
|
||||
190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF
|
||||
JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/
|
||||
LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M
|
||||
JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI
|
||||
MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z
|
||||
OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO
|
||||
sZ1EltfJHT57a0ub22mRtVc=
|
||||
-----END PRIVATE KEY-----
|
||||
""".trimIndent()
|
||||
|
||||
// `openssl x509 -noout -fingerprint -sha256` over CERT_PEM.
|
||||
const val EXPECTED_FINGERPRINT =
|
||||
"9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44"
|
||||
|
||||
fun pemBody(pem: String): ByteArray {
|
||||
val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "")
|
||||
return Base64.getDecoder().decode(base64)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selfSignedGatewayRequiresConfirmThenPins() {
|
||||
val cert =
|
||||
CertificateFactory
|
||||
.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray()))
|
||||
.let { it as java.security.cert.X509Certificate }
|
||||
val key =
|
||||
KeyFactory
|
||||
.getInstance("RSA")
|
||||
.generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM)))
|
||||
|
||||
// Local HTTPS server presenting the self-signed cert.
|
||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
ks.load(null, null)
|
||||
ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert))
|
||||
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
||||
kmf.init(ks, CharArray(0))
|
||||
val serverCtx = SSLContext.getInstance("TLS")
|
||||
serverCtx.init(kmf.keyManagers, null, null)
|
||||
|
||||
val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
||||
server.httpsConfigurator = HttpsConfigurator(serverCtx)
|
||||
server.createContext("/v1/health") { exchange ->
|
||||
val body = "ok".toByteArray()
|
||||
exchange.sendResponseHeaders(200, body.size.toLong())
|
||||
exchange.responseBody.use { it.write(body) }
|
||||
}
|
||||
server.start()
|
||||
|
||||
// The client is wired exactly like GatewayClient: pinning socket
|
||||
// factory + trust manager, pin read live from a mutable holder.
|
||||
var pin = ""
|
||||
val tm = PinningTrustManager { pin }
|
||||
val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build()
|
||||
val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build()
|
||||
|
||||
try {
|
||||
// 1. Unpinned: the handshake fails; the unwrap finds the
|
||||
// presented fingerprint in the nested exception chain.
|
||||
val e =
|
||||
assertFailsWith<Exception> {
|
||||
client.newCall(request).execute().use { it.body!!.string() }
|
||||
}
|
||||
val tls = tlsFingerprintRequired(e)
|
||||
assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e")
|
||||
assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint)
|
||||
|
||||
// 2. User "confirms" the fingerprint: the SAME client now
|
||||
// connects (the pin is read live, no client rebuild).
|
||||
pin = EXPECTED_FINGERPRINT
|
||||
client.newCall(request).execute().use { response ->
|
||||
assertEquals(200, response.code)
|
||||
assertEquals("ok", response.body!!.string())
|
||||
}
|
||||
} finally {
|
||||
server.stop(0)
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package iris.net
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow.
|
||||
*
|
||||
* The embedded certificate is a self-signed cert (CN=iris-test-gateway) the
|
||||
* platform trust store does NOT contain, so it exercises the exact path a
|
||||
* self-signed gateway hits: default trust manager rejects → the pinning
|
||||
* manager either offers the fingerprint for confirmation or accepts the
|
||||
* user-confirmed pin.
|
||||
*/
|
||||
class TlsPinningTest {
|
||||
private companion object {
|
||||
// Self-signed, 10-year validity — generated with:
|
||||
// openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway"
|
||||
val SELF_SIGNED_PEM =
|
||||
"""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL
|
||||
BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3
|
||||
WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC
|
||||
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp
|
||||
9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V
|
||||
LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs
|
||||
BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc
|
||||
HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66
|
||||
UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC
|
||||
AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY
|
||||
MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
|
||||
hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj
|
||||
GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/
|
||||
Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S
|
||||
vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA
|
||||
I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2
|
||||
vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs=
|
||||
-----END CERTIFICATE-----
|
||||
""".trimIndent()
|
||||
|
||||
// `openssl x509 -noout -fingerprint -sha256` over the same cert.
|
||||
const val EXPECTED_FINGERPRINT =
|
||||
"C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA"
|
||||
|
||||
val CERT: X509Certificate by lazy {
|
||||
CertificateFactory
|
||||
.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray()))
|
||||
.let { it as X509Certificate }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun certFingerprintMatchesOpenSsl() {
|
||||
assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() {
|
||||
val tm = PinningTrustManager { "" }
|
||||
val e =
|
||||
assertFailsWith<TlsFingerprintRequired> {
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedPinAcceptsTheSelfSignedCert() {
|
||||
val tm = PinningTrustManager { EXPECTED_FINGERPRINT }
|
||||
// Must not throw: the user confirmed exactly this certificate.
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrongPinStillFailsWithThePresentedFingerprint() {
|
||||
val tm = PinningTrustManager { "DE:AD:BE:EF" }
|
||||
val e =
|
||||
assertFailsWith<TlsFingerprintRequired> {
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinIsReadLive() {
|
||||
// The provider is a lambda: a pin saved AFTER the manager was built
|
||||
// (the confirm dialog's action) takes effect without rebuilding it.
|
||||
var pin = ""
|
||||
val tm = PinningTrustManager { pin }
|
||||
assertFailsWith<TlsFingerprintRequired> {
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
pin = EXPECTED_FINGERPRINT
|
||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unwrapFindsNestedTlsFingerprintRequired() {
|
||||
val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT)
|
||||
// The JSSE/OkHttp layers wrap the trust manager's exception in an
|
||||
// (SSL) handshake IOException — the unwrap must find it nested.
|
||||
val wrapped = IOException("PKIX path building failed", inner)
|
||||
val found = tlsFingerprintRequired(wrapped)
|
||||
assertNotNull(found)
|
||||
assertEquals(EXPECTED_FINGERPRINT, found.fingerprint)
|
||||
// Unrelated failures must not be misread as a confirm request.
|
||||
assertNull(tlsFingerprintRequired(IOException("remote host closed connection")))
|
||||
assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinningSocketFactoryCreatesSockets() {
|
||||
val tm = PinningTrustManager { "" }
|
||||
val factory = pinningSslSocketFactory(tm)
|
||||
val socket = factory.createSocket()
|
||||
assertTrue(socket.javaClass.name.contains("SSL"))
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** Wire tests for the channel directory `created` field (thread ordering). */
|
||||
class ChannelCreatedWireTest {
|
||||
@Test
|
||||
fun channelInfoDeserializesCreated() {
|
||||
val raw =
|
||||
"""
|
||||
{"v":1,"type":"channel.created","payload":{"chat_id":"t_9","name":"New topic",
|
||||
"kind":"thread","parent_chat_id":"default","created":1787648374.37}}
|
||||
""".trimIndent()
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
val info = frame.payloadAs<ChannelInfo>()
|
||||
assertEquals("t_9", info?.chatId)
|
||||
assertEquals(1787648374.37, info?.created)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun channelInfoDefaultsCreatedToZero() {
|
||||
// Legacy gateways omit the field; the app falls back to name ordering.
|
||||
val raw =
|
||||
"""{"v":1,"type":"channel.created","payload":{"chat_id":"t_1","name":"Old","kind":"thread"}}"""
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
assertEquals(0.0, frame.payloadAs<ChannelInfo>()?.created)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun threadsSortNewestFirst() {
|
||||
// Mirrors the topic-switcher ordering in ChatScreen: created desc,
|
||||
// name asc as the tie-break (e.g. for legacy entries with created==0).
|
||||
val threads =
|
||||
listOf(
|
||||
ChannelInfo(chatId = "t_2", name = "Capital of Romania", kind = "thread", parentChatId = "default", created = 1787232736.0),
|
||||
ChannelInfo(chatId = "t_1", name = "Capital of France", kind = "thread", parentChatId = "default", created = 1787232221.0),
|
||||
ChannelInfo(
|
||||
chatId = "t_9",
|
||||
name = "Test file operations",
|
||||
kind = "thread",
|
||||
parentChatId = "default",
|
||||
created = 1787422924.0,
|
||||
),
|
||||
ChannelInfo(chatId = "t_0", name = "Legacy b", kind = "thread", parentChatId = "default"),
|
||||
ChannelInfo(chatId = "t_0a", name = "Legacy a", kind = "thread", parentChatId = "default"),
|
||||
)
|
||||
val ordered =
|
||||
threads.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
|
||||
assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** Wire tests for the hello.ack payload (docs/04). */
|
||||
class HelloAckWireTest {
|
||||
@Test
|
||||
fun helloAckDeserializesDeviceToken() {
|
||||
val raw =
|
||||
"""
|
||||
{"v":1,"type":"hello.ack","payload":{"sync_cursor":5,
|
||||
"last_pushed_cursor":3,"device_token":"9f2c64hex"}}
|
||||
""".trimIndent()
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
assertEquals("hello.ack", frame.type)
|
||||
val p = frame.payloadAs<HelloAckPayload>()
|
||||
assertEquals("9f2c64hex", p?.deviceToken)
|
||||
assertEquals(5L, p?.syncCursor)
|
||||
assertEquals(3L, p?.lastPushedCursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun helloAckDefaultsDeviceTokenToEmpty() {
|
||||
// Legacy gateways (pre per-device tokens) omit the field entirely.
|
||||
val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}"""
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
val p = frame.payloadAs<HelloAckPayload>()
|
||||
assertEquals("", p?.deviceToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Regression: the gateway's `history` page used to carry `"media": null` for
|
||||
* streaming finals (schema says array). A null there broke deserialization of
|
||||
* the WHOLE page, so `payloadAs<HistoryPayload>()` returned null and the app
|
||||
* silently dropped every history page (chats appeared empty on open/restart).
|
||||
*/
|
||||
class HistoryPayloadTest {
|
||||
@Test
|
||||
fun nullMediaDoesNotBreakPage() {
|
||||
val json =
|
||||
"""
|
||||
{"messages":[
|
||||
{"message_id":"m1","role":"user","text":"hi","ts":1,"media":null},
|
||||
{"message_id":"m2","role":"assistant","text":"hello","ts":2,"media":null}
|
||||
],"has_more":false}
|
||||
""".trimIndent()
|
||||
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
|
||||
assertEquals(2, p.messages.size)
|
||||
assertNull(p.messages[0].media)
|
||||
assertEquals("hello", p.messages[1].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentMediaDefaultsToEmpty() {
|
||||
val json =
|
||||
"""
|
||||
{"messages":[
|
||||
{"message_id":"m1","role":"user","text":"hi","ts":1},
|
||||
{"message_id":"m2","role":"assistant","text":"hello","ts":2,
|
||||
"media":[{"media_id":"med1","kind":"image","mime":"image/png","size":1,"filename":"a.png"}]}
|
||||
],"has_more":false}
|
||||
""".trimIndent()
|
||||
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
|
||||
assertEquals(2, p.messages.size)
|
||||
assertEquals(emptyList<MediaRef>(), p.messages[0].media ?: emptyList())
|
||||
assertEquals(
|
||||
"med1",
|
||||
p.messages[1]
|
||||
.media
|
||||
?.firstOrNull()
|
||||
?.mediaId,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package iris.protocol
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/** Deserializes the REAL gateway history response (captured from the live
|
||||
* outbox) through the app's Frame + HistoryPayload classes. */
|
||||
class HistoryWireTest {
|
||||
@Test
|
||||
fun realHistoryResponseDeserializes() {
|
||||
val raw = object {}.javaClass.getResource("/history_resp.json")!!.readText()
|
||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
||||
assertEquals("history", frame.type)
|
||||
val p = frame.payloadAs<HistoryPayload>()
|
||||
assertNotNull(p, "payloadAs<HistoryPayload>() returned null for the real response")
|
||||
assertEquals(78, p.messages.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package iris.ui
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HtmlArtifactTest {
|
||||
|
||||
@Test
|
||||
fun htmlLanguageTagIsArtifact() {
|
||||
assertTrue(isHtmlArtifact("html", "<p>hi</p>"))
|
||||
assertTrue(isHtmlArtifact("htm", "<p>hi</p>"))
|
||||
assertTrue(isHtmlArtifact("HTML", "<p>hi</p>"))
|
||||
assertTrue(isHtmlArtifact(" html ", "<p>hi</p>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherLanguageTagsAreNotArtifacts() {
|
||||
assertFalse(isHtmlArtifact("css", "<style>x</style>"))
|
||||
assertFalse(isHtmlArtifact("js", "console.log(1)"))
|
||||
assertFalse(isHtmlArtifact("kotlin", "fun main() {}"))
|
||||
// Even a full document inside a non-html fence is not an artifact.
|
||||
assertFalse(isHtmlArtifact("text", "<!DOCTYPE html><html></html>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun untaggedCompleteDocumentIsArtifact() {
|
||||
assertTrue(isHtmlArtifact(null, "<!DOCTYPE html>\n<html><body>hi</body></html>"))
|
||||
assertTrue(isHtmlArtifact(null, " <html><body>hi</body></html>"))
|
||||
assertTrue(isHtmlArtifact(null, "<HTML><BODY>hi</BODY></HTML>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun untaggedFragmentIsNotArtifact() {
|
||||
assertFalse(isHtmlArtifact(null, "<div>just a fragment</div>"))
|
||||
assertFalse(isHtmlArtifact(null, "Some prose with <b>bold</b> inside."))
|
||||
assertFalse(isHtmlArtifact(null, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsTitle() {
|
||||
assertEquals(
|
||||
"My Page",
|
||||
htmlTitle("<html><head><title>My Page</title></head><body></body></html>"),
|
||||
)
|
||||
assertEquals(
|
||||
"Spaced",
|
||||
htmlTitle("<title> Spaced </title>"),
|
||||
)
|
||||
// Case-insensitive, attributes allowed.
|
||||
assertEquals(
|
||||
"X",
|
||||
htmlTitle("<TITLE lang=\"en\">X</TITLE>"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOrEmptyTitleIsNull() {
|
||||
assertNull(htmlTitle("<html><body>no title</body></html>"))
|
||||
assertNull(htmlTitle("<title></title>"))
|
||||
assertNull(htmlTitle("<title> </title>"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FuzzyTest {
|
||||
@Test
|
||||
fun emptyQueryMatchesEverything() {
|
||||
assertEquals(0, fuzzyScore("", "new"))
|
||||
assertEquals(0, fuzzyScore("", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefixMatchRanksBest() {
|
||||
assertEquals(1, fuzzyScore("mod", "model"))
|
||||
assertEquals(1, fuzzyScore("NEW", "new"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subsequenceMatchesInOrder() {
|
||||
assertTrue(fuzzyScore("nw", "new") >= 0)
|
||||
assertTrue(fuzzyScore("mdl", "model") >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outOfOrderDoesNotMatch() {
|
||||
assertEquals(-1, fuzzyScore("wn", "new"))
|
||||
assertEquals(-1, fuzzyScore("xyz", "model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consecutiveRunBeatsGapped() {
|
||||
// "sta" is a consecutive run in "status"; "stu" is gapped in "status".
|
||||
assertTrue(fuzzyScore("sta", "status") < fuzzyScore("stu", "status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefixBeatsSubsequence() {
|
||||
assertTrue(fuzzyScore("mod", "model") < fuzzyScore("mdl", "model"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MarkdownTest {
|
||||
@Test
|
||||
fun stripsLeadingNewlines() {
|
||||
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
||||
assertEquals("Hello", "\nHello".prepareForMarkdown())
|
||||
assertEquals("Hello", "\r\n\r\nHello".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripsLeadingBlankLines() {
|
||||
assertEquals("Hello", "\n \n\t\nHello".prepareForMarkdown())
|
||||
assertEquals("a\nb", "\n\na\nb".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesLeadingIndentationOnFirstLine() {
|
||||
// An indented code block at the very start must still parse as code.
|
||||
assertEquals(" code", "\n\n code".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesInteriorWhitespaceUntouched() {
|
||||
val md = "Line one\n\nLine two\n\n- item"
|
||||
assertEquals(md, md.prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noLeadingNewlinesIsUnchanged() {
|
||||
assertEquals("Hello world", "Hello world".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allBlankBecomesEmpty() {
|
||||
assertEquals("", "\n\n \n".prepareForMarkdown())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convertsSingleNewlinesToHardBreaks() {
|
||||
assertEquals("a \nb ", "a\nb".preserveNewlinesAsHardBreaks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsBlankLinesAsParagraphBreaks() {
|
||||
assertEquals("a \n\nb ", "a\n\nb".preserveNewlinesAsHardBreaks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavesFencedCodeBlocksUntouched() {
|
||||
val md = "```kotlin\nval a = 1\nval b = 2\n```"
|
||||
assertEquals(md, md.preserveNewlinesAsHardBreaks())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusStyleLinesBecomeHardBreaks() {
|
||||
val md = "**Title:** x\n**Model:** y"
|
||||
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||