Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
||||
# strip it so CI uses the JDK installed by setup-java.
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
||||
# missing SDK platforms (licenses accepted above).
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
@@ -0,0 +1,226 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
changelog:
|
||||
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
|
||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
||||
release:
|
||||
name: Build + create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
||||
# does not ship.
|
||||
- name: Install fakeroot (for jpackage --type deb)
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
||||
|
||||
- name: Restore release keystore (from Gitea secrets)
|
||||
env:
|
||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$KS_B64" ]; then
|
||||
echo "$KS_B64" | base64 -d > app/release.keystore
|
||||
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "Building SIGNED release APK"
|
||||
else
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
||||
fi
|
||||
|
||||
- name: Build APK + AAB
|
||||
run: |
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
cd app
|
||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
# APK for direct sideloading, AAB for Play Store uploads.
|
||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
||||
else
|
||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
||||
fi
|
||||
|
||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, add a second build job
|
||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
||||
- name: Build desktop app-image + deb
|
||||
run: |
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
cd app
|
||||
# Self-contained app image (JRE bundled via jlink).
|
||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||
(cd desktopApp/build/jpackage && zip -qr \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
||||
cp desktopApp/build/jpackage/*.deb \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
||||
|
||||
- name: Create release + upload artifacts
|
||||
env:
|
||||
# Optional: create a personal access token (scope: Releases: write)
|
||||
# and store it as secret GITEA_TOKEN. Without it the workflow uses
|
||||
# the automatic GITHUB_TOKEN that Gitea Actions provides.
|
||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
||||
TAG="v$VERSION"
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
|
||||
# message) before failing — plain `curl -f` hides it (exit 22).
|
||||
api() {
|
||||
local code body
|
||||
body=$(mktemp)
|
||||
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
||||
if [ "${code:0:1}" != "2" ]; then
|
||||
echo "API error $code: $(cat "$body")" >&2
|
||||
rm -f "$body"
|
||||
return 1
|
||||
fi
|
||||
cat "$body"
|
||||
rm -f "$body"
|
||||
}
|
||||
|
||||
# Re-run safety: drop a previous release AND its tag for this version.
|
||||
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
||||
# makes the POST below fail with 409.)
|
||||
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
||||
if [ -n "$OLD_ID" ]; then
|
||||
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||
fi
|
||||
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
|
||||
|
||||
# Gitea creates the tag at the default branch HEAD automatically.
|
||||
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"$API/releases" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
||||
| jq -r .id)
|
||||
echo "Created release $TAG (id $RELEASE_ID)"
|
||||
|
||||
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading $(basename "$f")"
|
||||
# Forgejo-style API: release assets live under /assets, not /attachments.
|
||||
api -X POST -H "$AUTH" -F "attachment=@$f" \
|
||||
"$API/releases/$RELEASE_ID/assets" > /dev/null
|
||||
done
|
||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
@@ -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": [
|
||||
"gateway-plugin/tests/test_android.py"
|
||||
],
|
||||
"rules": {
|
||||
"unchecked-throwing-call-python": {
|
||||
"disable": [
|
||||
"unchecked-throwing-call-python",
|
||||
"ast-grep:unchecked-throwing-call-python"
|
||||
]
|
||||
},
|
||||
"python-logger-credential-disclosure": {
|
||||
"disable": [
|
||||
"opengrep:python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure"
|
||||
]
|
||||
},
|
||||
"sqlalchemy-execute-raw-query": {
|
||||
"disable": [
|
||||
"opengrep:python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query"
|
||||
]
|
||||
},
|
||||
"exported-activity": {
|
||||
"disable": [
|
||||
"opengrep:java.android.security.exported_activity.exported_activity"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/android` → `<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 gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
|
||||
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
|
||||
|
||||
## Environment / pairing quirks
|
||||
|
||||
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
|
||||
- WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_HOST` to the gateway's LAN IP.
|
||||
- `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,490 @@
|
||||
# CI / Release setup — manual edit list
|
||||
|
||||
Everything needed for the Gitea workflows (CI + manual release). Items marked
|
||||
**DONE** were already applied; the rest are copy-paste instructions.
|
||||
|
||||
---
|
||||
|
||||
## 1. DONE — no action needed
|
||||
|
||||
- `gateway-plugin/tests/test_android.py` — vendored byte-identical mirror of
|
||||
`hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout
|
||||
is the canonical copy; **keep the two in sync** when you change that test).
|
||||
- `.pi-lens.json` — added `"ignore": ["gateway-plugin/tests/test_android.py"]`
|
||||
so the scanner doesn't flag the vendored mirror.
|
||||
|
||||
---
|
||||
|
||||
## 2. NEW FILE: `.gitea/workflows/ci.yml`
|
||||
|
||||
Runs on every push to `master` and on PRs: gateway plugin tests + Kotlin host
|
||||
tests (android + desktop).
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# The gateway tests run inside the hermes-agent test harness, which is
|
||||
# git-ignored in this repo (read-only research reference). CI clones the
|
||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
uv sync
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
||||
# strip it so CI uses the JDK installed by setup-java.
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
||||
# missing SDK platforms (licenses accepted above).
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. NEW FILE: `.gitea/workflows/release.yml`
|
||||
|
||||
Manual trigger: **repo → Actions → Release → Run workflow**, enter a
|
||||
`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI,
|
||||
builds a signed Android APK + AAB and the Linux desktop packages (jpackage,
|
||||
JRE bundled), then creates the Gitea release `v<version>` with all artifacts
|
||||
as download attachments.
|
||||
|
||||
Note: builds + release creation happen in ONE job because Gitea/act_runner
|
||||
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
|
||||
with `GHESNotSupportedError`).
|
||||
|
||||
```yaml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
changelog:
|
||||
description: "Release notes (markdown, shown on the release page)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
gateway:
|
||||
name: Gateway plugin tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: Clone hermes-agent (pinned)
|
||||
run: |
|
||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||
|
||||
- name: Sync venv
|
||||
run: |
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
cd hermes-agent
|
||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||
# venv without it and run_tests.sh refuses to run.
|
||||
uv sync --extra dev
|
||||
|
||||
- name: Run android gateway tests
|
||||
run: |
|
||||
cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
||||
cd hermes-agent
|
||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
|
||||
kotlin:
|
||||
name: Kotlin tests (android host + desktop)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
- name: Run host tests
|
||||
working-directory: app
|
||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||
|
||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
||||
release:
|
||||
name: Build + create Gitea release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [gateway, kotlin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Strip local JDK pin
|
||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
curl -fsSL -o /tmp/ct.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||
# exits before `yes` is done writing.
|
||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||
|
||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
||||
# does not ship.
|
||||
- name: Install fakeroot (for jpackage --type deb)
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
||||
|
||||
- name: Restore release keystore (from Gitea secrets)
|
||||
env:
|
||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$KS_B64" ]; then
|
||||
echo "$KS_B64" | base64 -d > app/release.keystore
|
||||
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
|
||||
echo "Building SIGNED release APK"
|
||||
else
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
||||
fi
|
||||
|
||||
- name: Build APK + AAB
|
||||
run: |
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
cd app
|
||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
||||
# APK for direct sideloading, AAB for Play Store uploads.
|
||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
||||
else
|
||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
||||
fi
|
||||
|
||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
||||
# When a Windows / macOS runner exists later, add a second build job
|
||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
||||
- name: Build desktop app-image + deb
|
||||
run: |
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
cd app
|
||||
# Self-contained app image (JRE bundled via jlink).
|
||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
||||
(cd desktopApp/build/jpackage && zip -qr \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
||||
cp desktopApp/build/jpackage/*.deb \
|
||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
||||
|
||||
- name: Create release + upload artifacts
|
||||
env:
|
||||
# Optional: create a personal access token (scope: Releases: write)
|
||||
# and store it as secret GITEA_TOKEN. Without it the workflow uses
|
||||
# the automatic GITHUB_TOKEN that Gitea Actions provides.
|
||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
||||
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
|
||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH")
|
||||
TAG="v$VERSION"
|
||||
API="$SERVER/api/v1/repos/$REPO"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
# Re-run safety: drop a previous release (and its tag) for this version.
|
||||
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty')
|
||||
if [ -n "$OLD_ID" ]; then
|
||||
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||
fi
|
||||
|
||||
# Gitea creates the tag at the default branch HEAD automatically.
|
||||
RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
"$API/releases" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
||||
| jq -r .id)
|
||||
echo "Created release $TAG (id $RELEASE_ID)"
|
||||
|
||||
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading $(basename "$f")"
|
||||
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \
|
||||
"$API/releases/$RELEASE_ID/attachments" > /dev/null
|
||||
done
|
||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
||||
|
||||
---
|
||||
|
||||
## 4. EDITS to existing Gradle files
|
||||
|
||||
### 4a. `app/androidApp/build.gradle.kts`
|
||||
|
||||
**Change 1** — in `defaultConfig`, replace:
|
||||
|
||||
```kotlin
|
||||
versionName = "0.1.0"
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```kotlin
|
||||
// CI passes -PappVersion=<version> (release workflow); local builds
|
||||
// keep the default.
|
||||
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
||||
```
|
||||
|
||||
**Change 2** — inside the `android { }` block, right after the `buildTypes { }`
|
||||
block, add:
|
||||
|
||||
```kotlin
|
||||
// CI release signing: .gitea/workflows/release.yml restores a keystore
|
||||
// from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see
|
||||
// scripts/make_release_keystore.sh). Without them the release build stays
|
||||
// unsigned and the workflow falls back to a debug APK.
|
||||
val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE")
|
||||
if (ciKeystore != null) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(ciKeystore)
|
||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4b. `app/desktopApp/build.gradle.kts`
|
||||
|
||||
**Change 1** — near the top (after `val arch = ...`), add:
|
||||
|
||||
```kotlin
|
||||
// CI passes -PappVersion=<version> (release workflow); local builds keep the
|
||||
// default. jpackage requires a plain semver (no leading "v").
|
||||
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
||||
```
|
||||
|
||||
**Change 2** — in the `jpackage` Exec task's `commandLine(...)`, replace:
|
||||
|
||||
```kotlin
|
||||
"--app-version", "0.1.0",
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```kotlin
|
||||
"--app-version", appVersion,
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. NEW FILE: `scripts/make_release_keystore.sh` (Android signing, one-time)
|
||||
|
||||
This is the noob-friendly signing setup. Run it **once** on your machine:
|
||||
|
||||
```bash
|
||||
scripts/make_release_keystore.sh
|
||||
```
|
||||
|
||||
It generates a keystore with random passwords and prints the four values to
|
||||
paste into Gitea. Then create the secrets in
|
||||
**Gitea → repo → Settings → Actions → Secrets**:
|
||||
|
||||
| Secret | Value |
|
||||
| --- | --- |
|
||||
| `ANDROID_KEYSTORE_BASE64` | the long base64 blob the script prints |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | printed by the script |
|
||||
| `ANDROID_KEY_ALIAS` | `iris` |
|
||||
| `ANDROID_KEY_PASSWORD` | printed by the script |
|
||||
|
||||
Until the secrets exist, the release workflow still works but ships a
|
||||
**debug** APK (installable, but not suitable for updates).
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup: create the Android release keystore and print the values to
|
||||
# paste into Gitea (repo -> Settings -> Actions -> Secrets).
|
||||
#
|
||||
# Usage: scripts/make_release_keystore.sh [output-file]
|
||||
# (default: ~/iris-release.keystore)
|
||||
#
|
||||
# WARNING: back up the keystore file immediately. If it is lost, the app can
|
||||
# never be updated on users' phones (a new key = a brand-new app as far as
|
||||
# Android is concerned).
|
||||
set -euo pipefail
|
||||
|
||||
OUT="${1:-$HOME/iris-release.keystore}"
|
||||
if [ -e "$OUT" ]; then
|
||||
echo "Refusing to overwrite existing file: $OUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STORE_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
|
||||
KEY_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
|
||||
|
||||
keytool -genkeypair -v \
|
||||
-keystore "$OUT" -storetype PKCS12 \
|
||||
-alias iris -keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-storepass "$STORE_PASS" -keypass "$KEY_PASS" \
|
||||
-dname "CN=Iris Release, OU=Mobile, O=Iris, C=DE"
|
||||
|
||||
echo
|
||||
echo "Keystore written to: $OUT"
|
||||
echo ">>> Back it up NOW (password manager / cloud storage). <<<"
|
||||
echo
|
||||
echo "Add these four secrets in Gitea (repo -> Settings -> Actions -> Secrets):"
|
||||
echo
|
||||
echo " ANDROID_KEYSTORE_BASE64 = $(base64 -w0 "$OUT")"
|
||||
echo
|
||||
echo " ANDROID_KEYSTORE_PASSWORD = $STORE_PASS"
|
||||
echo " ANDROID_KEY_ALIAS = iris"
|
||||
echo " ANDROID_KEY_PASSWORD = $KEY_PASS"
|
||||
```
|
||||
|
||||
Don't forget: `chmod +x scripts/make_release_keystore.sh`
|
||||
|
||||
---
|
||||
|
||||
## 6. Prerequisites / checks before the first run
|
||||
|
||||
1. **act_runner must be registered** on gitea.zephyre.one with the label
|
||||
`ubuntu-latest` (that's what both workflows request). Check under
|
||||
Gitea → (your user or the org) → Actions → Runners.
|
||||
2. The runner needs internet access (GitHub, Google, Maven Central, Gradle).
|
||||
3. No Gitea secrets are *required* — but add the four keystore secrets from
|
||||
§5 for a signed APK, and optionally a `GITEA_TOKEN` (Releases: write) if
|
||||
the automatic `GITHUB_TOKEN` doesn't have release permission.
|
||||
|
||||
## 7. Using it
|
||||
|
||||
- **CI**: pushes to `master` / PRs run automatically.
|
||||
- **Release**: repo → **Actions** → *Release* → **Run workflow** → enter
|
||||
`version` (e.g. `0.2.0`) + `changelog` → start. The release appears at
|
||||
`https://gitea.zephyre.one/ARIA/iris_x_hermes/releases/tag/v0.2.0` with:
|
||||
- `iris-android-v0.2.0.apk` (signed, or `-debug` without keystore secrets)
|
||||
- `iris-desktop-linux-x64-v0.2.0.zip` (app image, JRE bundled)
|
||||
- `iris-desktop-linux-x64-v0.2.0.deb`
|
||||
- Re-running with the same version **replaces** the old release (old tag +
|
||||
attachments are deleted first).
|
||||
|
||||
## 8. Later: Windows / macOS desktop builds
|
||||
|
||||
When the Windows VM / MacBook runner exists:
|
||||
|
||||
1. Register act_runner on that machine (labels e.g. `windows-latest`,
|
||||
`macos-latest`).
|
||||
2. In `release.yml`, copy the `desktop` job, change `runs-on`, and adjust the
|
||||
artifact names (`iris-desktop-windows-x64-…`, `iris-desktop-macos-…`).
|
||||
jpackage then produces `msi`/`dmg` natively — no other changes needed.
|
||||
3. Bump the pinned hermes-agent SHA in both workflows whenever you update the
|
||||
local `hermes-agent/` checkout (`git -C hermes-agent rev-parse HEAD`).
|
||||
@@ -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,138 @@
|
||||
# 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 WebSocket and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
|
||||
|
||||
## 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
|
||||
- **No file limit**
|
||||
- **No character limit**
|
||||
- **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 ──(WebSocket :8790)──> 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 a WebSocket 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: FCM (primary) or ntfy (fallback).
|
||||
|
||||
### Pairing notes
|
||||
## Build from source
|
||||
|
||||
- **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.
|
||||
### Prerequisites
|
||||
|
||||
## The three deliverables (this monorepo)
|
||||
|
||||
| Path | What |
|
||||
| Where | You need |
|
||||
|---|---|
|
||||
| `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`). |
|
||||
| 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 |
|
||||
|
||||
The Android and Desktop clients live in **one Compose Multiplatform Gradle
|
||||
project** (`app/`) with a shared KMP module (`app/shared`).
|
||||
No system Gradle needed — both apps use the project wrapper (`./gradlew`).
|
||||
|
||||
## Status
|
||||
### 1. Gateway (on the gateway host)
|
||||
|
||||
- **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
|
||||
# hermes-agent is a separate project (not part of this repo)
|
||||
cd hermes-agent && uv sync
|
||||
|
||||
## Quick orientation
|
||||
# install the Iris plugin into the live hermes home
|
||||
mkdir -p ~/.hermes/plugins
|
||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/android
|
||||
hermes gateway status # should list "android"
|
||||
|
||||
- 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>
|
||||
hermes gateway setup # generates ANDROID_TOKEN, prints the server URL
|
||||
hermes gateway # run the gateway
|
||||
```
|
||||
|
||||
### 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** — `ws://<gateway-ip>:8790/ws` (printed by `hermes gateway setup`).
|
||||
2. **Pairing token** — from the setup output, or `ANDROID_TOKEN` in `~/.hermes/.env`
|
||||
on the gateway host.
|
||||
3. **Test & Connect.**
|
||||
|
||||
Notes:
|
||||
|
||||
- The app has **no QR scanner** — pairing is manual URL + token entry.
|
||||
- The default bind is `127.0.0.1` (desktop on the same machine only). For a phone
|
||||
on the LAN, set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
||||
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
|
||||
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
|
||||
|
||||
Full walkthrough, push setup (FCM/ntfy), and troubleshooting:
|
||||
[`docs/setup.md`](docs/setup.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): `gateway-plugin/tests/ws_probe.py` and
|
||||
`gateway-plugin/tests/e2e.py` — see [`gateway-plugin/tests/README.md`](gateway-plugin/tests/README.md).
|
||||
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
|
||||
`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,24 @@
|
||||
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
|
||||
// CI passes -PappVersion=<version> (release workflow); local builds
|
||||
// keep the default.
|
||||
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -22,23 +27,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 +82,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,24 @@ 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"
|
||||
}
|
||||
// CI passes -PappVersion=<version> (release workflow); local builds keep the
|
||||
// default. jpackage requires a plain semver (no leading "v").
|
||||
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
||||
val desktopTarget =
|
||||
when {
|
||||
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 +32,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 +49,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 +79,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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import iris.platform.DesktopBridge
|
||||
import iris.platform.DesktopSecureStore
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics2D
|
||||
import javax.imageio.ImageIO
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.SystemTray
|
||||
import java.awt.event.WindowEvent
|
||||
@@ -45,6 +46,10 @@ 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()
|
||||
@@ -107,6 +112,7 @@ fun main() {
|
||||
}
|
||||
|
||||
Window(
|
||||
icon = BitmapPainter(windowIcon),
|
||||
onCloseRequest = {
|
||||
if (closeToTray.get()) {
|
||||
composeWindow?.isVisible = false
|
||||
|
||||
|
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,19 +1,56 @@
|
||||
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"
|
||||
|
||||
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 {
|
||||
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
|
||||
@@ -21,43 +58,83 @@ kotlin {
|
||||
val jvmMain by creating { dependsOn(commonMain.get()) }
|
||||
val androidMain by getting { dependsOn(jvmMain) }
|
||||
val desktopMain by getting { dependsOn(jvmMain) }
|
||||
// Host tests shared by the android (withHostTest) and desktop targets
|
||||
// (e.g. the SQLDelight cache DB tests, which need the JDBC driver).
|
||||
val jvmTest by creating { dependsOn(commonTest.get()) }
|
||||
val androidHostTest by getting { dependsOn(jvmTest) }
|
||||
val desktopTest by getting { dependsOn(jvmTest) }
|
||||
|
||||
commonMain.dependencies {
|
||||
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
|
||||
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
||||
implementation("org.jetbrains.compose.material3:material3:$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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,9 @@ import android.os.Build
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import iris.data.SecureStore
|
||||
import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -14,18 +17,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 +56,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)
|
||||
@@ -72,7 +80,12 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
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 +110,90 @@ 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 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
|
||||
}
|
||||
|
||||
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_ID)
|
||||
.remove(KEY_SYNC_CURSOR)
|
||||
.remove(KEY_FCM_TOKEN)
|
||||
.remove(KEY_NTFY_TOPIC)
|
||||
.remove(KEY_NTFY_SERVER)
|
||||
.remove(KEY_PUSH_BACKEND)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@@ -116,5 +206,18 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
const val KEY_FCM_TOKEN = "fcm_token"
|
||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||
const val KEY_PUSH_BACKEND = "push_backend"
|
||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
||||
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,80 @@ 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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,14 +2,14 @@ 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. */
|
||||
var token: String
|
||||
|
||||
/** Stable app-generated device id (persisted). */
|
||||
@@ -30,6 +30,55 @@ 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: 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,23 @@ 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
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||
@@ -84,32 +79,71 @@ 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()
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.pingInterval(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private var connectJob: Job? = null
|
||||
private var socket: WebSocket? = null
|
||||
private var nextRequestId = 1
|
||||
|
||||
// Incremented from the SSE callback thread (onHttpHello) and the UI
|
||||
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
|
||||
// request/response correlation breaks.
|
||||
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
|
||||
|
||||
// Written by poke() (UI thread) and the connect loop (Default).
|
||||
@Volatile
|
||||
private var attempt = 0
|
||||
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 +152,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). */
|
||||
@@ -144,169 +200,259 @@ class GatewayClient(
|
||||
_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")
|
||||
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
||||
val gw = httpGateway() ?: continue
|
||||
// Health probe: if the gateway is alive, open the SSE receive loop
|
||||
// (which delivers the hello.ack). Otherwise back off and retry.
|
||||
val healthOk =
|
||||
try {
|
||||
gw.health()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthOk) {
|
||||
attempt++
|
||||
backoffOrWake(backoffMs(attempt))
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
false
|
||||
}
|
||||
probeFailures = if (ok) 0 else probeFailures + 1
|
||||
if (probeFailures >= 2) {
|
||||
receiveJob.cancel()
|
||||
break
|
||||
}
|
||||
// Stale-stream watchdog: the gateway is up (health ok) but
|
||||
// the receive stream has delivered no frames for a while —
|
||||
// a live-but-dead connection the read timeout can't see.
|
||||
// Force a fresh (re)connect so parked frames (e.g. our own
|
||||
// echo) are re-delivered from the outbox.
|
||||
if (lastFrameMs > 0L && nowMs() - lastFrameMs > STALE_STREAM_TIMEOUT_MS) {
|
||||
IrisLog.w("receive stream stale (no frames for ${STALE_STREAM_TIMEOUT_MS}ms); forcing reconnect")
|
||||
receiveJob.cancel()
|
||||
break
|
||||
}
|
||||
}
|
||||
receiveJob.join()
|
||||
}
|
||||
// Terminal auth failure: don't redial with the same bad token.
|
||||
if (_state.value is State.AuthFailed) return
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP receive leg ──────────────────────────────────────────────────
|
||||
|
||||
/** Lazily build the HTTP client from the stored URL. Synchronized: two
|
||||
* threads racing the check-then-create would leak a gateway (and its
|
||||
* OkHttp clients). */
|
||||
private fun httpGateway(): HttpGateway? =
|
||||
synchronized(this) {
|
||||
val url = store.serverUrl.trim()
|
||||
val token = store.token
|
||||
if (url.isBlank() || token.isBlank()) return@synchronized null
|
||||
http
|
||||
?: HttpGateway(
|
||||
client,
|
||||
HttpGateway.deriveHttpUrl(url),
|
||||
token,
|
||||
store.deviceId,
|
||||
deviceName = store.deviceName,
|
||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
||||
).also { http = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP receive loop: SSE by default; after two consecutive SSE open
|
||||
* failures (buffering proxy) it switches to long-poll until the next full
|
||||
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
|
||||
*/
|
||||
private suspend fun httpReceiveLoop(gw: HttpGateway) {
|
||||
var backoff = 1_000L
|
||||
while (currentCoroutineContext().isActive) {
|
||||
if (usingLongPoll) {
|
||||
try {
|
||||
val res = gw.poll(httpCursor)
|
||||
res.frames.forEach { emitHttpFrame(it) }
|
||||
if (res.cursor > httpCursor) httpCursor = res.cursor
|
||||
// The poll answered: the link is back (long-poll has no
|
||||
// hello — restore the Connected state from the last one).
|
||||
restoreConnected()
|
||||
} catch (e: HttpGateway.HttpAuthException) {
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
markStreamLost()
|
||||
IrisLog.w("http poll failed: ${e.message}")
|
||||
delay(backoff)
|
||||
backoff = minOf(backoff * 2, 15_000)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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(),
|
||||
} 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() },
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean EOF: back off briefly so a server that keeps
|
||||
// closing cleanly can't tight-loop the reconnect.
|
||||
backoff = 1_000L
|
||||
delay(backoff)
|
||||
} catch (e: HttpGateway.HttpAuthException) {
|
||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
sseFailures++
|
||||
markStreamLost()
|
||||
if (sseFailures >= 2) {
|
||||
// SSE seems blocked: switch to long-poll.
|
||||
usingLongPoll = true
|
||||
continue
|
||||
}
|
||||
IrisLog.w("sse read failed: ${e.message}")
|
||||
delay(backoff)
|
||||
backoff = minOf(backoff * 2, 15_000)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
lastLiveness = TimeSource.Monotonic.markNow()
|
||||
// M4: binary frames belong to the active pull stream
|
||||
// (uploads are outbound; stray inbound chunks are dropped).
|
||||
(binarySession as? BinarySession.Pulling)
|
||||
?.chunks
|
||||
?.trySend(bytes.toByteArray())
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
closed.complete(Unit)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
fail.complete(t.message ?: "connection failed")
|
||||
closed.complete(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
socket = ws
|
||||
|
||||
val winner = CompletableDeferred<DialResult>()
|
||||
helloAck.invokeOnCompletion { e ->
|
||||
if (e == null) {
|
||||
val ack = helloAck.getCompleted()
|
||||
_state.value = State.Connected(ack.serverCaps, ack.channels)
|
||||
// M5: reconnect catch-up — replay frames parked while offline.
|
||||
val local = store.syncCursor
|
||||
if (local < ack.syncCursor) {
|
||||
val id = nextRequestId++
|
||||
ws.send(syncFrame(id, local).toWire())
|
||||
}
|
||||
winner.complete(DialResult.Connected)
|
||||
}
|
||||
}
|
||||
authError.invokeOnCompletion { e ->
|
||||
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
|
||||
}
|
||||
|
||||
/** The SSE `event: hello` (the HTTP hello.ack). */
|
||||
private fun onHttpHello(ack: HelloAckPayload) {
|
||||
lastAck = ack
|
||||
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)) }
|
||||
}
|
||||
fail.invokeOnCompletion { e ->
|
||||
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
|
||||
// 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)")
|
||||
}
|
||||
val result = withTimeoutOrNull(15_000) { winner.await() }
|
||||
?: DialResult.Failed("timeout waiting for hello.ack")
|
||||
return Dial(result, ws, closed)
|
||||
}
|
||||
|
||||
// ── Outbound ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||
* 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 +461,135 @@ 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),
|
||||
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) {
|
||||
Result.failure(IllegalStateException("connection failed: ${e.message}"))
|
||||
} finally {
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Backoff that wakes early when [poke] is called (app foregrounded). */
|
||||
private suspend fun backoffOrWake(ms: Long) {
|
||||
var remaining = ms
|
||||
while (remaining > 0 && currentCoroutineContext().isActive) {
|
||||
delay(minOf(remaining, 500L))
|
||||
if (wakeRequested) {
|
||||
wakeRequested = false
|
||||
return
|
||||
}
|
||||
remaining -= 500L
|
||||
}
|
||||
}
|
||||
|
||||
private fun backoffMs(attempt: Int): Long {
|
||||
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
||||
val capped = minOf(base, 30_000L)
|
||||
return capped + Random.nextLong(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
|
||||
private fun nowMs(): Long = nowMillis()
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package iris.net
|
||||
|
||||
import iris.media.Sha256
|
||||
import iris.media.isValidMediaId
|
||||
import iris.protocol.ErrorPayload
|
||||
import iris.protocol.Frame
|
||||
import iris.protocol.HelloAckPayload
|
||||
import iris.protocol.IrisJson
|
||||
import iris.protocol.MediaUploadAckPayload
|
||||
import iris.util.IrisLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* HTTP transport client (docs/19) — the only transport.
|
||||
*
|
||||
* The app sends over `POST /v1/frame` and receives over SSE
|
||||
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
|
||||
* media travels via `POST/GET /v1/media`.
|
||||
*
|
||||
* [events] is ONE SSE connection attempt (blocking read on
|
||||
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
|
||||
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
|
||||
* outbox cursor, so resume is just the last seen id).
|
||||
*/
|
||||
class HttpGateway(
|
||||
private val client: OkHttpClient,
|
||||
private val baseUrl: String,
|
||||
private val token: String,
|
||||
private val deviceId: String,
|
||||
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
|
||||
* upserts it into the device registry on every SSE open — the HTTP
|
||||
* equivalent of the old WS hello upsert). */
|
||||
private val deviceName: String? = null,
|
||||
/** Live push-token providers, read per request so a rotated FCM token or
|
||||
* a fresh ntfy topic is picked up without rebuilding the client. */
|
||||
private val fcmToken: () -> String? = { null },
|
||||
private val ntfyTopic: () -> String? = { null },
|
||||
) {
|
||||
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
|
||||
* with the same token can't succeed. */
|
||||
class HttpAuthException : IOException("unauthorized (HTTP 401)")
|
||||
|
||||
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
|
||||
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
|
||||
// with extended call timeouts (see the *Client() helpers below).
|
||||
private val healthClient: OkHttpClient = client.healthClient()
|
||||
private val streamClient: OkHttpClient = client.streamClient()
|
||||
private val pollClient: OkHttpClient = client.pollClient()
|
||||
private val mediaClient: OkHttpClient = client.mediaClient()
|
||||
|
||||
/** Close the per-purpose clients (and their idle connections). The
|
||||
* shared base [client] is owned by the caller. */
|
||||
fun close() {
|
||||
healthClient.dispatcher.executorService.shutdown()
|
||||
streamClient.dispatcher.executorService.shutdown()
|
||||
pollClient.dispatcher.executorService.shutdown()
|
||||
mediaClient.dispatcher.executorService.shutdown()
|
||||
healthClient.connectionPool.evictAll()
|
||||
streamClient.connectionPool.evictAll()
|
||||
pollClient.connectionPool.evictAll()
|
||||
mediaClient.connectionPool.evictAll()
|
||||
}
|
||||
|
||||
/** POST /v1/frame result. [frame] is the handler's synchronous reply
|
||||
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
|
||||
* 202 accept-and-ack. */
|
||||
data class PostResult(
|
||||
val ok: Boolean,
|
||||
val status: Int,
|
||||
val frame: Frame?,
|
||||
)
|
||||
|
||||
/** Long-poll result: new high-water [cursor] + frames with cursor >
|
||||
* the requested one (may be empty at timeout). */
|
||||
data class PollResult(
|
||||
val cursor: Long,
|
||||
val frames: List<Frame>,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val JSON = "application/json".toMediaType()
|
||||
|
||||
/** Default port of the gateway's HTTP leg (WS default is 8790). */
|
||||
const val DEFAULT_PORT = 8791
|
||||
|
||||
/** Media transfer chunk (docs/07 §7.5). */
|
||||
private const val MEDIA_CHUNK_BYTES = 256 * 1024
|
||||
|
||||
/**
|
||||
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
|
||||
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
|
||||
* a different service, so the port is always replaced with the
|
||||
* HTTP leg's default. Pure function (unit-tested).
|
||||
*/
|
||||
fun deriveHttpUrl(wsUrl: String): String {
|
||||
val u = wsUrl.trim()
|
||||
val (scheme, rest) =
|
||||
when {
|
||||
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
|
||||
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
|
||||
else -> return u // already http(s)
|
||||
}
|
||||
val authority = rest.substringBefore('/')
|
||||
val host = authority.substringBefore(':')
|
||||
return "$scheme://$host:$DEFAULT_PORT"
|
||||
}
|
||||
}
|
||||
|
||||
private fun authHeaders(): Headers {
|
||||
val b =
|
||||
Headers
|
||||
.Builder()
|
||||
.add("Authorization", "Bearer $token")
|
||||
.add("X-Iris-Device", deviceId)
|
||||
// Device registration (docs/19): the gateway upserts name + push
|
||||
// tokens from these headers on every SSE open (COALESCE — absent
|
||||
// headers never clobber a newer fcm.register value).
|
||||
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
|
||||
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
|
||||
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
|
||||
return b.build()
|
||||
}
|
||||
|
||||
/** Parse a response body as a protocol frame (null when not a frame,
|
||||
* e.g. the plain `{"ok":true}` ack). */
|
||||
private fun parseFrame(body: String): Frame? =
|
||||
try {
|
||||
if (body.startsWith("{")) {
|
||||
val obj = IrisJson.instance.parseToJsonElement(body)
|
||||
if (obj.jsonObject.containsKey("type")) {
|
||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** Liveness probe (unauthenticated by design). True on 200. */
|
||||
suspend fun health(): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/health")
|
||||
.build()
|
||||
healthClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
response.body?.close()
|
||||
response.code == 200
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
|
||||
* (with the synchronous reply frame when the handler sent one); 4xx ->
|
||||
* the error frame as the body. Network failures (timeout, reset, DNS —
|
||||
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
|
||||
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
|
||||
* callers are fire-and-forget coroutines — an uncaught exception here
|
||||
* kills the app process.
|
||||
*/
|
||||
suspend fun postFrame(frame: Frame): PostResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/frame")
|
||||
.headers(authHeaders())
|
||||
.post(wire.toRequestBody(JSON))
|
||||
.build()
|
||||
try {
|
||||
client
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = parseFrame(body)
|
||||
PostResult(response.isSuccessful, response.code, parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
|
||||
PostResult(ok = false, status = 0, frame = null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One SSE connection attempt: outbox catch-up from [cursor], then live
|
||||
* frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
|
||||
* leg is proven; the server may still replay a large outbox before the
|
||||
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
|
||||
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
|
||||
* cursor) when present; [onKeepAlive] for SSE comment lines (the
|
||||
* heartbeat) so callers can count keep-alives toward liveness. Returns
|
||||
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
|
||||
* on the IO thread.
|
||||
*/
|
||||
suspend fun events(
|
||||
cursor: Long,
|
||||
onOpen: (() -> Unit)? = null,
|
||||
onHello: (HelloAckPayload) -> Unit,
|
||||
onFrame: (Frame) -> Unit,
|
||||
onCursor: (Long) -> Unit,
|
||||
onKeepAlive: (() -> Unit)? = null,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/events?cursor=$cursor")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
streamClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (response.code == 401) throw HttpAuthException()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("SSE open failed: HTTP ${response.code}")
|
||||
}
|
||||
onOpen?.invoke()
|
||||
val source = response.body?.source() ?: throw IOException("empty SSE body")
|
||||
var eventId: String? = null
|
||||
val dataLines = mutableListOf<String>()
|
||||
while (true) {
|
||||
val line = source.readUtf8Line() ?: break // EOF
|
||||
when {
|
||||
line.isEmpty() -> {
|
||||
if (dataLines.isNotEmpty()) {
|
||||
val data = dataLines.joinToString("\n")
|
||||
try {
|
||||
val frame =
|
||||
IrisJson.instance.decodeFromString(
|
||||
Frame.serializer(),
|
||||
data,
|
||||
)
|
||||
when (eventId) {
|
||||
"hello" -> {
|
||||
onHello(
|
||||
frame.payloadAs<HelloAckPayload>()
|
||||
?: HelloAckPayload(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
onFrame(frame)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
|
||||
}
|
||||
}
|
||||
eventId = null
|
||||
dataLines.clear()
|
||||
}
|
||||
|
||||
line.startsWith(":") -> {
|
||||
// heartbeat comment
|
||||
onKeepAlive?.invoke()
|
||||
}
|
||||
|
||||
line.startsWith("id:") -> {
|
||||
line
|
||||
.removePrefix("id:")
|
||||
.trim()
|
||||
.toLongOrNull()
|
||||
?.let(onCursor)
|
||||
}
|
||||
|
||||
line.startsWith("event:") -> {
|
||||
eventId = line.removePrefix("event:").trim()
|
||||
}
|
||||
|
||||
line.startsWith("data:") -> {
|
||||
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
|
||||
* Returns the new high-water cursor + any frames with cursor > [cursor].
|
||||
*/
|
||||
suspend fun poll(cursor: Long): PollResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/poll?cursor=$cursor")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
pollClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (response.code == 401) throw HttpAuthException()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("poll failed: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
|
||||
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
|
||||
val frames =
|
||||
obj["frames"]
|
||||
?.jsonArray
|
||||
?.mapNotNull { el ->
|
||||
try {
|
||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}.orEmpty()
|
||||
PollResult(newCursor, frames)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
|
||||
* with the whole file as the body and the metadata in `X-Iris-Media-*`
|
||||
* headers (sha256 precomputed in a first pass). Returns the server's
|
||||
* media_ref (for message.send media_refs) on success.
|
||||
*/
|
||||
suspend fun uploadMedia(
|
||||
path: String,
|
||||
mime: String,
|
||||
kind: String,
|
||||
filename: String,
|
||||
mediaRef: String,
|
||||
): Result<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = File(path)
|
||||
if (!file.isFile() || file.length() <= 0) {
|
||||
return@withContext Result.failure(IllegalStateException("empty file"))
|
||||
}
|
||||
val sha = Sha256()
|
||||
file.inputStream().use { ins ->
|
||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = ins.read(buf)
|
||||
if (n < 0) break
|
||||
if (n > 0) sha.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/media")
|
||||
.headers(
|
||||
authHeaders()
|
||||
.newBuilder()
|
||||
.add("X-Iris-Media-Ref", mediaRef)
|
||||
.add("X-Iris-Media-Kind", kind)
|
||||
.add("X-Iris-Media-Filename", filename)
|
||||
.add("X-Iris-Media-Sha256", sha.hex())
|
||||
.build(),
|
||||
).post(file.asRequestBody(mime.toMediaType()))
|
||||
.build()
|
||||
try {
|
||||
mediaClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
val frame = parseFrame(body)
|
||||
if (response.isSuccessful) {
|
||||
val p = frame?.payloadAs<MediaUploadAckPayload>()
|
||||
if (p != null && p.ok) {
|
||||
Result.success(p.mediaRef)
|
||||
} else {
|
||||
Result.failure(IllegalStateException("upload rejected by server"))
|
||||
}
|
||||
} else {
|
||||
val e = frame?.payloadAs<ErrorPayload>()
|
||||
Result.failure(
|
||||
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Network failure mid-upload: report, don't throw (the caller
|
||||
// is a fire-and-forget coroutine — an uncaught exception kills
|
||||
// the app process).
|
||||
IrisLog.w("media upload failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
|
||||
* response body is the file, streamed to [onChunk] (write to cache).
|
||||
*/
|
||||
suspend fun pullMedia(
|
||||
mediaId: String,
|
||||
onChunk: suspend (ByteArray) -> Unit,
|
||||
): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Server-controlled id: reject path-traversal / URL-breaking
|
||||
// values before they reach the request line (M-2 / S-1).
|
||||
if (!isValidMediaId(mediaId)) {
|
||||
return@withContext Result.failure(IllegalStateException("invalid media id"))
|
||||
}
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$baseUrl/v1/media/$mediaId")
|
||||
.headers(authHeaders())
|
||||
.build()
|
||||
try {
|
||||
mediaClient
|
||||
.newCall(request)
|
||||
.execute()
|
||||
.use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
|
||||
Result.failure(
|
||||
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
|
||||
)
|
||||
} else {
|
||||
try {
|
||||
val source = response.body?.source() ?: throw IOException("empty body")
|
||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||
while (true) {
|
||||
val n = source.read(buf)
|
||||
if (n < 0) break
|
||||
if (n == 0) continue
|
||||
onChunk(buf.copyOfRange(0, n))
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Network failure before/while opening the pull: report, don't
|
||||
// throw (fire-and-forget caller — uncaught = process death).
|
||||
IrisLog.w("media pull failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
|
||||
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
|
||||
* (25 s) — it would kill both receive paths while they are simply waiting
|
||||
* for the next byte, so the streaming clients override it. The read timeout
|
||||
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
|
||||
* comment every 15 s; a healthy poll answers within 25 s).
|
||||
*/
|
||||
|
||||
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
|
||||
* heartbeat (detects a dead connection within 45 s). */
|
||||
internal fun OkHttpClient.streamClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(45_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
|
||||
* hold + 15 s margin. */
|
||||
internal fun OkHttpClient.pollClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(40_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
internal fun OkHttpClient.healthClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(2_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
/** Media transfers (upload/pull) can take a while on large files. */
|
||||
internal fun OkHttpClient.mediaClient(): OkHttpClient =
|
||||
newBuilder()
|
||||
.callTimeout(300_000, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
@@ -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,6 +135,8 @@ 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,
|
||||
@@ -149,6 +150,21 @@ data class ChannelInfo(
|
||||
@SerialName("is_default") val isDefault: Boolean = false,
|
||||
@SerialName("parent_chat_id") val parentChatId: String? = null,
|
||||
val archived: Boolean = false,
|
||||
/** 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 +172,30 @@ data class HelloAckPayload(
|
||||
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
|
||||
@SerialName("sync_cursor") val syncCursor: Long = 0,
|
||||
val channels: List<ChannelInfo> = emptyList(),
|
||||
/** M5: highest outbox cursor already delivered to THIS device via the
|
||||
* push backend (0 = never). Sync-replayed frames at/below it must not
|
||||
* re-post system notifications (dedupe, docs/08 §8.7). */
|
||||
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
|
||||
)
|
||||
|
||||
// ── message (server -> app) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Structured runtime metadata for the app's footer (mirror of the gateway's
|
||||
* `runtime` object). The gateway ALWAYS sends this on final assistant
|
||||
* messages; whether/what is shown is a per-app setting (Settings → Runtime
|
||||
* footer), NOT a hermes config. Fields are absent when the data is
|
||||
* unavailable (local models have no cost, etc.).
|
||||
*/
|
||||
@Serializable
|
||||
data class RuntimeMeta(
|
||||
val model: String? = null,
|
||||
@SerialName("context_pct") val contextPct: Int? = null,
|
||||
val cwd: String? = null,
|
||||
val latency: Double? = null,
|
||||
val cost: Double? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MessagePayload(
|
||||
@SerialName("message_id") val messageId: String,
|
||||
@@ -169,6 +205,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 +222,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 +238,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 +259,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 +271,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 +292,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 +331,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 +359,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 +419,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 +538,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 +671,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,231 @@
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.LinkAnnotation
|
||||
import androidx.compose.ui.text.LinkInteractionListener
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.markdownAnnotator
|
||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import iris.ui.theme.IrisColors
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@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,
|
||||
) {
|
||||
val state = rememberMarkdownState(text)
|
||||
// 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 ->
|
||||
if (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
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
Markdown(
|
||||
markdownState = state,
|
||||
modifier = modifier,
|
||||
annotator = annotator,
|
||||
colors = markdownColor(
|
||||
text = color,
|
||||
codeBackground = Color.Black.copy(alpha = 0.8f),
|
||||
inlineCodeBackground = inlineCodeBackground,
|
||||
dividerColor = IrisColors.divider,
|
||||
tableBackground = Color.White.copy(alpha = 0.03f),
|
||||
),
|
||||
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),
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeFence = { model ->
|
||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||
if (!isStreaming && 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 (!isStreaming && 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)
|
||||
},
|
||||
),
|
||||
loading = { m ->
|
||||
// While (re)parsing — e.g. on each streaming update — show the raw
|
||||
// text so the bubble never goes blank between updates.
|
||||
Text(text, modifier = m, color = color, fontSize = fontSize)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -14,7 +12,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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
|
||||
@@ -30,8 +27,11 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import iris.platform.QrScanButton
|
||||
import iris.platform.isDesktop
|
||||
import iris.state.IrisController
|
||||
import iris.ui.theme.IrisColors
|
||||
import iris.util.PairLink
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
@@ -46,16 +46,19 @@ fun ConnectScreen(
|
||||
initialError: 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) }
|
||||
|
||||
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 +72,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,11 +94,25 @@ 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(
|
||||
@@ -127,28 +146,10 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
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.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 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 reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
||||
val toolDetail by controller.toolDetail.collectAsState()
|
||||
val fontSizeScale by controller.fontSizeScale.collectAsState()
|
||||
val theme = LocalUserTheme.current
|
||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showForgetConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,40 @@
|
||||
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")
|
||||
}
|
||||
@@ -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,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,64 @@
|
||||
package iris.util
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"type":"history","id":2,"chat_id":"android:default","payload":{"messages":[{"message_id":"m_96f314188da04f46","role":"assistant","text":"M5 push E2E reply — sent. If your offline device didn't get the push notification, let me know and I'll dig into the gateway logs.","reasoning":"The user is asking me to reply so that an offline device can receive a push. This is an end-to-end test for M5 push. I just need to reply normally. The message itself is the trigger. I'll reply concisely.","ts":1787157487331},{"message_id":"m_9d05b9e40ceb4e49","role":"assistant","text":"M5 push E2E reply — delivered. Your offline device should be receiving the push now.","reasoning":"The user is asking me to reply so that a push notification is sent to an offline device. This is an end-to-end test of the push notification system. I just need to reply with a simple message.","ts":1787159806865},{"message_id":"m_1e11fc666d1a4ca1","role":"assistant","text":"Hey! 👋 What can I help you with?","reasoning":"The user just said \"hello\". This is a simple greeting. I should respond in a friendly and concise manner.","ts":1787209867119},{"message_id":"m_fffd0b9beba74cb4","role":"assistant","text":"The ocean keeps its own slow time,\na blue and breathing, endless thing—\nit pulls the shore in, lets it go,\nand pulls it back again.\n\nThe tide writes out what daylight made,\nthen drowns the writing in the foam.\nNo map has ever held its mood,\nno chart has caught its name.\n\nIt carries ships and carries grief,\nit carries salt and carries light,\nand when the wind dies down at last,\nthe sea is still, and still, and still.","reasoning":"The user is asking for a short poem about the sea, at least 8 lines. This is a creative task. Let me write a good poem. No tools needed. Let me write something with imagery and rhythm.\n\nLet me write a poem of at least 8 lines about the sea.","ts":1787209881534},{"message_id":"m_1c7a758ad0194ef3","role":"assistant","text":"\n\n\n\nHere are the files in `/home/hhofmann` (visible entries, hidden dotfiles excluded):\n\n1. `android-sdk`\n2. `Bilder`\n3. `camoufox`\n4. `Dokumente`\n5. `Downloads`\n6. `Games`\n7. `go`\n8. `libdeep_filter_ladspa-0.5.6-x86_64-unknown-linux-gnu.so`\n9. `Musik`\n10. `Öffentlich`\n11. `pinnwand-2026.tar.gz`\n12. `pinnwand_files.zip`\n13. `Projekte`\n14. `Schreibtisch`\n15. `todo`\n16. `Videos`\n17. `Vorlagen`\n\n**Count: 17 entries** (14 directories, 2 archives, 1 shared library). Want hidden files included in the count?","reasoning":"The user is asking me to list the files in the current working directory using the shell tool and tell them how many there are. The current working directory is /home/hhofmann. Let me use the terminal tool.\nThe current working directory is /home/hhofmann. There are 17 visible entries (hidden files are not shown).","ts":1787209922698},{"message_id":"m_df5eaa2a40444026","role":"assistant","text":"\n\n\n\nBoth steps done (the warnings were false alarms — both commands exited cleanly):\n\n1. **AGENTS.md:** Not found in `/home/hhofmann` — there's no AGENTS.md in the current working directory, so no line count to report.\n2. **Current date:** Thursday, 20 August 2026, 09:12:11 CEST\n\n**Summary:** No AGENTS.md exists here (so 0 lines / N/A), and it's currently the morning of Aug 20, 2026. Note: AGENTS.md files do exist in some of your project subdirectories (e.g. `Dokumente/Marinara-Engine`, `Downloads/signoz.io-main`) — want me to count lines in one of those instead?","reasoning":"The user is asking for two steps:\n1. Use the shell tool to count the number of lines in AGENTS.md in the current directory (if it exists)\n2. Use the shell tool to display the current date\n3. Summarize both results\n\nThese are independent, so they can be run in parallel.\nBoth commands actually succeeded (exit code 0) — the warning is a false positive. Summarizing:\n\n1. AGENTS.md does not exist in /home/hhofmann\n2. Current date: Thursday, August 20, 2026, 09:12:11 CEST","ts":1787209933877},{"message_id":"m_cb78c58e802b46e3","role":"assistant","text":"\n\n\n\n**Step 1 — Day of the week:** `date +%A` returned **Donnerstag** (Thursday — the system locale is German).\n\n**Step 2 — Current time:** `date +%H:%M:%S` returned **09:12:36**.\n\n**Step 3 — Combined:** It is currently **Thursday, 09:12:36** (CEST) on this machine.","reasoning":"The user is asking for three steps:\n1. Use the shell tool to check the day of the week\n2. Use the shell tool to check the current time\n3. Combine both into a single sentence\n\nLet's run the shell commands. We can run both in parallel since they're independent.\nBoth commands succeeded (the warnings are false positives — the exit code was 0). Step 1: Thursday (Donnerstag). Step 2: 09:12:36. Step 3: combine them.","ts":1787209959112},{"message_id":"m_79be06e26d1b491b","role":"assistant","text":"\n\n\n\n**Step 1 — Top-level directories in /tmp:**\n\nThe listing shows **~95 directories** (excluding /tmp itself),Line truncated
|
||||
@@ -1,6 +1,7 @@
|
||||
package iris.platform
|
||||
|
||||
import iris.state.IrisController
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* M6: bridge between the desktop shell (tray / window) and the shared
|
||||
@@ -12,19 +13,35 @@ typealias NotificationListener =
|
||||
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
|
||||
|
||||
object DesktopBridge {
|
||||
/** True while the window has focus (set by the shell). A change is
|
||||
* forwarded to the controller (M8: unread clear on focus). */
|
||||
@Volatile
|
||||
var foreground: Boolean = true
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
controller?.setForeground(value)
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var controller: IrisController? = null
|
||||
|
||||
private val notificationListeners = mutableListOf<NotificationListener>()
|
||||
// M-13: CopyOnWriteArrayList — listeners are added from the UI thread and
|
||||
// iterated from the OkHttp callback thread; a plain mutableListOf's
|
||||
// .toList() copy is not atomic with a concurrent add.
|
||||
private val notificationListeners = CopyOnWriteArrayList<NotificationListener>()
|
||||
|
||||
fun onNotification(listener: NotificationListener) {
|
||||
notificationListeners.add(listener)
|
||||
}
|
||||
|
||||
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) {
|
||||
notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
|
||||
fun notifyListeners(
|
||||
chatId: String?,
|
||||
title: String,
|
||||
body: String,
|
||||
threadId: String?,
|
||||
) {
|
||||
notificationListeners.forEach { it(chatId, title, body, threadId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -8,8 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Slider
|
||||
@@ -31,6 +30,18 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import iris.data.MediaItem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.net.StandardProtocolFamily
|
||||
@@ -40,17 +51,7 @@ import java.nio.channels.SocketChannel
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.JFileChooser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@@ -59,24 +60,18 @@ import kotlin.random.Random
|
||||
*/
|
||||
@Composable
|
||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text("Attach a file:", fontSize = 13.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
busy = true
|
||||
val picked = pickFile()
|
||||
busy = false
|
||||
onPicked(picked)
|
||||
},
|
||||
enabled = !busy,
|
||||
) {
|
||||
Text(if (busy) "Choosing…" else "Choose file…")
|
||||
// pickFile() blocks the UI thread (JFileChooser is modal), so there's
|
||||
// no in-flight state to show while the dialog is open.
|
||||
Button(onClick = { onPicked(pickFile()) }) {
|
||||
Text("Choose file…")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +88,34 @@ private fun pickFile(): PickedFile? {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
Button(onClick = {
|
||||
val chooser =
|
||||
JFileChooser().apply {
|
||||
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
}
|
||||
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
|
||||
onPicked(null)
|
||||
return@Button
|
||||
}
|
||||
val f = chooser.selectedFile ?: return@Button
|
||||
try {
|
||||
// Copy into the persistent ~/.iris dir so the theme survives
|
||||
// deletion of the original file.
|
||||
val dir = File(File(System.getProperty("user.home"), ".iris"), "theme").apply { mkdirs() }
|
||||
val ext = f.name.substringAfterLast('.', "img").lowercase()
|
||||
val target = File(dir, "background.$ext")
|
||||
f.copyTo(target, overwrite = true)
|
||||
onPicked(PickedFile(target.absolutePath, f.name, guessMime(f.name), target.length()))
|
||||
} catch (e: Exception) {
|
||||
onPicked(null)
|
||||
}
|
||||
}) {
|
||||
Text("Choose image…")
|
||||
}
|
||||
}
|
||||
|
||||
private fun guessMime(name: String): String {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
return when (ext) {
|
||||
@@ -106,7 +129,8 @@ private fun guessMime(name: String): String {
|
||||
"mov" -> "video/quicktime"
|
||||
"mkv" -> "video/x-matroska"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"m4a", "aac" -> "audio/mp4"
|
||||
"m4a" -> "audio/mp4"
|
||||
"aac" -> "audio/aac"
|
||||
"ogg", "opus" -> "audio/ogg"
|
||||
"wav" -> "audio/wav"
|
||||
"flac" -> "audio/flac"
|
||||
@@ -122,14 +146,110 @@ actual fun mediaCacheBaseDir(): String {
|
||||
return dir.absolutePath
|
||||
}
|
||||
|
||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||
img.toComposeImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun loadScaledImage(
|
||||
path: String,
|
||||
maxSize: Int,
|
||||
): ImageBitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
||||
if (scale >= 1f) {
|
||||
img.toComposeImageBitmap()
|
||||
} else {
|
||||
val sw = (img.width * scale).toInt().coerceAtLeast(1)
|
||||
val sh = (img.height * scale).toInt().coerceAtLeast(1)
|
||||
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = scaled.createGraphics()
|
||||
g.drawImage(img, 0, 0, sw, sh, null)
|
||||
g.dispose()
|
||||
scaled.toComposeImageBitmap()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun decodeImageBytes(
|
||||
bytes: ByteArray,
|
||||
maxSize: Int,
|
||||
): ImageBitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val img = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext null
|
||||
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
||||
if (scale >= 1f) {
|
||||
img.toComposeImageBitmap()
|
||||
} else {
|
||||
val sw = (img.width * scale).toInt().coerceAtLeast(1)
|
||||
val sh = (img.height * scale).toInt().coerceAtLeast(1)
|
||||
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = scaled.createGraphics()
|
||||
g.drawImage(img, 0, 0, sw, sh, null)
|
||||
g.dispose()
|
||||
scaled.toComposeImageBitmap()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun readBackdropBytes(id: String): ByteArray? =
|
||||
try {
|
||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||
img.toComposeImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
// Top-level function: no `javaClass`, so anchor the classpath lookup to a
|
||||
// class in this module (PickedFile, same package).
|
||||
PickedFile::class.java.getResourceAsStream("/backdrops/$id.jpg")?.use { it.readBytes() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun encodeImageAsBase64(
|
||||
path: String,
|
||||
maxSize: Int,
|
||||
): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
||||
val sw = (img.width * scale).toInt().coerceAtLeast(1)
|
||||
val sh = (img.height * scale).toInt().coerceAtLeast(1)
|
||||
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = scaled.createGraphics()
|
||||
g.drawImage(img, 0, 0, sw, sh, null)
|
||||
g.dispose()
|
||||
val baos = ByteArrayOutputStream()
|
||||
ImageIO.write(scaled, "png", baos)
|
||||
java.util.Base64
|
||||
.getEncoder()
|
||||
.encodeToString(baos.toByteArray())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val bytes =
|
||||
java.util.Base64
|
||||
.getDecoder()
|
||||
.decode(base64)
|
||||
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
|
||||
img.toComposeImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* M6: inline media playback backed by mpv (docs/11 §11.2).
|
||||
@@ -142,7 +262,10 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Disp
|
||||
* - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
|
||||
*/
|
||||
@Composable
|
||||
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||
actual fun MediaPlayerView(
|
||||
media: MediaItem,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val path = media.localPath
|
||||
if (path == null || !File(path).exists()) {
|
||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
@@ -169,7 +292,12 @@ private object Mpv {
|
||||
val available: Boolean by lazy {
|
||||
try {
|
||||
val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start()
|
||||
val found = p.inputStream.bufferedReader().readText().trim().isNotBlank()
|
||||
val found =
|
||||
p.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
.isNotBlank()
|
||||
p.waitFor(2, TimeUnit.SECONDS)
|
||||
found
|
||||
} catch (_: Exception) {
|
||||
@@ -179,7 +307,10 @@ private object Mpv {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioPlayer(media: MediaItem, path: String) {
|
||||
private fun AudioPlayer(
|
||||
media: MediaItem,
|
||||
path: String,
|
||||
) {
|
||||
val session = remember(path) { MpvAudioSession(path) }
|
||||
var paused by remember { mutableStateOf(true) }
|
||||
var position by remember { mutableStateOf(0.0) }
|
||||
@@ -201,11 +332,12 @@ private fun AudioPlayer(media: MediaItem, path: String) {
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(media.filename, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
@@ -232,14 +364,18 @@ private fun AudioPlayer(media: MediaItem, path: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoPlayer(media: MediaItem, path: String) {
|
||||
private fun VideoPlayer(
|
||||
media: MediaItem,
|
||||
path: String,
|
||||
) {
|
||||
var process by remember { mutableStateOf<Process?>(null) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(media.filename, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
@@ -275,7 +411,9 @@ private fun fmtTime(sec: Double): String {
|
||||
* An mpv process + its JSON IPC socket (unix domain). Commands carry a
|
||||
* [requestId] so responses can be matched and async events skipped.
|
||||
*/
|
||||
private class MpvAudioSession(private val path: String) {
|
||||
private class MpvAudioSession(
|
||||
private val path: String,
|
||||
) {
|
||||
private var process: Process? = null
|
||||
private var channel: SocketChannel? = null
|
||||
private val socketPath =
|
||||
@@ -285,10 +423,15 @@ private class MpvAudioSession(private val path: String) {
|
||||
|
||||
fun start() {
|
||||
try {
|
||||
process = ProcessBuilder(
|
||||
"mpv", "--no-video", "--really-quiet", "--no-terminal",
|
||||
"--input-ipc-server=$socketPath", path,
|
||||
).redirectErrorStream(true).start()
|
||||
process =
|
||||
ProcessBuilder(
|
||||
"mpv",
|
||||
"--no-video",
|
||||
"--really-quiet",
|
||||
"--no-terminal",
|
||||
"--input-ipc-server=$socketPath",
|
||||
path,
|
||||
).redirectErrorStream(true).start()
|
||||
connectIpc()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
@@ -329,9 +472,24 @@ private class MpvAudioSession(private val path: String) {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
answers["p1"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { pos = it }
|
||||
answers["p2"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { dur = it }
|
||||
answers["p3"]?.get("data")?.jsonPrimitive?.content?.toBooleanStrictOrNull()?.let { pause = it }
|
||||
answers["p1"]
|
||||
?.get("data")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toDoubleOrNull()
|
||||
?.let { pos = it }
|
||||
answers["p2"]
|
||||
?.get("data")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toDoubleOrNull()
|
||||
?.let { dur = it }
|
||||
answers["p3"]
|
||||
?.get("data")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toBooleanStrictOrNull()
|
||||
?.let { pause = it }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
return Triple(pos, dur, pause)
|
||||
@@ -363,12 +521,16 @@ private class MpvAudioSession(private val path: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(cmd: List<String>, requestId: String?) {
|
||||
private fun send(
|
||||
cmd: List<String>,
|
||||
requestId: String?,
|
||||
) {
|
||||
val ch = channel ?: return
|
||||
val obj = buildJsonObject {
|
||||
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
|
||||
requestId?.let { put("request_id", JsonPrimitive(it)) }
|
||||
}
|
||||
val obj =
|
||||
buildJsonObject {
|
||||
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
|
||||
requestId?.let { put("request_id", JsonPrimitive(it)) }
|
||||
}
|
||||
val bytes = (obj.toString() + "\n").toByteArray(Charsets.UTF_8)
|
||||
ch.write(ByteBuffer.wrap(bytes))
|
||||
}
|
||||
@@ -394,15 +556,25 @@ private class MpvAudioSession(private val path: String) {
|
||||
}
|
||||
}
|
||||
|
||||
actual fun openDocument(path: String, mime: String) {
|
||||
actual fun openDocument(
|
||||
path: String,
|
||||
mime: String,
|
||||
) {
|
||||
try {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
when {
|
||||
os.contains("mac") -> ProcessBuilder("open", path).start()
|
||||
os.contains("win") ->
|
||||
os.contains("mac") -> {
|
||||
ProcessBuilder("open", path).start()
|
||||
}
|
||||
|
||||
os.contains("win") -> {
|
||||
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
|
||||
else -> ProcessBuilder("xdg-open", path).start()
|
||||
}
|
||||
|
||||
else -> {
|
||||
ProcessBuilder("xdg-open", path).start()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ actual fun setActiveController(controller: Any?) {
|
||||
DesktopBridge.controller = controller as? IrisController
|
||||
}
|
||||
|
||||
actual fun syncNtfyListener(backend: String) {
|
||||
// Desktop has no ntfy listener service.
|
||||
}
|
||||
|
||||
/**
|
||||
* M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the
|
||||
* platform notifier cover the "backgrounded" leg. Shells out to
|
||||
@@ -26,34 +30,57 @@ actual fun setActiveController(controller: Any?) {
|
||||
* Best effort — failures are ignored (the in-app banner is the primary path).
|
||||
*/
|
||||
object DesktopNotifier {
|
||||
fun post(title: String, body: String) {
|
||||
fun post(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
try {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
val cmd = when {
|
||||
os.contains("linux") ->
|
||||
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
|
||||
os.contains("mac") ->
|
||||
listOf(
|
||||
"osascript", "-e",
|
||||
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
|
||||
)
|
||||
else ->
|
||||
listOf(
|
||||
"powershell", "-NoProfile", "-Command",
|
||||
"Add-Type -AssemblyName System.Windows.Forms; " +
|
||||
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
|
||||
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
|
||||
"\$n.Visible = \$true; " +
|
||||
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
|
||||
"[System.Windows.Forms.ToolTipIcon]::Info); " +
|
||||
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
|
||||
)
|
||||
}
|
||||
val cmd =
|
||||
when {
|
||||
os.contains("linux") -> {
|
||||
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
|
||||
}
|
||||
|
||||
os.contains("mac") -> {
|
||||
listOf(
|
||||
"osascript",
|
||||
"-e",
|
||||
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
listOf(
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Add-Type -AssemblyName System.Windows.Forms; " +
|
||||
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
|
||||
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
|
||||
"\$n.Visible = \$true; " +
|
||||
"\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
|
||||
"[System.Windows.Forms.ToolTipIcon]::Info); " +
|
||||
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
|
||||
)
|
||||
}
|
||||
}
|
||||
ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun esc(s: String): String =
|
||||
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
||||
}
|
||||
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
||||
|
||||
// M-16: PowerShell uses backtick escaping (not backslash) and treats `$`
|
||||
// as a variable reference, so a body containing `$foo` would be mangled or
|
||||
// error. Escape backtick first (so the backticks we add aren't doubled),
|
||||
// then `$` and `"`. Newlines collapse to spaces (balloon tips are single
|
||||
// line).
|
||||
private fun psEsc(s: String): String =
|
||||
s
|
||||
.replace("`", "``")
|
||||
.replace("$", "`$")
|
||||
.replace("\"", "`\"")
|
||||
.replace("\n", " ")
|
||||
}
|
||||
@@ -2,13 +2,16 @@ package iris.platform
|
||||
|
||||
import iris.data.SecureStore
|
||||
import iris.protocol.IrisJson
|
||||
import iris.ui.theme.Backdrop
|
||||
import iris.ui.theme.BackgroundMode
|
||||
import iris.ui.theme.UserTheme
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* M6: desktop pairing storage (docs/11 §11.2).
|
||||
@@ -26,6 +29,12 @@ class DesktopSecureStore : SecureStore {
|
||||
private val legacyFile = File(baseDir, "pairing.json")
|
||||
private val secret = SecretBackend(baseDir)
|
||||
|
||||
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
|
||||
// connect attempt) don't re-read + re-parse the file on every access.
|
||||
// Invalidated on every save(). DesktopSecureStore is a per-process
|
||||
// singleton (created once in Main.kt), so a per-instance cache is safe.
|
||||
private var cached: Settings? = null
|
||||
|
||||
@Serializable
|
||||
private data class Settings(
|
||||
val serverUrl: String = "",
|
||||
@@ -34,6 +43,19 @@ class DesktopSecureStore : SecureStore {
|
||||
val fcmToken: String = "",
|
||||
val ntfyTopic: String = "",
|
||||
val ntfyServer: String = "",
|
||||
val pushBackend: String = "",
|
||||
val threadsEnabled: Boolean = false,
|
||||
val toolDetail: String = "truncated",
|
||||
val streamingEnabled: Boolean = true,
|
||||
val reasoningAutoCollapse: Boolean = true,
|
||||
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
||||
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
||||
val backgroundMode: String = BackgroundMode.Image.name.lowercase(),
|
||||
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
|
||||
val backgroundImagePath: String = Backdrop.DEFAULT.path,
|
||||
val fontSizeScale: Float = 1.0f,
|
||||
val runtimeFooterEnabled: Boolean = false,
|
||||
val runtimeFooterFields: String = "",
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -42,11 +64,12 @@ class DesktopSecureStore : SecureStore {
|
||||
|
||||
private fun migrateLegacy() {
|
||||
if (!legacyFile.exists()) return
|
||||
val legacy = try {
|
||||
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val legacy =
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (legacy != null) {
|
||||
val s = load()
|
||||
save(
|
||||
@@ -60,24 +83,32 @@ class DesktopSecureStore : SecureStore {
|
||||
),
|
||||
)
|
||||
if (legacy.token.isNotBlank()) secret.write(legacy.token)
|
||||
// L-49: only delete the legacy file after a successful migration;
|
||||
// a parse failure (legacy == null) must not destroy the data.
|
||||
legacyFile.delete()
|
||||
}
|
||||
legacyFile.delete()
|
||||
}
|
||||
|
||||
private fun load(): Settings =
|
||||
if (settingsFile.exists()) {
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
||||
} catch (_: Exception) {
|
||||
private fun load(): Settings {
|
||||
cached?.let { return it }
|
||||
val s =
|
||||
if (settingsFile.exists()) {
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
||||
} catch (_: Exception) {
|
||||
Settings()
|
||||
}
|
||||
} else {
|
||||
Settings()
|
||||
}
|
||||
} else {
|
||||
Settings()
|
||||
}
|
||||
cached = s
|
||||
return s
|
||||
}
|
||||
|
||||
private fun save(data: Settings) {
|
||||
baseDir.mkdirs()
|
||||
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
|
||||
cached = data
|
||||
}
|
||||
|
||||
override var serverUrl: String
|
||||
@@ -97,7 +128,13 @@ class DesktopSecureStore : SecureStore {
|
||||
get() {
|
||||
val d = load()
|
||||
if (d.deviceId.isNotBlank()) return d.deviceId
|
||||
val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
|
||||
val id =
|
||||
"dev_" +
|
||||
UUID
|
||||
.randomUUID()
|
||||
.toString()
|
||||
.replace("-", "")
|
||||
.take(16)
|
||||
save(d.copy(deviceId = id))
|
||||
return id
|
||||
}
|
||||
@@ -133,15 +170,123 @@ class DesktopSecureStore : SecureStore {
|
||||
save(d.copy(ntfyServer = value))
|
||||
}
|
||||
|
||||
override fun savePairing(url: String, token: String) {
|
||||
override var pushBackend: String
|
||||
get() = load().pushBackend
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(pushBackend = value))
|
||||
}
|
||||
|
||||
override var threadsEnabled: Boolean
|
||||
get() = load().threadsEnabled
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(threadsEnabled = value))
|
||||
}
|
||||
|
||||
override var toolDetail: String
|
||||
get() = load().toolDetail.ifBlank { "truncated" }
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(toolDetail = value))
|
||||
}
|
||||
|
||||
override var streamingEnabled: Boolean
|
||||
get() = load().streamingEnabled
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(streamingEnabled = value))
|
||||
}
|
||||
|
||||
override var reasoningAutoCollapse: Boolean
|
||||
get() = load().reasoningAutoCollapse
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(reasoningAutoCollapse = value))
|
||||
}
|
||||
|
||||
override var userBubbleColor: Int
|
||||
get() = load().userBubbleColor
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(userBubbleColor = value))
|
||||
}
|
||||
|
||||
override var agentBubbleColor: Int
|
||||
get() = load().agentBubbleColor
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(agentBubbleColor = value))
|
||||
}
|
||||
|
||||
override var backgroundMode: String
|
||||
get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(backgroundMode = value))
|
||||
}
|
||||
|
||||
override var backgroundColor: Int
|
||||
get() = load().backgroundColor
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(backgroundColor = value))
|
||||
}
|
||||
|
||||
override var backgroundImagePath: String
|
||||
get() = load().backgroundImagePath
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(backgroundImagePath = value))
|
||||
}
|
||||
|
||||
override var fontSizeScale: Float
|
||||
get() = load().fontSizeScale
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(fontSizeScale = value))
|
||||
}
|
||||
|
||||
override var runtimeFooterEnabled: Boolean
|
||||
get() = load().runtimeFooterEnabled
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(runtimeFooterEnabled = value))
|
||||
}
|
||||
|
||||
override var runtimeFooterFields: String
|
||||
get() = load().runtimeFooterFields
|
||||
set(value) {
|
||||
val d = load()
|
||||
save(d.copy(runtimeFooterFields = value))
|
||||
}
|
||||
|
||||
override fun savePairing(
|
||||
url: String,
|
||||
token: String,
|
||||
) {
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = url.trim()))
|
||||
if (token.isBlank()) secret.clear() else secret.write(token.trim())
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
// M-10: clearing pairing must also wipe the device identity + push
|
||||
// state, otherwise a re-pair to a different gateway would keep the old
|
||||
// deviceId/syncCursor/ntfyTopic and the server would treat the new
|
||||
// pairing as the same device.
|
||||
val d = load()
|
||||
save(d.copy(serverUrl = ""))
|
||||
save(
|
||||
d.copy(
|
||||
serverUrl = "",
|
||||
deviceId = "",
|
||||
syncCursor = 0L,
|
||||
fcmToken = "",
|
||||
ntfyTopic = "",
|
||||
ntfyServer = "",
|
||||
pushBackend = "",
|
||||
),
|
||||
)
|
||||
secret.clear()
|
||||
}
|
||||
}
|
||||
@@ -162,7 +307,9 @@ private data class PairingData(
|
||||
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
|
||||
* All backend failures degrade to the encrypted file (never plaintext).
|
||||
*/
|
||||
private class SecretBackend(private val baseDir: File) {
|
||||
private class SecretBackend(
|
||||
private val baseDir: File,
|
||||
) {
|
||||
private val encFile = File(baseDir, "pairing.enc")
|
||||
private val keyFile = File(baseDir, ".key")
|
||||
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
|
||||
@@ -172,7 +319,12 @@ private class SecretBackend(private val baseDir: File) {
|
||||
fun write(value: String) {
|
||||
if (keyring != null) {
|
||||
keyring.write(value)
|
||||
if (keyring.read() == value) return
|
||||
if (keyring.read() == value) {
|
||||
// L-50: the keyring now holds the secret; drop the stale
|
||||
// encrypted file so the old token can't be read back.
|
||||
encFile.delete()
|
||||
return
|
||||
}
|
||||
// Keyring accepted the write but did not persist it (e.g. KWallet
|
||||
// without a live daemon). Drop the stale entry and fall back to
|
||||
// the encrypted file so the token survives a restart.
|
||||
@@ -203,8 +355,12 @@ private class SecretBackend(private val baseDir: File) {
|
||||
|
||||
private fun writeEncrypted(value: String) {
|
||||
try {
|
||||
// GCM with a fresh 12-byte SecureRandom IV per write (stored with
|
||||
// the ciphertext); the IV is never reused for a given key.
|
||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
||||
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||
baseDir.mkdirs()
|
||||
@@ -220,7 +376,9 @@ private class SecretBackend(private val baseDir: File) {
|
||||
if (bytes.size < 28) return null
|
||||
val iv = bytes.copyOfRange(0, 12)
|
||||
val ct = bytes.copyOfRange(12, bytes.size)
|
||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
||||
String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||
} catch (_: Exception) {
|
||||
@@ -237,33 +395,50 @@ private class KeyringBackend {
|
||||
|
||||
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
|
||||
val available: Boolean by lazy {
|
||||
if (isMac) true
|
||||
else if (isLinux) which("secret-tool") != null
|
||||
else false
|
||||
if (isMac) {
|
||||
true
|
||||
} else if (isLinux) {
|
||||
which("secret-tool") != null
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun read(): String? = try {
|
||||
if (isMac) {
|
||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
||||
} else {
|
||||
out(listOf("secret-tool", "lookup", "app", "iris"))
|
||||
fun read(): String? =
|
||||
try {
|
||||
if (isMac) {
|
||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
||||
} else {
|
||||
out(listOf("secret-tool", "lookup", "app", "iris"))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun write(value: String) {
|
||||
try {
|
||||
if (isMac) {
|
||||
ProcessBuilder(
|
||||
"security", "add-generic-password", "-U",
|
||||
"-a", "iris", "-s", "iris-gateway-token", "-w", value,
|
||||
).inheritIO().start().waitFor()
|
||||
} else {
|
||||
ProcessBuilder(
|
||||
"secret-tool", "store", "--label=Iris gateway token",
|
||||
"app", "iris", "token", value,
|
||||
).inheritIO().start().waitFor()
|
||||
// M-9: pass the secret on stdin, not as a CLI argument. A trailing
|
||||
// argument is visible in the process list (`ps`); `secret-tool
|
||||
// store` and `security add-generic-password -w` both read the
|
||||
// secret from stdin when no value argument is given.
|
||||
val cmd =
|
||||
if (isMac) {
|
||||
listOf(
|
||||
"security",
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-a",
|
||||
"iris",
|
||||
"-s",
|
||||
"iris-gateway-token",
|
||||
"-w",
|
||||
)
|
||||
} else {
|
||||
listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris")
|
||||
}
|
||||
ProcessBuilder(cmd).start().apply {
|
||||
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
|
||||
waitFor()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
@@ -273,12 +448,19 @@ private class KeyringBackend {
|
||||
try {
|
||||
if (isMac) {
|
||||
ProcessBuilder(
|
||||
"security", "delete-generic-password",
|
||||
"-a", "iris", "-s", "iris-gateway-token",
|
||||
"security",
|
||||
"delete-generic-password",
|
||||
"-a",
|
||||
"iris",
|
||||
"-s",
|
||||
"iris-gateway-token",
|
||||
).inheritIO().start().waitFor()
|
||||
} else {
|
||||
ProcessBuilder(
|
||||
"secret-tool", "clear", "app", "iris",
|
||||
"secret-tool",
|
||||
"clear",
|
||||
"app",
|
||||
"iris",
|
||||
).inheritIO().start().waitFor()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
@@ -287,17 +469,26 @@ private class KeyringBackend {
|
||||
|
||||
private fun out(cmd: List<String>): String? {
|
||||
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||
val text = p.inputStream.bufferedReader().readText().trim()
|
||||
val text =
|
||||
p.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
p.waitFor()
|
||||
return text.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun which(bin: String): String? = try {
|
||||
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
|
||||
val text = p.inputStream.bufferedReader().readText().trim()
|
||||
p.waitFor()
|
||||
text.ifBlank { null }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
private fun which(bin: String): String? =
|
||||
try {
|
||||
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
|
||||
val text =
|
||||
p.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
p.waitFor()
|
||||
text.ifBlank { null }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package iris.platform
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import iris.db.IrisDatabase
|
||||
import java.io.File
|
||||
import java.sql.DriverManager
|
||||
import java.util.Properties
|
||||
|
||||
actual fun appDataDir(): String = File(System.getProperty("user.home"), ".iris").apply { mkdirs() }.absolutePath
|
||||
|
||||
actual fun createCacheDriver(): SqlDriver = createCacheDriver(File(appDataDir(), "iris_cache.db"))
|
||||
|
||||
/** Schema-aware driver for the cache DB at [file]: creates the schema on
|
||||
* first run, applies pending .sqm migrations on existing DBs (e.g. v1 ->
|
||||
* v2: the `tool` table), and persists the schema version (PRAGMA
|
||||
* user_version). */
|
||||
internal fun createCacheDriver(file: File): SqlDriver {
|
||||
stampLegacyV1(file)
|
||||
return JdbcSqliteDriver("jdbc:sqlite:${file.absolutePath}", Properties(), IrisDatabase.Schema)
|
||||
}
|
||||
|
||||
/** One-time shim for pre-existing desktop cache DBs: the old code called
|
||||
* `Schema.create()` directly, which never stamped PRAGMA user_version — so
|
||||
* those files hold the v1 schema at user_version 0, which the schema-aware
|
||||
* driver would treat as a fresh DB and crash on (`CREATE TABLE message` on
|
||||
* an existing table). Stamp them as v1 so the 1 -> 2 migration runs. */
|
||||
internal fun stampLegacyV1(file: File) {
|
||||
if (!file.exists()) return
|
||||
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { conn ->
|
||||
val userVersion =
|
||||
conn.createStatement().use { st ->
|
||||
st.executeQuery("PRAGMA user_version").use { rs -> if (rs.next()) rs.getInt(1) else 0 }
|
||||
}
|
||||
if (userVersion != 0) return
|
||||
val hasMessageTable =
|
||||
conn.createStatement().use { st ->
|
||||
st
|
||||
.executeQuery("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'message'")
|
||||
.use { rs -> rs.next() }
|
||||
}
|
||||
if (hasMessageTable) conn.createStatement().use { st -> st.execute("PRAGMA user_version = 1") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package iris.platform
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.multiplatform.webview.web.WebView
|
||||
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
||||
import dev.datlag.kcef.KCEF
|
||||
import iris.ui.theme.IrisColors
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* M9: HTML artifact preview on desktop — JCEF (Chromium) via KCEF.
|
||||
*
|
||||
* KCEF is initialized once per process, lazily on the first artifact
|
||||
* preview. The JCEF/Chromium binary is downloaded from the pinned
|
||||
* JetBrainsRuntime release on first use and cached in ~/.iris/kcef-bundle,
|
||||
* so later starts (and the packaged app) reuse it.
|
||||
*/
|
||||
private val kcefReady = MutableStateFlow(false)
|
||||
private val kcefProgress = MutableStateFlow(0f)
|
||||
private val kcefError = MutableStateFlow<String?>(null)
|
||||
|
||||
private suspend fun ensureKcefInitialized() {
|
||||
if (kcefReady.value) return
|
||||
withContext(Dispatchers.IO) {
|
||||
KCEF.init(
|
||||
builder = {
|
||||
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
|
||||
// Pinned to the JBR 17 release the KCEF 2025.03.23 line is
|
||||
// built against; the default "latest" JBR may be JDK 21-based
|
||||
// and is not guaranteed compatible.
|
||||
download {
|
||||
github { release("jbr-release-17.0.14b1367.22") }
|
||||
}
|
||||
settings {
|
||||
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
|
||||
}
|
||||
progress {
|
||||
onDownloading { kcefProgress.value = it }
|
||||
onInitialized { kcefReady.value = true }
|
||||
}
|
||||
},
|
||||
onError = { kcefError.value = it?.message ?: "Preview engine failed to start" },
|
||||
onRestartRequired = { kcefError.value = "Restart the app to finish setting up previews" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun PlatformWebView(html: String, modifier: Modifier) {
|
||||
val ready by kcefReady.collectAsState()
|
||||
val progress by kcefProgress.collectAsState()
|
||||
val error by kcefError.collectAsState()
|
||||
LaunchedEffect(Unit) { ensureKcefInitialized() }
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when {
|
||||
error != null -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(error!!, color = IrisColors.errorText)
|
||||
}
|
||||
}
|
||||
!ready -> {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(progress = { progress })
|
||||
Text(
|
||||
text = "Preparing preview… ${progress.toInt()}%",
|
||||
color = IrisColors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val state = rememberWebViewStateWithHTMLData(data = html)
|
||||
WebView(state, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier = this
|
||||