Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0951ebbbce | ||
|
|
a8c4418aff | ||
|
|
8100730cba | ||
|
|
bff363d579 | ||
|
|
5b9427b10d | ||
|
|
f5e39c0752 | ||
|
|
b681e547e7 | ||
|
|
0d99ee5cf5 | ||
|
|
8b40eb6e4c | ||
|
|
ccc40213ca | ||
|
|
c668b209b2 | ||
|
|
de3b5fd956 | ||
|
|
6f4bf3cb7f | ||
|
|
60ec2b44a7 | ||
|
|
1d67de5f12 |
No files matched your search
@@ -1,79 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
gateway:
|
|
||||||
name: Gateway plugin tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
|
|
||||||
# The gateway tests run inside the hermes-agent test harness, which is
|
|
||||||
# git-ignored in this repo (read-only research reference). CI clones the
|
|
||||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
|
||||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
|
||||||
- name: Clone hermes-agent (pinned)
|
|
||||||
run: |
|
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
|
||||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
|
|
||||||
- name: Sync venv
|
|
||||||
run: |
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
cd hermes-agent
|
|
||||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
|
||||||
# venv without it and run_tests.sh refuses to run.
|
|
||||||
uv sync --extra dev
|
|
||||||
|
|
||||||
- name: Run android gateway tests
|
|
||||||
run: |
|
|
||||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
|
||||||
cd hermes-agent
|
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
|
||||||
|
|
||||||
kotlin:
|
|
||||||
name: Kotlin tests (android host + desktop)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
|
||||||
# strip it so CI uses the JDK installed by setup-java.
|
|
||||||
- name: Strip local JDK pin
|
|
||||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
|
||||||
run: |
|
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
|
||||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
|
||||||
curl -fsSL -o /tmp/ct.zip \
|
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
|
||||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
|
||||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
|
||||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
|
||||||
# exits before `yes` is done writing.
|
|
||||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
|
||||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
|
||||||
|
|
||||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
|
||||||
# missing SDK platforms (licenses accepted above).
|
|
||||||
- name: Run host tests
|
|
||||||
working-directory: app
|
|
||||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
# The release version comes from the repo-root VERSION file (the single
|
|
||||||
# source of truth) — bump it in a commit, then dispatch this workflow.
|
|
||||||
changelog:
|
|
||||||
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
gateway:
|
|
||||||
name: Gateway plugin tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
|
|
||||||
- name: Clone hermes-agent (pinned)
|
|
||||||
run: |
|
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
|
||||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
|
|
||||||
- name: Sync venv
|
|
||||||
run: |
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
cd hermes-agent
|
|
||||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
|
||||||
# venv without it and run_tests.sh refuses to run.
|
|
||||||
uv sync --extra dev
|
|
||||||
|
|
||||||
- name: Run android gateway tests
|
|
||||||
run: |
|
|
||||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
|
||||||
cd hermes-agent
|
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
|
||||||
|
|
||||||
kotlin:
|
|
||||||
name: Kotlin tests (android host + desktop)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- name: Strip local JDK pin
|
|
||||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
|
||||||
run: |
|
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
|
||||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
|
||||||
curl -fsSL -o /tmp/ct.zip \
|
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
|
||||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
|
||||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
|
||||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
|
||||||
# exits before `yes` is done writing.
|
|
||||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
|
||||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
|
||||||
|
|
||||||
- name: Run host tests
|
|
||||||
working-directory: app
|
|
||||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
|
||||||
|
|
||||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
|
||||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
|
||||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
|
||||||
release:
|
|
||||||
name: Build + create Gitea release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [gateway, kotlin]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- name: Strip local JDK pin
|
|
||||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
|
||||||
run: |
|
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
|
||||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
|
||||||
curl -fsSL -o /tmp/ct.zip \
|
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
|
||||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
|
||||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
|
||||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
|
||||||
# exits before `yes` is done writing.
|
|
||||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
|
||||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
|
||||||
|
|
||||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
|
||||||
# does not ship.
|
|
||||||
- name: Install fakeroot (for jpackage --type deb)
|
|
||||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
|
||||||
|
|
||||||
- name: Restore release keystore (from Gitea secrets)
|
|
||||||
env:
|
|
||||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
if [ -n "$KS_B64" ]; then
|
|
||||||
echo "$KS_B64" | base64 -d > app/release.keystore
|
|
||||||
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
|
|
||||||
echo "Building SIGNED release APK"
|
|
||||||
else
|
|
||||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build APK + AAB
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
|
||||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
|
||||||
cd app
|
|
||||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
|
||||||
# APK for direct sideloading, AAB for Play Store uploads.
|
|
||||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
|
||||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
|
||||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
|
||||||
else
|
|
||||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
|
||||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
|
||||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
|
||||||
# When a Windows / macOS runner exists later, add a second build job
|
|
||||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
|
||||||
- name: Build desktop app-image + deb
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
|
||||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
|
||||||
cd app
|
|
||||||
# Self-contained app image (JRE bundled via jlink).
|
|
||||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
|
||||||
(cd desktopApp/build/jpackage && zip -qr \
|
|
||||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
|
||||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
|
||||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
|
||||||
cp desktopApp/build/jpackage/*.deb \
|
|
||||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
|
||||||
|
|
||||||
- name: Create release + upload artifacts
|
|
||||||
env:
|
|
||||||
# Optional: create a personal access token (scope: Releases: write)
|
|
||||||
# and store it as secret GITEA_TOKEN. Without it the workflow uses
|
|
||||||
# the automatic GITHUB_TOKEN that Gitea Actions provides.
|
|
||||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
|
||||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
|
||||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
|
||||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
|
||||||
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
|
||||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
|
||||||
TAG="v$VERSION"
|
|
||||||
API="$SERVER/api/v1/repos/$REPO"
|
|
||||||
AUTH="Authorization: token $TOKEN"
|
|
||||||
|
|
||||||
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
|
|
||||||
# message) before failing — plain `curl -f` hides it (exit 22).
|
|
||||||
api() {
|
|
||||||
local code body
|
|
||||||
body=$(mktemp)
|
|
||||||
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
|
||||||
if [ "${code:0:1}" != "2" ]; then
|
|
||||||
echo "API error $code: $(cat "$body")" >&2
|
|
||||||
rm -f "$body"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
cat "$body"
|
|
||||||
rm -f "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Re-run safety: drop a previous release AND its tag for this version.
|
|
||||||
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
|
||||||
# makes the POST below fail with 409.)
|
|
||||||
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
|
||||||
if [ -n "$OLD_ID" ]; then
|
|
||||||
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
|
||||||
fi
|
|
||||||
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
|
|
||||||
|
|
||||||
# Gitea creates the tag at the default branch HEAD automatically.
|
|
||||||
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
"$API/releases" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
|
||||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
|
||||||
| jq -r .id)
|
|
||||||
echo "Created release $TAG (id $RELEASE_ID)"
|
|
||||||
|
|
||||||
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
echo "Uploading $(basename "$f")"
|
|
||||||
# Forgejo-style API: release assets live under /assets, not /attachments.
|
|
||||||
api -X POST -H "$AUTH" -F "attachment=@$f" \
|
|
||||||
"$API/releases/$RELEASE_ID/assets" > /dev/null
|
|
||||||
done
|
|
||||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Gitleaks allowlist for iris_x_hermes.
|
|
||||||
#
|
|
||||||
# pi-lens runs `gitleaks detect --no-git` over the working tree, which includes
|
|
||||||
# git-ignored paths. The hits below are all false positives:
|
|
||||||
# - hermes-agent/ read-only research reference (never committed)
|
|
||||||
# - **/build/** Gradle / jpackage build artifacts (regenerated)
|
|
||||||
# - google-services.json standard Firebase config; its "API key" is a web
|
|
||||||
# client key restricted by package name + SHA-1, and
|
|
||||||
# the file is git-ignored (optional; FCM is inert
|
|
||||||
# without it).
|
|
||||||
title = "iris_x_hermes gitleaks allowlist"
|
|
||||||
|
|
||||||
[allowlist]
|
|
||||||
description = "Git-ignored reference tree, build artifacts, and Firebase config"
|
|
||||||
paths = [
|
|
||||||
'''^hermes-agent/''',
|
|
||||||
'''.*/build/''',
|
|
||||||
'''^app/androidApp/google-services\.json$''',
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"ignore": [
|
|
||||||
"tests/test_android.py"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"unchecked-throwing-call-python": {
|
|
||||||
"disable": [
|
|
||||||
"unchecked-throwing-call-python",
|
|
||||||
"ast-grep:unchecked-throwing-call-python"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"python-logger-credential-disclosure": {
|
|
||||||
"disable": [
|
|
||||||
"opengrep:python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sqlalchemy-execute-raw-query": {
|
|
||||||
"disable": [
|
|
||||||
"opengrep:python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"exported-activity": {
|
|
||||||
"disable": [
|
|
||||||
"opengrep:java.android.security.exported_activity.exported_activity"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
|
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
|
||||||
- **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
|
- The plugin is installed by symlink: `~/.hermes/plugins/android` → `<repo>/gateway-plugin` (already set up on this machine).
|
||||||
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -15,27 +14,26 @@
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
|
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
|
||||||
- Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
|
- Gateway: `hermes gateway setup` (one-time; generates `ANDROID_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
|
||||||
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
|
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
|
||||||
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
|
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
|
||||||
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
|
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
|
||||||
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
|
- Kotlin tests: `cd app && ./gradlew :shared:testDebugUnitTest` / `:shared:desktopTest` (no test sources exist yet).
|
||||||
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python tests/ws_probe.py --token <IRIS_TOKEN> --send "hello"` — assertion flags documented in `tests/README.md`.
|
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`.
|
||||||
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python tests/e2e.py`.
|
- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`.
|
||||||
|
|
||||||
## Environment / pairing quirks
|
## Environment / pairing quirks
|
||||||
|
|
||||||
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
|
- Pairing token: `ANDROID_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry.
|
||||||
- HTTP default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
- WS default bind is `127.0.0.1`; for a phone on the LAN set `ANDROID_WS_HOST` to the gateway's LAN IP.
|
||||||
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
|
||||||
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
|
||||||
- **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
|
- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
|
||||||
- Desktop jpackage on Linux/JDK 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
|
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
|
||||||
|
|
||||||
## Testing quirks
|
## Testing quirks
|
||||||
|
|
||||||
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
|
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `ANDROID_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
|
||||||
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
|
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
|
||||||
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
|
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
|
||||||
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
|
|
||||||
- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
|
- Gateway logs: `~/.hermes/logs/gateway.log` or `hermes logs --follow`.
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
# CI / Release setup — manual edit list
|
|
||||||
|
|
||||||
Everything needed for the Gitea workflows (CI + manual release). Items marked
|
|
||||||
**DONE** were already applied; the rest are copy-paste instructions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. DONE — no action needed
|
|
||||||
|
|
||||||
- `tests/test_android.py` — vendored byte-identical mirror of
|
|
||||||
`hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout
|
|
||||||
is the canonical copy; **keep the two in sync** when you change that test).
|
|
||||||
- `.pi-lens.json` — added `"ignore": ["tests/test_android.py"]`
|
|
||||||
so the scanner doesn't flag the vendored mirror.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. NEW FILE: `.gitea/workflows/ci.yml`
|
|
||||||
|
|
||||||
Runs on every push to `master` and on PRs: gateway plugin tests + Kotlin host
|
|
||||||
tests (android + desktop).
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
gateway:
|
|
||||||
name: Gateway plugin tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
|
|
||||||
# The gateway tests run inside the hermes-agent test harness, which is
|
|
||||||
# git-ignored in this repo (read-only research reference). CI clones the
|
|
||||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
|
||||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
|
||||||
- name: Clone hermes-agent (pinned)
|
|
||||||
run: |
|
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
|
||||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
|
|
||||||
- name: Sync venv
|
|
||||||
run: |
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
cd hermes-agent
|
|
||||||
uv sync
|
|
||||||
|
|
||||||
- name: Run android gateway tests
|
|
||||||
run: |
|
|
||||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
|
||||||
cd hermes-agent
|
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
|
||||||
|
|
||||||
kotlin:
|
|
||||||
name: Kotlin tests (android host + desktop)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
|
||||||
# strip it so CI uses the JDK installed by setup-java.
|
|
||||||
- name: Strip local JDK pin
|
|
||||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
|
||||||
run: |
|
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
|
||||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
|
||||||
curl -fsSL -o /tmp/ct.zip \
|
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
|
||||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
|
||||||
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses > /dev/null
|
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
|
||||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
|
||||||
|
|
||||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
|
||||||
# missing SDK platforms (licenses accepted above).
|
|
||||||
- name: Run host tests
|
|
||||||
working-directory: app
|
|
||||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. NEW FILE: `.gitea/workflows/release.yml`
|
|
||||||
|
|
||||||
The release version is the repo-root **`VERSION` file** (single source of
|
|
||||||
truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
|
|
||||||
commit). Manual trigger: **repo → Actions → Release → Run workflow**,
|
|
||||||
optionally with a `changelog`. It runs the same tests as CI, builds a signed
|
|
||||||
Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled),
|
|
||||||
then creates the Gitea release `v<VERSION>` with all artifacts as download
|
|
||||||
attachments.
|
|
||||||
|
|
||||||
Note: builds + release creation happen in ONE job because Gitea/act_runner
|
|
||||||
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
|
|
||||||
with `GHESNotSupportedError`).
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
# The release version comes from the repo-root VERSION file (the single
|
|
||||||
# source of truth) — bump it in a commit, then dispatch this workflow.
|
|
||||||
changelog:
|
|
||||||
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
gateway:
|
|
||||||
name: Gateway plugin tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
|
|
||||||
- name: Clone hermes-agent (pinned)
|
|
||||||
run: |
|
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
|
||||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
|
||||||
|
|
||||||
- name: Sync venv
|
|
||||||
run: |
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
cd hermes-agent
|
|
||||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
|
||||||
# venv without it and run_tests.sh refuses to run.
|
|
||||||
uv sync --extra dev
|
|
||||||
|
|
||||||
- name: Run android gateway tests
|
|
||||||
run: |
|
|
||||||
cp tests/test_android.py hermes-agent/tests/gateway/test_android.py
|
|
||||||
cd hermes-agent
|
|
||||||
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
|
|
||||||
scripts/run_tests.sh tests/gateway/test_android.py
|
|
||||||
|
|
||||||
kotlin:
|
|
||||||
name: Kotlin tests (android host + desktop)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- name: Strip local JDK pin
|
|
||||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
|
||||||
run: |
|
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
|
||||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
|
||||||
curl -fsSL -o /tmp/ct.zip \
|
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
|
||||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
|
||||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
|
||||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
|
||||||
# exits before `yes` is done writing.
|
|
||||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
|
||||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
|
||||||
|
|
||||||
- name: Run host tests
|
|
||||||
working-directory: app
|
|
||||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
|
||||||
|
|
||||||
# Gitea/act_runner does not implement the GitHub artifacts API
|
|
||||||
# (upload-artifact@v4+ fails with GHESNotSupportedError), so the builds and
|
|
||||||
# the release creation happen in ONE job — no artifact handoff between jobs.
|
|
||||||
release:
|
|
||||||
name: Build + create Gitea release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [gateway, kotlin]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "21"
|
|
||||||
|
|
||||||
- name: Strip local JDK pin
|
|
||||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
|
||||||
|
|
||||||
- name: Install Android SDK
|
|
||||||
run: |
|
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
|
||||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
|
||||||
curl -fsSL -o /tmp/ct.zip \
|
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
|
||||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
|
||||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
|
||||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
|
||||||
# exits before `yes` is done writing.
|
|
||||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
|
||||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
|
||||||
|
|
||||||
# jpackage --type deb shells out to fakeroot, which the runner image
|
|
||||||
# does not ship.
|
|
||||||
- name: Install fakeroot (for jpackage --type deb)
|
|
||||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq fakeroot
|
|
||||||
|
|
||||||
- name: Restore release keystore (from Gitea secrets)
|
|
||||||
env:
|
|
||||||
KS_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
|
||||||
run: |
|
|
||||||
if [ -n "$KS_B64" ]; then
|
|
||||||
echo "$KS_B64" | base64 -d > app/release.keystore
|
|
||||||
echo "ANDROID_KEYSTORE_FILE=$GITHUB_WORKSPACE/app/release.keystore" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
|
|
||||||
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
|
|
||||||
echo "Building SIGNED release APK"
|
|
||||||
else
|
|
||||||
echo "::warning::ANDROID_KEYSTORE_BASE64 secret not set — falling back to a DEBUG apk (see CI-SETUP.md §5)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build APK + AAB
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
|
||||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
|
||||||
cd app
|
|
||||||
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
|
|
||||||
# APK for direct sideloading, AAB for Play Store uploads.
|
|
||||||
./gradlew :androidApp:assembleRelease :androidApp:bundleRelease -PappVersion="$VERSION"
|
|
||||||
cp androidApp/build/outputs/apk/release/androidApp-release.apk \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.apk"
|
|
||||||
cp androidApp/build/outputs/bundle/release/androidApp-release.aab \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION.aab"
|
|
||||||
else
|
|
||||||
./gradlew :androidApp:assembleDebug :androidApp:bundleDebug -PappVersion="$VERSION"
|
|
||||||
cp androidApp/build/outputs/apk/debug/androidApp-debug.apk \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.apk"
|
|
||||||
cp androidApp/build/outputs/bundle/debug/androidApp-debug.aab \
|
|
||||||
"$GITHUB_WORKSPACE/iris-android-v$VERSION-debug.aab"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# jpackage cannot cross-compile: this only produces Linux packages.
|
|
||||||
# When a Windows / macOS runner exists later, add a second build job
|
|
||||||
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
|
|
||||||
- name: Build desktop app-image + deb
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
|
||||||
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
|
|
||||||
cd app
|
|
||||||
# Self-contained app image (JRE bundled via jlink).
|
|
||||||
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
|
|
||||||
(cd desktopApp/build/jpackage && zip -qr \
|
|
||||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.zip" iris)
|
|
||||||
# .deb package (dpkg-deb ships with Ubuntu; fakeroot installed above).
|
|
||||||
./gradlew :desktopApp:jpackage -PjpackageType=deb -PappVersion="$VERSION"
|
|
||||||
cp desktopApp/build/jpackage/*.deb \
|
|
||||||
"$GITHUB_WORKSPACE/iris-desktop-linux-x64-v$VERSION.deb"
|
|
||||||
|
|
||||||
- name: Create release + upload artifacts
|
|
||||||
env:
|
|
||||||
# Optional: create a personal access token (scope: Releases: write)
|
|
||||||
# and store it as secret GITEA_TOKEN. Without it the workflow uses
|
|
||||||
# the automatic GITHUB_TOKEN that Gitea Actions provides.
|
|
||||||
RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
|
|
||||||
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
|
|
||||||
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
|
|
||||||
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
|
|
||||||
# The dispatch input is a single-line field; turn literal \n into real newlines.
|
|
||||||
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
|
|
||||||
TAG="v$VERSION"
|
|
||||||
API="$SERVER/api/v1/repos/$REPO"
|
|
||||||
AUTH="Authorization: token $TOKEN"
|
|
||||||
|
|
||||||
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
|
|
||||||
# message) before failing — plain `curl -f` hides it (exit 22).
|
|
||||||
api() {
|
|
||||||
local code body
|
|
||||||
body=$(mktemp)
|
|
||||||
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
|
||||||
if [ "${code:0:1}" != "2" ]; then
|
|
||||||
echo "API error $code: $(cat "$body")" >&2
|
|
||||||
rm -f "$body"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
cat "$body"
|
|
||||||
rm -f "$body"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Re-run safety: drop a previous release AND its tag for this version.
|
|
||||||
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
|
||||||
# makes the POST below fail with 409.)
|
|
||||||
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
|
||||||
if [ -n "$OLD_ID" ]; then
|
|
||||||
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
|
||||||
fi
|
|
||||||
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
|
|
||||||
|
|
||||||
# Gitea creates the tag at the default branch HEAD automatically.
|
|
||||||
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
|
||||||
"$API/releases" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
|
|
||||||
'{tag_name:$tag, title:$title, body:$body}')" \
|
|
||||||
| jq -r .id)
|
|
||||||
echo "Created release $TAG (id $RELEASE_ID)"
|
|
||||||
|
|
||||||
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
echo "Uploading $(basename "$f")"
|
|
||||||
# Forgejo-style API: release assets live under /assets, not /attachments.
|
|
||||||
api -X POST -H "$AUTH" -F "attachment=@$f" \
|
|
||||||
"$API/releases/$RELEASE_ID/assets" > /dev/null
|
|
||||||
done
|
|
||||||
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. EDITS to existing Gradle files
|
|
||||||
|
|
||||||
> **Note (versioning):** the `versionName` / `appVersion` lines shown below
|
|
||||||
> have since been changed to read the repo-root **`VERSION` file** (single
|
|
||||||
> source of truth; `-PappVersion` still overrides in CI). See
|
|
||||||
> `.gitea/workflows/release.yml` and `gateway-plugin/version.py`.
|
|
||||||
|
|
||||||
### 4a. `app/androidApp/build.gradle.kts`
|
|
||||||
|
|
||||||
**Change 1** — in `defaultConfig`, replace:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
versionName = "0.1.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// CI passes -PappVersion=<version> (release workflow); local builds
|
|
||||||
// keep the default.
|
|
||||||
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Change 2** — inside the `android { }` block, right after the `buildTypes { }`
|
|
||||||
block, add:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// CI release signing: .gitea/workflows/release.yml restores a keystore
|
|
||||||
// from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see
|
|
||||||
// scripts/make_release_keystore.sh). Without them the release build stays
|
|
||||||
// unsigned and the workflow falls back to a debug APK.
|
|
||||||
val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE")
|
|
||||||
if (ciKeystore != null) {
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file(ciKeystore)
|
|
||||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
|
||||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
|
||||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4b. `app/desktopApp/build.gradle.kts`
|
|
||||||
|
|
||||||
**Change 1** — near the top (after `val arch = ...`), add:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
// CI passes -PappVersion=<version> (release workflow); local builds keep the
|
|
||||||
// default. jpackage requires a plain semver (no leading "v").
|
|
||||||
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Change 2** — in the `jpackage` Exec task's `commandLine(...)`, replace:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
"--app-version", "0.1.0",
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
"--app-version", appVersion,
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. NEW FILE: `scripts/make_release_keystore.sh` (Android signing, one-time)
|
|
||||||
|
|
||||||
This is the noob-friendly signing setup. Run it **once** on your machine:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/make_release_keystore.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
It generates a keystore with random passwords and prints the four values to
|
|
||||||
paste into Gitea. Then create the secrets in
|
|
||||||
**Gitea → repo → Settings → Actions → Secrets**:
|
|
||||||
|
|
||||||
| Secret | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| `ANDROID_KEYSTORE_BASE64` | the long base64 blob the script prints |
|
|
||||||
| `ANDROID_KEYSTORE_PASSWORD` | printed by the script |
|
|
||||||
| `ANDROID_KEY_ALIAS` | `iris` |
|
|
||||||
| `ANDROID_KEY_PASSWORD` | printed by the script |
|
|
||||||
|
|
||||||
Until the secrets exist, the release workflow still works but ships a
|
|
||||||
**debug** APK (installable, but not suitable for updates).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
# One-time setup: create the Android release keystore and print the values to
|
|
||||||
# paste into Gitea (repo -> Settings -> Actions -> Secrets).
|
|
||||||
#
|
|
||||||
# Usage: scripts/make_release_keystore.sh [output-file]
|
|
||||||
# (default: ~/iris-release.keystore)
|
|
||||||
#
|
|
||||||
# WARNING: back up the keystore file immediately. If it is lost, the app can
|
|
||||||
# never be updated on users' phones (a new key = a brand-new app as far as
|
|
||||||
# Android is concerned).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
OUT="${1:-$HOME/iris-release.keystore}"
|
|
||||||
if [ -e "$OUT" ]; then
|
|
||||||
echo "Refusing to overwrite existing file: $OUT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
STORE_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
|
|
||||||
KEY_PASS="$(openssl rand -base64 18 | tr -d '/+=')"
|
|
||||||
|
|
||||||
keytool -genkeypair -v \
|
|
||||||
-keystore "$OUT" -storetype PKCS12 \
|
|
||||||
-alias iris -keyalg RSA -keysize 2048 -validity 10000 \
|
|
||||||
-storepass "$STORE_PASS" -keypass "$KEY_PASS" \
|
|
||||||
-dname "CN=Iris Release, OU=Mobile, O=Iris, C=DE"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "Keystore written to: $OUT"
|
|
||||||
echo ">>> Back it up NOW (password manager / cloud storage). <<<"
|
|
||||||
echo
|
|
||||||
echo "Add these four secrets in Gitea (repo -> Settings -> Actions -> Secrets):"
|
|
||||||
echo
|
|
||||||
echo " ANDROID_KEYSTORE_BASE64 = $(base64 -w0 "$OUT")"
|
|
||||||
echo
|
|
||||||
echo " ANDROID_KEYSTORE_PASSWORD = $STORE_PASS"
|
|
||||||
echo " ANDROID_KEY_ALIAS = iris"
|
|
||||||
echo " ANDROID_KEY_PASSWORD = $KEY_PASS"
|
|
||||||
```
|
|
||||||
|
|
||||||
Don't forget: `chmod +x scripts/make_release_keystore.sh`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Prerequisites / checks before the first run
|
|
||||||
|
|
||||||
1. **act_runner must be registered** on gitea.zephyre.one with the label
|
|
||||||
`ubuntu-latest` (that's what both workflows request). Check under
|
|
||||||
Gitea → (your user or the org) → Actions → Runners.
|
|
||||||
2. The runner needs internet access (GitHub, Google, Maven Central, Gradle).
|
|
||||||
3. No Gitea secrets are *required* — but add the four keystore secrets from
|
|
||||||
§5 for a signed APK, and optionally a `GITEA_TOKEN` (Releases: write) if
|
|
||||||
the automatic `GITHUB_TOKEN` doesn't have release permission.
|
|
||||||
|
|
||||||
## 7. Using it
|
|
||||||
|
|
||||||
- **CI**: pushes to `master` / PRs run automatically.
|
|
||||||
- **Release**: repo → **Actions** → *Release* → **Run workflow** → enter
|
|
||||||
`version` (e.g. `0.2.0`) + `changelog` → start. The release appears at
|
|
||||||
`https://gitea.zephyre.one/ARIA/iris_x_hermes/releases/tag/v0.2.0` with:
|
|
||||||
- `iris-android-v0.2.0.apk` (signed, or `-debug` without keystore secrets)
|
|
||||||
- `iris-desktop-linux-x64-v0.2.0.zip` (app image, JRE bundled)
|
|
||||||
- `iris-desktop-linux-x64-v0.2.0.deb`
|
|
||||||
- Re-running with the same version **replaces** the old release (old tag +
|
|
||||||
attachments are deleted first).
|
|
||||||
|
|
||||||
## 8. Later: Windows / macOS desktop builds
|
|
||||||
|
|
||||||
When the Windows VM / MacBook runner exists:
|
|
||||||
|
|
||||||
1. Register act_runner on that machine (labels e.g. `windows-latest`,
|
|
||||||
`macos-latest`).
|
|
||||||
2. In `release.yml`, copy the `desktop` job, change `runs-on`, and adjust the
|
|
||||||
artifact names (`iris-desktop-windows-x64-…`, `iris-desktop-macos-…`).
|
|
||||||
jpackage then produces `msi`/`dmg` natively — no other changes needed.
|
|
||||||
3. Bump the pinned hermes-agent SHA in both workflows whenever you update the
|
|
||||||
local `hermes-agent/` checkout (`git -C hermes-agent rev-parse HEAD`).
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
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,183 +1,57 @@
|
|||||||
# Iris × Hermes
|
# Iris × Hermes
|
||||||
|
|
||||||
A chat app for [hermes-agent](https://github.com/NousResearch/hermes-agent): a **native Android app** and a **desktop app** (Linux, macOS, Windows) — both built from one shared Kotlin codebase (Compose Multiplatform).
|
A **native Android + Desktop** experience for [hermes-agent](https://github.com/NousResearch/hermes-agent), connected through a **gateway platform plugin**.
|
||||||
|
|
||||||
Iris pairs with your running `hermes gateway` over a private, token-authenticated connection and gives you a Telegram-quality chat experience with your personal agent: streaming replies, visible reasoning, structured tool activity, channels, threads, media, search, and push notifications.
|
> ## ⚠️ 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.
|
||||||
|
|
||||||
## Features
|
## Start here
|
||||||
|
|
||||||
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
|
📚 **The full implementation reference library is in [`docs/`](docs/README.md).**
|
||||||
- **Absolute Privacy!** — everything stays on your own infrastructure
|
Read `docs/00-overview.md` first, then follow the numbered docs.
|
||||||
(push: ntfy by default; FCM is opt-in and routes push metadata via Google —
|
|
||||||
see [Push notifications](#push-notifications))
|
|
||||||
- **100 MB file uploads by default** — configurable on the gateway via
|
|
||||||
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
|
|
||||||
on the gateway side (hermes), not in the app
|
|
||||||
- **No 4,096-character message limit like Telegram** — messages travel over
|
|
||||||
your own gateway (hard frame-body cap: 1 MiB)
|
|
||||||
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
|
|
||||||
- **HTML Artifact Preview** — agent-sent HTML/CSS/JS rendered in an in-app WebView
|
|
||||||
- **All settings live in the app**, not in hermes `config.yml`! Change everything on the fly.
|
|
||||||
- **Channels** — create channels to keep track of your reports, cronjobs, webhook calls
|
|
||||||
- **Pin channels, rename them, change the color/icon**
|
|
||||||
- **Customize how your chat should look like** — color? Check! Images? Check!
|
|
||||||
- **Reasoning collapse/expandable** in the chat bubble
|
|
||||||
- **Change the default behavior** of tool & reasoning verbosity
|
|
||||||
- **Threads** — create your own, or let the AI create them with a title
|
|
||||||
- **Search messages from everywhere**
|
|
||||||
|
|
||||||
## How it works
|
## Quickstart
|
||||||
|
|
||||||
```
|
Full walkthrough: [`docs/setup.md`](docs/setup.md).
|
||||||
hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android / Desktop)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `gateway-plugin/` is a hermes **platform plugin** (`android`). It runs inside the
|
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`.
|
||||||
`hermes gateway` process and opens an HTTP server the apps connect to.
|
2. **App:** `cd app && ./gradlew :androidApp:installDebug` (Android; ADB device connected) or `./gradlew :desktopApp:run` (desktop).
|
||||||
Zero new Python dependencies, zero hermes-core changes.
|
3. **Pair:** on the app's Connect screen, enter the server URL (`ws://<gateway-ip>:8790/ws`) + pairing token, then **Test & Connect**.
|
||||||
- `app/` is one Compose Multiplatform Gradle project: `:shared` (KMP, most of the
|
4. **Chat.**
|
||||||
code), `:androidApp` (native Kotlin + Jetpack Compose client), `:desktopApp`
|
|
||||||
(the same app, tweaked for a big screen).
|
|
||||||
- The app is a first-class hermes *messaging platform*, so everything the gateway
|
|
||||||
already does just works: slash commands, cron delivery, `send_message` routing,
|
|
||||||
coexistence with Telegram/Discord/etc.
|
|
||||||
- Push notifications: ntfy (default) or FCM (opt-in).
|
|
||||||
|
|
||||||
## Push notifications
|
### Pairing notes
|
||||||
|
|
||||||
Push wakes a backgrounded/offline device; on reconnect the app syncs the
|
- **Token location:** `~/.hermes/.env` on the gateway host (`ANDROID_TOKEN`), or the `hermes gateway setup` output (printed once, at generation).
|
||||||
outbox, so nothing is lost.
|
- **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.
|
||||||
|
|
||||||
- **ntfy (default)** — push metadata stays on your own infrastructure
|
## The three deliverables (this monorepo)
|
||||||
(self-hosted ntfy recommended). This is the backend for truly private
|
|
||||||
communication.
|
|
||||||
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
|
|
||||||
metadata (notification title, device token) is routed through **Google's
|
|
||||||
servers**. If you want truly private communication, use ntfy instead.
|
|
||||||
|
|
||||||
Setup: [`docs/install.md`](docs/install.md); details:
|
|
||||||
[`docs/08-push.md`](docs/08-push.md).
|
|
||||||
|
|
||||||
## Install the gateway
|
|
||||||
|
|
||||||
Three commands on the machine where hermes runs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd hermes-agent && uv sync # 1. hermes with its venv (separate project, not this repo)
|
|
||||||
|
|
||||||
# 2. install the Iris plugin — the #gateway-plugin suffix points the
|
|
||||||
# installer at the plugin subfolder of this monorepo
|
|
||||||
hermes plugins install git@gitea.zephyre.one:ARIA/iris_x_hermes.git#gateway-plugin
|
|
||||||
|
|
||||||
# 3. generate the pairing token + server URL, then run the gateway
|
|
||||||
hermes gateway setup
|
|
||||||
hermes gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
`hermes gateway setup` prints the **server URL** and **pairing token / QR**
|
|
||||||
the app needs on its Connect screen.
|
|
||||||
|
|
||||||
All options (LAN binding, TLS, push, device allowlist) and the full app
|
|
||||||
pairing walkthrough: [`docs/install.md`](docs/install.md).
|
|
||||||
|
|
||||||
## Build from source
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
| Where | You need |
|
|
||||||
| --- | --- |
|
|
||||||
| Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) |
|
|
||||||
| Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device |
|
|
||||||
| Desktop build machine | JDK 17 only |
|
|
||||||
|
|
||||||
No system Gradle needed — both apps use the project wrapper (`./gradlew`).
|
|
||||||
|
|
||||||
### 1. Gateway (on the gateway host)
|
|
||||||
|
|
||||||
If you're developing from a checkout, skip `hermes plugins install` and
|
|
||||||
symlink the plugin so it always tracks your working tree:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd hermes-agent && uv sync
|
|
||||||
mkdir -p ~/.hermes/plugins
|
|
||||||
ln -s "$PWD/gateway-plugin" ~/.hermes/plugins/iris
|
|
||||||
hermes gateway status # should list the Iris platform
|
|
||||||
|
|
||||||
hermes gateway setup # generates IRIS_TOKEN, prints server URL + pairing QR
|
|
||||||
hermes gateway # run the gateway
|
|
||||||
```
|
|
||||||
|
|
||||||
(Otherwise see [Install the gateway](#install-the-gateway) above.)
|
|
||||||
|
|
||||||
### 2. Android app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd app
|
|
||||||
./gradlew :androidApp:installDebug # build + install on the connected ADB device
|
|
||||||
# or just build the APK:
|
|
||||||
./gradlew :androidApp:assembleDebug # → app/androidApp/build/outputs/apk/debug/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Desktop app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd app
|
|
||||||
./gradlew :desktopApp:run # dev run
|
|
||||||
./gradlew :desktopApp:jpackage # native app-image (bundles the JRE)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Pair
|
|
||||||
|
|
||||||
On the app's **Connect** screen:
|
|
||||||
|
|
||||||
1. **Server URL** — `http://<gateway-ip>:8791` (printed by `hermes gateway setup`).
|
|
||||||
2. **Pairing token** — from the setup output, or `IRIS_TOKEN` in `~/.hermes/.env`
|
|
||||||
on the gateway host.
|
|
||||||
3. **Test & Connect.**
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- **Android** has a **Scan QR** button that reads the QR printed by
|
|
||||||
`hermes gateway setup` and pre-fills URL + token; desktop uses manual entry.
|
|
||||||
- The default bind is `127.0.0.1` (desktop on the same machine only). For a
|
|
||||||
phone on the LAN, set `IRIS_HTTP_HOST` to the gateway's LAN IP.
|
|
||||||
- Remote access: Tailscale/WireGuard, or a reverse proxy/tunnel with TLS
|
|
||||||
(`IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`).
|
|
||||||
|
|
||||||
Full walkthrough, push setup (ntfy/FCM), TLS, and troubleshooting:
|
|
||||||
[`docs/install.md`](docs/install.md).
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! Before you start:
|
|
||||||
|
|
||||||
1. **Read the docs.** The full reference library is in [`docs/`](docs/README.md) —
|
|
||||||
start with [`docs/00-overview.md`](docs/00-overview.md), then follow the
|
|
||||||
numbered docs (architecture, wire protocol, plugin design, testing, …).
|
|
||||||
2. **Know the layout.**
|
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth |
|
| `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/shared` | KMP module with most of the client code (shared by Android + Desktop) |
|
| `app/androidApp` | Native **Kotlin + Jetpack Compose** client. |
|
||||||
| `app/androidApp` | Thin Android shell (package `dev.iris.app`) |
|
| `app/desktopApp` | **Kotlin + Compose Multiplatform** client — the Android app, tweaked for a big screen (shares `app/shared`). |
|
||||||
| `app/desktopApp` | Thin desktop shell |
|
|
||||||
| `docs/` | Numbered reference library |
|
|
||||||
|
|
||||||
3. **Run the tests.**
|
The Android and Desktop clients live in **one Compose Multiplatform Gradle
|
||||||
- Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
|
project** (`app/`) with a shared KMP module (`app/shared`).
|
||||||
(never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`).
|
|
||||||
- Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest`
|
|
||||||
- Live check (gateway must be running): `tests/ws_probe.py` and
|
|
||||||
`tests/e2e.py` — see [`tests/README.md`](tests/README.md).
|
|
||||||
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
|
|
||||||
`app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json`
|
|
||||||
must always agree.
|
|
||||||
5. **Pre-commit hooks** are configured (`.pre-commit-config.yaml`); run
|
|
||||||
`pre-commit install` once after cloning.
|
|
||||||
|
|
||||||
Open an issue first for anything big, then send a pull request.
|
## Status
|
||||||
|
|
||||||
## License
|
- **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).
|
||||||
|
|
||||||
Apache License 2.0 — see [LICENSE](LICENSE).
|
## Quick orientation
|
||||||
|
|
||||||
|
- 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>
|
||||||
@@ -1,31 +1,19 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
kotlin("android")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "dev.iris.app"
|
namespace = "dev.iris.app"
|
||||||
compileSdk = 37
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.iris.app"
|
applicationId = "dev.iris.app"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
// Play Store requires an incrementing versionCode per upload; CI can
|
versionCode = 1
|
||||||
// pass -PappVersionCode=<n>. Local builds keep the default.
|
versionName = "0.1.0"
|
||||||
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
|
|
||||||
// The repo-root VERSION file is the single source of truth (bump it
|
|
||||||
// to cut a release); CI can still override with -PappVersion.
|
|
||||||
versionName =
|
|
||||||
(project.findProperty("appVersion") as? String)
|
|
||||||
?: project
|
|
||||||
.file("../../VERSION")
|
|
||||||
.takeIf { it.exists() }
|
|
||||||
?.readText()
|
|
||||||
?.trim()
|
|
||||||
?: "0.1.0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -34,54 +22,23 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CI release signing: .gitea/workflows/release.yml restores a keystore
|
|
||||||
// from Gitea secrets and exports ANDROID_KEYSTORE_* env vars (see
|
|
||||||
// scripts/make_release_keystore.sh). Without them the release build stays
|
|
||||||
// unsigned and the workflow falls back to a debug APK.
|
|
||||||
val ciKeystore = System.getenv("ANDROID_KEYSTORE_FILE")
|
|
||||||
if (ciKeystore != null) {
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
storeFile = file(ciKeystore)
|
|
||||||
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
|
|
||||||
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
|
|
||||||
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig = signingConfigs.getByName("release")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
// targetSdk is deliberately pinned to 34 (a stable API level): the
|
kotlinOptions {
|
||||||
// reference device is API 29 and API 37 (the only newer installed
|
jvmTarget = "17"
|
||||||
// platform) is a preview SDK, which is not appropriate to target for a
|
|
||||||
// stable build. Silence the informational OldTargetApi hint.
|
|
||||||
lint {
|
|
||||||
disable += "OldTargetApi"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":shared"))
|
implementation(project(":shared"))
|
||||||
implementation(platform("androidx.compose:compose-bom:2026.08.00"))
|
implementation(platform("androidx.compose:compose-bom:2024.09.02"))
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.activity:activity-compose:1.13.0")
|
implementation("androidx.activity:activity-compose:1.9.2")
|
||||||
implementation("androidx.core:core-splashscreen:1.2.0")
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
// M5: apply the google-services plugin only when a Firebase project is
|
// M5: apply the google-services plugin only when a Firebase project is
|
||||||
|
|||||||
@@ -8,16 +8,7 @@
|
|||||||
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
<!-- M5: ntfy listener foreground service (dataSync type on API 34). -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<!-- QR pairing (docs/20): the in-app scanner reads the camera. -->
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
|
||||||
|
|
||||||
<!-- M-3: cleartext (http://) is required because the gateway is a LAN host
|
|
||||||
addressed by IP (e.g. 192.168.x.x), not a domain. Android's
|
|
||||||
network_security_config can only scope cleartext to domain names, not
|
|
||||||
IP ranges, so a per-host allowlist isn't possible for this use case.
|
|
||||||
The token is still required for auth; traffic is only ever sent to the
|
|
||||||
user-configured gateway on the local network. -->
|
|
||||||
<application
|
<application
|
||||||
android:label="Iris"
|
android:label="Iris"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -47,22 +38,8 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="iris" android:host="chat" />
|
<data android:scheme="iris" android:host="chat" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- QR pairing (docs/20): iris://pair deep link (scanned QR / link). -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="iris" android:host="pair" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- QR pairing (docs/20): full-screen scanner launched from the
|
|
||||||
Connect screen. Not exported; started only by our own app. -->
|
|
||||||
<activity
|
|
||||||
android:name="iris.platform.QrScanActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:screenOrientation="portrait" />
|
|
||||||
|
|
||||||
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
<!-- M4: serve cached media/documents to other apps (ACTION_VIEW). -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 758 KiB |
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 610 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
@@ -15,17 +15,12 @@ import iris.IrisApp
|
|||||||
import iris.platform.AndroidEnv
|
import iris.platform.AndroidEnv
|
||||||
import iris.platform.AndroidSecureStore
|
import iris.platform.AndroidSecureStore
|
||||||
import iris.platform.AppBridge
|
import iris.platform.AppBridge
|
||||||
import iris.platform.syncNtfyListener
|
import iris.platform.NtfyListenerService
|
||||||
import iris.util.PairLink
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val deepLinkChatId = mutableStateOf<String?>(null)
|
private val deepLinkChatId = mutableStateOf<String?>(null)
|
||||||
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
private val deepLinkThreadId = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
// QR pairing (docs/20): a parsed iris://pair link to prefill the Connect
|
|
||||||
// screen with.
|
|
||||||
private val pendingPair = mutableStateOf<PairLink?>(null)
|
|
||||||
|
|
||||||
private val notificationPermission =
|
private val notificationPermission =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* result ignored */ }
|
||||||
|
|
||||||
@@ -36,19 +31,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
val store = AndroidSecureStore(applicationContext)
|
val store = AndroidSecureStore(applicationContext)
|
||||||
handleDeepLink(intent)
|
handleDeepLink(intent)
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
// M5: the ntfy listener (and its permanent notification) only runs
|
startNtfyListener(store)
|
||||||
// when the paired gateway pushes via ntfy; unknown ("") = not yet.
|
|
||||||
syncNtfyListener(store.pushBackend)
|
|
||||||
setContent {
|
setContent {
|
||||||
val chatId by deepLinkChatId
|
val chatId by deepLinkChatId
|
||||||
val threadId by deepLinkThreadId
|
val threadId by deepLinkThreadId
|
||||||
val pair by pendingPair
|
IrisApp(store = store, deepLinkChatId = chatId, deepLinkThreadId = threadId)
|
||||||
IrisApp(
|
|
||||||
store = store,
|
|
||||||
deepLinkChatId = chatId,
|
|
||||||
deepLinkThreadId = threadId,
|
|
||||||
deepLinkPair = pair,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +47,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
AppBridge.foreground = true
|
AppBridge.foreground = true
|
||||||
// Wake the connect loop's backoff: after a background stint the
|
|
||||||
// network is usually back, so re-probe immediately instead of making
|
|
||||||
// the user wait out the (up to 30 s) backoff with a Connecting banner.
|
|
||||||
AppBridge.controller?.client?.poke()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@@ -75,19 +58,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
* extras or an iris://chat/<id>?thread=<tid> URI). */
|
||||||
private fun handleDeepLink(intent: Intent?) {
|
private fun handleDeepLink(intent: Intent?) {
|
||||||
val data = intent?.data
|
val data = intent?.data
|
||||||
// QR pairing (docs/20): iris://pair?host=…&port=…&secure=…&token=… —
|
val chatId = intent?.getStringExtra("chat_id")
|
||||||
// the whole payload is in the query string; parse it with the shared
|
|
||||||
// PairLink parser.
|
|
||||||
if (data?.scheme == "iris" && data.host == "pair") {
|
|
||||||
val link = PairLink.parse(data.toString())
|
|
||||||
if (link != null) pendingPair.value = link
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val chatId =
|
|
||||||
intent?.getStringExtra("chat_id")
|
|
||||||
?: data?.pathSegments?.firstOrNull()
|
?: data?.pathSegments?.firstOrNull()
|
||||||
val threadId =
|
val threadId = intent?.getStringExtra("thread_id")
|
||||||
intent?.getStringExtra("thread_id")
|
|
||||||
?: data?.getQueryParameter("thread")
|
?: data?.getQueryParameter("thread")
|
||||||
if (!chatId.isNullOrBlank()) {
|
if (!chatId.isNullOrBlank()) {
|
||||||
deepLinkChatId.value = chatId
|
deepLinkChatId.value = chatId
|
||||||
@@ -104,4 +77,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startNtfyListener(store: AndroidSecureStore) {
|
||||||
|
if (store.ntfyTopic.isBlank()) return
|
||||||
|
ContextCompat.startForegroundService(this, Intent(this, NtfyListenerService::class.java))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -7,20 +7,19 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("com.google.gms:google-services:4.5.0")
|
classpath("com.google.gms:google-services:4.4.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform") version "2.4.10" apply false
|
kotlin("multiplatform") version "2.1.0" apply false
|
||||||
kotlin("jvm") version "2.4.10" apply false
|
kotlin("android") version "2.1.0" apply false
|
||||||
kotlin("plugin.serialization") version "2.4.10" apply false
|
kotlin("jvm") version "2.1.0" apply false
|
||||||
id("com.android.application") version "9.3.1" apply false
|
kotlin("plugin.serialization") version "2.1.0" apply false
|
||||||
id("com.android.kotlin.multiplatform.library") version "9.3.1" apply false
|
id("com.android.application") version "8.7.3" apply false
|
||||||
id("org.jetbrains.compose") version "1.11.1" apply false
|
id("com.android.library") version "8.7.3" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
|
id("org.jetbrains.compose") version "1.7.3" apply false
|
||||||
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
|
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
|
||||||
id("app.cash.sqldelight") version "2.3.2" apply false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("clean") {
|
tasks.register("clean") {
|
||||||
|
|||||||
@@ -6,22 +6,10 @@ plugins {
|
|||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
val composeVersion = "1.11.1"
|
val composeVersion = "1.7.3"
|
||||||
val os = OperatingSystem.current()
|
val os = OperatingSystem.current()
|
||||||
val arch = System.getProperty("os.arch") ?: "amd64"
|
val arch = System.getProperty("os.arch") ?: "amd64"
|
||||||
// The repo-root VERSION file is the single source of truth (bump it to cut
|
val desktopTarget = when {
|
||||||
// a release); CI can still override with -PappVersion. jpackage requires a
|
|
||||||
// plain semver (no leading "v").
|
|
||||||
val appVersion =
|
|
||||||
(project.findProperty("appVersion") as? String)
|
|
||||||
?: project
|
|
||||||
.file("../../VERSION")
|
|
||||||
.takeIf { it.exists() }
|
|
||||||
?.readText()
|
|
||||||
?.trim()
|
|
||||||
?: "0.1.0"
|
|
||||||
val desktopTarget =
|
|
||||||
when {
|
|
||||||
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
|
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
|
||||||
os.isWindows -> "windows-x64"
|
os.isWindows -> "windows-x64"
|
||||||
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
|
else -> if (arch == "aarch64") "linux-arm64" else "linux-x64"
|
||||||
@@ -31,7 +19,7 @@ dependencies {
|
|||||||
implementation(project(":shared"))
|
implementation(project(":shared"))
|
||||||
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
|
implementation("org.jetbrains.compose.desktop:desktop-jvm-$desktopTarget:$composeVersion")
|
||||||
// M6: window-state persistence (window.json).
|
// M6: window-state persistence (window.json).
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
@@ -57,15 +45,13 @@ afterEvaluate {
|
|||||||
|
|
||||||
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
|
val jpackageInputDir = layout.buildDirectory.dir("jpackage-input")
|
||||||
|
|
||||||
val fatJar =
|
val fatJar = tasks.register<Jar>("fatJar") {
|
||||||
tasks.register<Jar>("fatJar") {
|
|
||||||
archiveClassifier.set("all")
|
archiveClassifier.set("all")
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
|
manifest { attributes("Main-Class" to "iris.desktop.MainKt") }
|
||||||
from(sourceSets.main.get().output)
|
from(sourceSets.main.get().output)
|
||||||
from({
|
from({
|
||||||
configurations.runtimeClasspath
|
configurations.runtimeClasspath.get()
|
||||||
.get()
|
|
||||||
.filter { it.name.endsWith(".jar") }
|
.filter { it.name.endsWith(".jar") }
|
||||||
.map { zipTree(it) }
|
.map { zipTree(it) }
|
||||||
}) {
|
}) {
|
||||||
@@ -87,39 +73,23 @@ tasks.register<Exec>("jpackage") {
|
|||||||
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
|
val jpackageBin = file("${System.getProperty("java.home")}/bin/jpackage")
|
||||||
val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
|
val type = (project.findProperty("jpackageType") as? String) ?: "app-image"
|
||||||
val inputDir = jpackageInputDir.get().asFile
|
val inputDir = jpackageInputDir.get().asFile
|
||||||
val destDir =
|
val destDir = layout.buildDirectory.dir("jpackage").get().asFile
|
||||||
layout.buildDirectory
|
|
||||||
.dir("jpackage")
|
|
||||||
.get()
|
|
||||||
.asFile
|
|
||||||
inputs.dir(inputDir)
|
inputs.dir(inputDir)
|
||||||
outputs.dir(destDir)
|
outputs.dir(destDir)
|
||||||
commandLine(
|
commandLine(
|
||||||
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
|
if (jpackageBin.exists()) jpackageBin.absolutePath else "jpackage",
|
||||||
"--name",
|
"--name", "iris",
|
||||||
"iris",
|
"--app-version", "0.1.0",
|
||||||
"--app-version",
|
"--vendor", "Iris",
|
||||||
appVersion,
|
"--type", type,
|
||||||
"--vendor",
|
"--input", inputDir.absolutePath,
|
||||||
"Iris",
|
"--main-jar", "desktopApp-all.jar",
|
||||||
"--type",
|
"--main-class", "iris.desktop.MainKt",
|
||||||
type,
|
"--icon", file("src/main/resources/icon.png").absolutePath,
|
||||||
"--input",
|
"--dest", destDir.absolutePath,
|
||||||
inputDir.absolutePath,
|
"--java-options", "-Xmx1g",
|
||||||
"--main-jar",
|
|
||||||
"desktopApp-all.jar",
|
|
||||||
"--main-class",
|
|
||||||
"iris.desktop.MainKt",
|
|
||||||
"--icon",
|
|
||||||
file("src/main/resources/icon.png").absolutePath,
|
|
||||||
"--dest",
|
|
||||||
destDir.absolutePath,
|
|
||||||
"--java-options",
|
|
||||||
"-Xmx1g",
|
|
||||||
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
|
// M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above).
|
||||||
"--java-options",
|
"--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
|
||||||
"--add-opens=java.desktop/sun.awt=ALL-UNNAMED",
|
"--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
|
||||||
"--java-options",
|
|
||||||
"--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -19,12 +19,9 @@ import iris.IrisApp
|
|||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.DesktopBridge
|
import iris.platform.DesktopBridge
|
||||||
import iris.platform.DesktopSecureStore
|
import iris.platform.DesktopSecureStore
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import java.awt.Color
|
import java.awt.Color
|
||||||
import java.awt.Graphics2D
|
import java.awt.Graphics2D
|
||||||
|
import javax.imageio.ImageIO
|
||||||
import java.awt.RenderingHints
|
import java.awt.RenderingHints
|
||||||
import java.awt.SystemTray
|
import java.awt.SystemTray
|
||||||
import java.awt.event.WindowEvent
|
import java.awt.event.WindowEvent
|
||||||
@@ -32,7 +29,10 @@ import java.awt.event.WindowFocusListener
|
|||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.imageio.ImageIO
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M6: desktop shell (docs/11 §11.2/§11.3).
|
* M6: desktop shell (docs/11 §11.2/§11.3).
|
||||||
@@ -48,7 +48,6 @@ private val windowJson = File(System.getProperty("user.home"), ".iris/window.jso
|
|||||||
|
|
||||||
// Window/taskbar icon (src/main/resources/icon.png).
|
// Window/taskbar icon (src/main/resources/icon.png).
|
||||||
private class IrisDesktop
|
private class IrisDesktop
|
||||||
|
|
||||||
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
private val windowIcon = ImageIO.read(IrisDesktop::class.java.getResource("/icon.png")!!).toComposeImageBitmap()
|
||||||
|
|
||||||
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
// Tray icon colors (ARGB). .toInt(): the literals exceed the Int range.
|
||||||
@@ -88,36 +87,14 @@ fun main() {
|
|||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(2_000)
|
delay(2_000)
|
||||||
val state =
|
val state = DesktopBridge.controller?.client?.state?.value
|
||||||
DesktopBridge.controller
|
val (color, tooltip) = when (state) {
|
||||||
?.client
|
|
||||||
?.state
|
|
||||||
?.value
|
|
||||||
val (color, tooltip) =
|
|
||||||
when (state) {
|
|
||||||
null,
|
null,
|
||||||
is GatewayClient.State.Disconnected,
|
is GatewayClient.State.Disconnected -> TRAY_OFFLINE to "Iris — offline"
|
||||||
-> {
|
|
||||||
TRAY_OFFLINE to "Iris — offline"
|
|
||||||
}
|
|
||||||
|
|
||||||
is GatewayClient.State.Connecting,
|
is GatewayClient.State.Connecting,
|
||||||
is GatewayClient.State.Reconnecting,
|
is GatewayClient.State.Reconnecting -> TRAY_CONNECTING to "Iris — connecting…"
|
||||||
-> {
|
is GatewayClient.State.Connected -> TRAY_CONNECTED to "Iris — connected"
|
||||||
TRAY_CONNECTING to "Iris — connecting…"
|
is GatewayClient.State.AuthFailed -> TRAY_AUTH_FAILED to "Iris — auth failed"
|
||||||
}
|
|
||||||
|
|
||||||
is GatewayClient.State.Connected -> {
|
|
||||||
TRAY_CONNECTED to "Iris — connected"
|
|
||||||
}
|
|
||||||
|
|
||||||
is GatewayClient.State.AuthFailed -> {
|
|
||||||
TRAY_AUTH_FAILED to "Iris — auth failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
is GatewayClient.State.TlsConfirmRequired -> {
|
|
||||||
TRAY_AUTH_FAILED to "Iris — gateway certificate needs confirmation"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
trayColor = color
|
trayColor = color
|
||||||
trayTooltip = tooltip
|
trayTooltip = tooltip
|
||||||
@@ -149,8 +126,7 @@ fun main() {
|
|||||||
state = loadWindowState(),
|
state = loadWindowState(),
|
||||||
) {
|
) {
|
||||||
composeWindow = window
|
composeWindow = window
|
||||||
window.addWindowFocusListener(
|
window.addWindowFocusListener(object : WindowFocusListener {
|
||||||
object : WindowFocusListener {
|
|
||||||
override fun windowGainedFocus(e: WindowEvent) {
|
override fun windowGainedFocus(e: WindowEvent) {
|
||||||
DesktopBridge.foreground = true
|
DesktopBridge.foreground = true
|
||||||
}
|
}
|
||||||
@@ -158,8 +134,7 @@ fun main() {
|
|||||||
override fun windowLostFocus(e: WindowEvent) {
|
override fun windowLostFocus(e: WindowEvent) {
|
||||||
DesktopBridge.foreground = false
|
DesktopBridge.foreground = false
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
IrisApp(store)
|
IrisApp(store)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||||
# Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is
|
|
||||||
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
|
|
||||||
# unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget).
|
|
||||||
#
|
|
||||||
# The JDK-21 home is machine-specific, so it is NOT hardcoded here (a
|
|
||||||
# committed path would break every other checkout). Set it per machine via
|
|
||||||
# one of:
|
|
||||||
# - ~/.gradle/gradle.properties -> org.gradle.java.home=/path/to/jdk21
|
|
||||||
# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
|
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -1,124 +1,44 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
kotlin("multiplatform")
|
kotlin("multiplatform")
|
||||||
kotlin("plugin.serialization")
|
kotlin("plugin.serialization")
|
||||||
id("com.android.kotlin.multiplatform.library")
|
id("com.android.library")
|
||||||
id("org.jetbrains.compose")
|
id("org.jetbrains.compose")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
// Local cache DB (docs/16: "Local DB (KMP) — SQLDelight").
|
|
||||||
id("app.cash.sqldelight")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val composeVersion = "1.11.1"
|
val composeVersion = "1.7.3"
|
||||||
// material3 and material-icons-extended are published on their own cadence and
|
val coroutinesVersion = "1.9.0"
|
||||||
// lag the core artifacts; use each one's latest stable release.
|
val serializationVersion = "1.7.3"
|
||||||
val material3Version = "1.9.0"
|
val okhttpVersion = "4.12.0"
|
||||||
val materialIconsVersion = "1.7.3"
|
// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1);
|
||||||
val coroutinesVersion = "1.11.0"
|
// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the
|
||||||
val serializationVersion = "1.11.0"
|
|
||||||
val okhttpVersion = "5.5.0"
|
|
||||||
// 2.0.x is the first line built against Compose 1.8+ (1.9.40 was the last
|
|
||||||
// 1.7.x-compatible release). HTML artifact previews (M9): Android uses the
|
|
||||||
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
|
// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use).
|
||||||
val webviewVersion = "2.0.3"
|
val webviewVersion = "1.9.40"
|
||||||
val kcefVersion = "2025.03.23"
|
val kcefVersion = "2024.04.20.3"
|
||||||
// 0.34.0+ moved to Compose 1.8+ (0.33.0 was the last 1.7.x / Kotlin 2.1.x
|
// Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x
|
||||||
// release). Provides GFM tables, bold/italic/underscore, and the -code module
|
// (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore,
|
||||||
// for language-aware syntax highlighting (Highlights).
|
// and the -code module for language-aware syntax highlighting (Highlights).
|
||||||
val markdownVersion = "0.44.0"
|
val markdownVersion = "0.33.0"
|
||||||
// Local cache DB (messages/channels/meta; docs/10 §10.7, docs/16).
|
|
||||||
val sqldelightVersion = "2.3.2"
|
|
||||||
// CameraX + ML Kit for in-app QR pairing (docs/20). Android-only.
|
|
||||||
val cameraxVersion = "1.5.1"
|
|
||||||
val mlKitVersion = "16.1.1"
|
|
||||||
|
|
||||||
// ── App version (generated) ─────────────────────────────────────────────
|
|
||||||
// The repo-root VERSION file is the single source of truth (bump it to cut
|
|
||||||
// a release; CI can still override with -PappVersion). Generate AppVersion.kt
|
|
||||||
// into commonMain so both targets can display it in Settings and send it to
|
|
||||||
// the gateway (X-Iris-App-Version header).
|
|
||||||
//
|
|
||||||
// A real task with the VERSION file as a DECLARED input: on a
|
|
||||||
// configuration-cache hit the script body does not re-run, so only the task
|
|
||||||
// (keyed on the file's content) can regenerate AppVersion.kt after a bump.
|
|
||||||
// The doLast reads everything from the task's own inputs/outputs (which are
|
|
||||||
// configuration-cache serializable) — it must not reference script-scope
|
|
||||||
// vals, because a .kts script lambda captures the script object and the
|
|
||||||
// configuration cache rejects that.
|
|
||||||
val generatedVersionDir = layout.buildDirectory.dir("generated/app-version")
|
|
||||||
val generateAppVersion by tasks.registering {
|
|
||||||
inputs.file(project.file("../../VERSION"))
|
|
||||||
inputs.property("appVersionOverride", (project.findProperty("appVersion") as? String).orEmpty())
|
|
||||||
val outFile = generatedVersionDir.map { it.file("AppVersion.kt") }
|
|
||||||
outputs.file(outFile)
|
|
||||||
doLast {
|
|
||||||
val override = inputs.properties["appVersionOverride"] as? String ?: ""
|
|
||||||
val versionFile = inputs.files.singleFile
|
|
||||||
val version =
|
|
||||||
override.ifBlank {
|
|
||||||
versionFile.takeIf { it.exists() }?.readText()?.trim() ?: "0.1.0"
|
|
||||||
}
|
|
||||||
val f = outFile.get().asFile
|
|
||||||
f.parentFile?.mkdirs()
|
|
||||||
f.writeText(
|
|
||||||
"""
|
|
||||||
|package iris
|
|
||||||
|
|
|
||||||
|/** Generated from the repo-root VERSION file - do not edit. */
|
|
||||||
|object AppVersion {
|
|
||||||
| const val VERSION = "$version"
|
|
||||||
|}
|
|
||||||
|
|
|
||||||
""".trimMargin(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
android {
|
androidTarget()
|
||||||
namespace = "iris.shared"
|
jvm("desktop")
|
||||||
compileSdk = 37
|
|
||||||
minSdk = 29
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_17)
|
|
||||||
}
|
|
||||||
withHostTest { }
|
|
||||||
}
|
|
||||||
jvm("desktop") {
|
|
||||||
// Desktop runs on the Java 21 runtime (see gradle.properties
|
|
||||||
// org.gradle.java.home); target 21 so the Markdown renderer 0.44.0
|
|
||||||
// (Java-21 bytecode) loads. Android keeps its JVM 17 target above.
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
// AppVersion.kt is generated from the repo-root VERSION file (see
|
|
||||||
// generateAppVersion above) into commonMain so both targets can read it.
|
|
||||||
commonMain {
|
|
||||||
kotlin.srcDir(generatedVersionDir)
|
|
||||||
}
|
|
||||||
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
|
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
|
||||||
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
|
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
|
||||||
val jvmMain by creating { dependsOn(commonMain.get()) }
|
val jvmMain by creating { dependsOn(commonMain.get()) }
|
||||||
val androidMain by getting { dependsOn(jvmMain) }
|
val androidMain by getting { dependsOn(jvmMain) }
|
||||||
val desktopMain by getting { dependsOn(jvmMain) }
|
val desktopMain by getting { dependsOn(jvmMain) }
|
||||||
// Host tests shared by the android (withHostTest) and desktop targets
|
|
||||||
// (e.g. the SQLDelight cache DB tests, which need the JDBC driver).
|
|
||||||
val jvmTest by creating { dependsOn(commonTest.get()) }
|
|
||||||
val androidHostTest by getting { dependsOn(jvmTest) }
|
|
||||||
val desktopTest by getting { dependsOn(jvmTest) }
|
|
||||||
|
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
|
implementation("org.jetbrains.compose.runtime:runtime:$composeVersion")
|
||||||
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
|
||||||
implementation("org.jetbrains.compose.material3:material3:$material3Version")
|
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
|
||||||
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
|
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
|
||||||
// Full Material icon set (Icons.Filled.ContentCopy for the code
|
// Full Material icon set (Icons.Filled.ContentCopy for the code
|
||||||
// copy buttons); -core only ships a small subset.
|
// copy buttons); -core only ships a small subset.
|
||||||
implementation("org.jetbrains.compose.material:material-icons-extended:$materialIconsVersion")
|
implementation("org.jetbrains.compose.material:material-icons-extended:$composeVersion")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
|
||||||
// OkHttp is a JVM library; both targets here are JVM-based
|
// OkHttp is a JVM library; both targets here are JVM-based
|
||||||
@@ -133,60 +53,41 @@ kotlin {
|
|||||||
// M9: HTML artifact previews — WebView composable (platform WebView
|
// M9: HTML artifact previews — WebView composable (platform WebView
|
||||||
// on Android, KCEF/JCEF on desktop).
|
// on Android, KCEF/JCEF on desktop).
|
||||||
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
|
implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion")
|
||||||
// Local cache DB runtime (generated code from commonMain/sqldelight).
|
|
||||||
implementation("app.cash.sqldelight:runtime:$sqldelightVersion")
|
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
}
|
}
|
||||||
jvmTest.dependencies {
|
|
||||||
implementation(kotlin("test"))
|
|
||||||
// JDBC SQLite driver: in-memory DB for the cache tests.
|
|
||||||
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
|
|
||||||
}
|
|
||||||
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
|
// M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The
|
||||||
// webview library exposes it transitively, but we reference KCEF
|
// webview library exposes it transitively, but we reference KCEF
|
||||||
// directly (init + progress) so declare it explicitly.
|
// directly (init + progress) so declare it explicitly.
|
||||||
desktopMain.dependencies {
|
desktopMain.dependencies {
|
||||||
implementation("dev.datlag:kcef:$kcefVersion")
|
implementation("dev.datlag:kcef:$kcefVersion")
|
||||||
// Local cache DB: JDBC SQLite driver (file in ~/.iris).
|
|
||||||
implementation("app.cash.sqldelight:sqlite-driver:$sqldelightVersion")
|
|
||||||
}
|
}
|
||||||
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
// M4: ExoPlayer (Media3) for inline audio/video playback (Android only).
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
// M7: EncryptedSharedPreferences for the pairing token
|
// M7: EncryptedSharedPreferences for the pairing token
|
||||||
// (docs/09 §9.7; AndroidSecureStore).
|
// (docs/09 §9.7; AndroidSecureStore).
|
||||||
implementation("androidx.security:security-crypto:1.1.0")
|
implementation("androidx.security:security-crypto:1.1.0")
|
||||||
implementation("androidx.media3:media3-exoplayer:1.11.0")
|
implementation("androidx.media3:media3-exoplayer:1.3.1")
|
||||||
implementation("androidx.media3:media3-ui:1.11.0")
|
implementation("androidx.media3:media3-ui:1.3.1")
|
||||||
// SAF picker (rememberLauncherForActivityResult).
|
// SAF picker (rememberLauncherForActivityResult).
|
||||||
implementation("androidx.activity:activity-compose:1.13.0")
|
implementation("androidx.activity:activity-compose:1.9.2")
|
||||||
// CameraX + ML Kit for QR pairing (docs/20): the scanner activity
|
|
||||||
// uses the camera2 CameraX backend and ML Kit's barcode model.
|
|
||||||
implementation("androidx.camera:camera-core:$cameraxVersion")
|
|
||||||
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
|
||||||
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
|
||||||
implementation("androidx.camera:camera-view:$cameraxVersion")
|
|
||||||
implementation("com.google.mlkit:barcode-scanning:$mlKitVersion")
|
|
||||||
// M5: FCM push (inert without a Firebase project / google-services.json;
|
// M5: FCM push (inert without a Firebase project / google-services.json;
|
||||||
// the ntfy listener is the fallback). The google-services plugin is
|
// the ntfy listener is the fallback). The google-services plugin is
|
||||||
// applied conditionally in the app module.
|
// applied conditionally in the app module.
|
||||||
implementation("com.google.firebase:firebase-messaging:25.1.2")
|
implementation("com.google.firebase:firebase-messaging:23.1.2")
|
||||||
// Local cache DB: Android SQLite driver (app database dir).
|
|
||||||
implementation("app.cash.sqldelight:android-driver:$sqldelightVersion")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sqldelight {
|
android {
|
||||||
databases {
|
namespace = "iris.shared"
|
||||||
create("IrisDatabase") {
|
compileSdk = 34
|
||||||
packageName.set("iris.db")
|
defaultConfig {
|
||||||
|
minSdk = 29
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Every Kotlin compile depends on the generated AppVersion.kt being current.
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
|
||||||
dependsOn(generateAppVersion)
|
|
||||||
}
|
|
||||||
@@ -40,16 +40,16 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.MediaItem as M3MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import iris.data.MediaItem
|
import iris.data.MediaItem
|
||||||
import iris.media.extForMime
|
import iris.media.extForMime
|
||||||
import iris.protocol.KIND_VIDEO
|
import iris.protocol.KIND_VIDEO
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
|
||||||
import androidx.media3.common.MediaItem as M3MediaItem
|
|
||||||
|
|
||||||
/** App context holder (set in MainActivity.onCreate). */
|
/** App context holder (set in MainActivity.onCreate). */
|
||||||
object AndroidEnv {
|
object AndroidEnv {
|
||||||
@@ -59,8 +59,7 @@ object AndroidEnv {
|
|||||||
@Composable
|
@Composable
|
||||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher =
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
onPicked(null)
|
onPicked(null)
|
||||||
return@rememberLauncherForActivityResult
|
return@rememberLauncherForActivityResult
|
||||||
@@ -86,8 +85,7 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher =
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
onPicked(null)
|
onPicked(null)
|
||||||
return@rememberLauncherForActivityResult
|
return@rememberLauncherForActivityResult
|
||||||
@@ -109,10 +107,7 @@ actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
|||||||
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
|
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun queryDisplayName(
|
private fun queryDisplayName(context: Context, uri: Uri): String? {
|
||||||
context: Context,
|
|
||||||
uri: Uri,
|
|
||||||
): String? {
|
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.use { c ->
|
context.contentResolver.query(uri, null, null, null, null)?.use { c ->
|
||||||
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
if (idx >= 0 && c.moveToFirst()) return c.getString(idx)
|
if (idx >= 0 && c.moveToFirst()) return c.getString(idx)
|
||||||
@@ -124,8 +119,7 @@ actual fun mediaCacheBaseDir(): String =
|
|||||||
AndroidEnv.context.externalCacheDir?.absolutePath
|
AndroidEnv.context.externalCacheDir?.absolutePath
|
||||||
?: AndroidEnv.context.cacheDir.absolutePath
|
?: AndroidEnv.context.cacheDir.absolutePath
|
||||||
|
|
||||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? =
|
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
BitmapFactory.decodeFile(path)?.asImageBitmap()
|
BitmapFactory.decodeFile(path)?.asImageBitmap()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -133,11 +127,7 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun loadScaledImage(
|
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
path: String,
|
|
||||||
maxSize: Int,
|
|
||||||
): ImageBitmap? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
// inJustDecodeBounds fills [bounds] and returns null by design — only
|
// inJustDecodeBounds fills [bounds] and returns null by design — only
|
||||||
// the outWidth/outHeight are meaningful here.
|
// the outWidth/outHeight are meaningful here.
|
||||||
@@ -153,38 +143,7 @@ actual suspend fun loadScaledImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun decodeImageBytes(
|
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
|
||||||
bytes: ByteArray,
|
|
||||||
maxSize: Int,
|
|
||||||
): ImageBitmap? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
|
||||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
|
|
||||||
var sample = 1
|
|
||||||
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
|
|
||||||
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)?.asImageBitmap()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun readBackdropBytes(id: String): ByteArray? =
|
|
||||||
try {
|
|
||||||
AndroidEnv.context.assets
|
|
||||||
.open("backdrops/$id.jpg")
|
|
||||||
.use { it.readBytes() }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun encodeImageAsBase64(
|
|
||||||
path: String,
|
|
||||||
maxSize: Int,
|
|
||||||
): String? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeFile(path, bounds)
|
BitmapFactory.decodeFile(path, bounds)
|
||||||
@@ -202,8 +161,7 @@ actual suspend fun encodeImageAsBase64(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
|
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||||
@@ -213,10 +171,7 @@ actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MediaPlayerView(
|
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||||
media: MediaItem,
|
|
||||||
modifier: Modifier,
|
|
||||||
) {
|
|
||||||
val path = media.localPath
|
val path = media.localPath
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
@@ -236,10 +191,7 @@ actual fun MediaPlayerView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VideoPlayerView(
|
private fun VideoPlayerView(path: String, modifier: Modifier) {
|
||||||
path: String,
|
|
||||||
modifier: Modifier,
|
|
||||||
) {
|
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
PlayerView(ctx).apply {
|
PlayerView(ctx).apply {
|
||||||
@@ -253,22 +205,16 @@ private fun VideoPlayerView(
|
|||||||
},
|
},
|
||||||
update = {},
|
update = {},
|
||||||
onRelease = { view -> view.player?.release() },
|
onRelease = { view -> view.player?.release() },
|
||||||
modifier =
|
modifier = modifier
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(220.dp),
|
.height(220.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AudioPlayerView(
|
private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) {
|
||||||
path: String,
|
|
||||||
filename: String,
|
|
||||||
modifier: Modifier,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val player =
|
val player = remember {
|
||||||
remember {
|
|
||||||
ExoPlayer.Builder(context).build().apply {
|
ExoPlayer.Builder(context).build().apply {
|
||||||
setMediaItem(M3MediaItem.fromUri(path.toUri()))
|
setMediaItem(M3MediaItem.fromUri(path.toUri()))
|
||||||
prepare()
|
prepare()
|
||||||
@@ -291,8 +237,7 @@ private fun AudioPlayerView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier = modifier
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -325,21 +270,17 @@ private fun fmt(seconds: Float): String {
|
|||||||
return "%d:%02d".format(s / 60, s % 60)
|
return "%d:%02d".format(s / 60, s % 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun openDocument(
|
actual fun openDocument(path: String, mime: String) {
|
||||||
path: String,
|
|
||||||
mime: String,
|
|
||||||
) {
|
|
||||||
val context = AndroidEnv.context
|
val context = AndroidEnv.context
|
||||||
try {
|
try {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
val intent =
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, mime)
|
setDataAndType(uri, mime)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
}
|
}
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Toast.makeText(context, "No app to open $mime", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "No app to open ${mime}", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package iris.platform
|
package iris.platform
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
@@ -13,33 +11,6 @@ actual fun setActiveController(controller: Any?) {
|
|||||||
AppBridge.controller = controller as? IrisController
|
AppBridge.controller = controller as? IrisController
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun syncNtfyListener(backend: String) {
|
|
||||||
val context = AndroidEnv.context
|
|
||||||
val intent = Intent(context, NtfyListenerService::class.java)
|
|
||||||
// L-18: reuse one AndroidSecureStore instead of rebuilding it (and
|
|
||||||
// re-running EncryptedSharedPreferences.create + migration) on every call.
|
|
||||||
val store = ntfyStore(context)
|
|
||||||
if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) {
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
} else {
|
|
||||||
// FCM (or unknown) backend: no persistent listener, no permanent
|
|
||||||
// notification. stopService is a no-op when it isn't running.
|
|
||||||
context.stopService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val ntfyStoreLock = Any()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedNtfyStore: AndroidSecureStore? = null
|
|
||||||
|
|
||||||
private fun ntfyStore(context: Context): AndroidSecureStore {
|
|
||||||
cachedNtfyStore?.let { return it }
|
|
||||||
return synchronized(ntfyStoreLock) {
|
|
||||||
cachedNtfyStore ?: AndroidSecureStore(context).also { cachedNtfyStore = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun postSystemNotification(
|
actual fun postSystemNotification(
|
||||||
chatId: String?,
|
chatId: String?,
|
||||||
chatName: String?,
|
chatName: String?,
|
||||||
@@ -48,7 +19,7 @@ actual fun postSystemNotification(
|
|||||||
threadId: String?,
|
threadId: String?,
|
||||||
) {
|
) {
|
||||||
val context = AndroidEnv.context
|
val context = AndroidEnv.context
|
||||||
val id = chatId ?: "default"
|
val id = chatId ?: "android:default"
|
||||||
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
// POST_NOTIFICATIONS is a runtime permission on API 33+.
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import android.os.Build
|
|||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.ui.theme.Backdrop
|
|
||||||
import iris.ui.theme.BackgroundMode
|
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,17 +15,13 @@ import java.util.UUID
|
|||||||
* values are migrated on first run (read old key, write encrypted, delete
|
* values are migrated on first run (read old key, write encrypted, delete
|
||||||
* old key) so an upgrade never loses the pairing.
|
* old key) so an upgrade never loses the pairing.
|
||||||
*/
|
*/
|
||||||
class AndroidSecureStore(
|
class AndroidSecureStore(context: Context) : SecureStore {
|
||||||
context: Context,
|
|
||||||
) : SecureStore {
|
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
|
private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
private val prefs: SharedPreferences =
|
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
|
||||||
EncryptedSharedPreferences.create(
|
|
||||||
appContext,
|
appContext,
|
||||||
SECURE_PREFS_NAME,
|
SECURE_PREFS_NAME,
|
||||||
MasterKey
|
MasterKey.Builder(appContext)
|
||||||
.Builder(appContext)
|
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build(),
|
.build(),
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
@@ -57,8 +50,7 @@ class AndroidSecureStore(
|
|||||||
}
|
}
|
||||||
if (migrated) editor.apply()
|
if (migrated) editor.apply()
|
||||||
// The plain store must not keep a copy of any value.
|
// The plain store must not keep a copy of any value.
|
||||||
plainPrefs
|
plainPrefs.edit()
|
||||||
.edit()
|
|
||||||
.remove(KEY_URL)
|
.remove(KEY_URL)
|
||||||
.remove(KEY_TOKEN)
|
.remove(KEY_TOKEN)
|
||||||
.remove(KEY_DEVICE_ID)
|
.remove(KEY_DEVICE_ID)
|
||||||
@@ -77,24 +69,11 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
|
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
|
||||||
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
|
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
|
||||||
|
|
||||||
override var deviceToken: String
|
|
||||||
get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty()
|
|
||||||
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply()
|
|
||||||
|
|
||||||
override var pinnedCertFingerprint: String
|
|
||||||
get() = prefs.getString(KEY_PINNED_CERT, "").orEmpty()
|
|
||||||
set(value) = prefs.edit().putString(KEY_PINNED_CERT, value.trim()).apply()
|
|
||||||
|
|
||||||
override val deviceId: String
|
override val deviceId: String
|
||||||
get() {
|
get() {
|
||||||
var id = prefs.getString(KEY_DEVICE_ID, null)
|
var id = prefs.getString(KEY_DEVICE_ID, null)
|
||||||
if (id.isNullOrBlank()) {
|
if (id.isNullOrBlank()) {
|
||||||
id = "dev_" +
|
id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
|
||||||
UUID
|
|
||||||
.randomUUID()
|
|
||||||
.toString()
|
|
||||||
.replace("-", "")
|
|
||||||
.take(16)
|
|
||||||
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
|
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
@@ -119,10 +98,6 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
get() = prefs.getString(KEY_NTFY_SERVER, "").orEmpty()
|
||||||
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
|
set(value) = prefs.edit().putString(KEY_NTFY_SERVER, value).apply()
|
||||||
|
|
||||||
override var pushBackend: String
|
|
||||||
get() = prefs.getString(KEY_PUSH_BACKEND, "").orEmpty()
|
|
||||||
set(value) = prefs.edit().putString(KEY_PUSH_BACKEND, value).apply()
|
|
||||||
|
|
||||||
override var threadsEnabled: Boolean
|
override var threadsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
|
get() = prefs.getBoolean(KEY_THREADS_ENABLED, false)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
|
set(value) = prefs.edit().putBoolean(KEY_THREADS_ENABLED, value).apply()
|
||||||
@@ -135,10 +110,6 @@ class AndroidSecureStore(
|
|||||||
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
|
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
|
||||||
|
|
||||||
override var streamSmoothness: Float
|
|
||||||
get() = prefs.getFloat(KEY_STREAM_SMOOTHNESS, STREAM_SMOOTHNESS_DEFAULT)
|
|
||||||
set(value) = prefs.edit().putFloat(KEY_STREAM_SMOOTHNESS, value).apply()
|
|
||||||
|
|
||||||
override var reasoningAutoCollapse: Boolean
|
override var reasoningAutoCollapse: Boolean
|
||||||
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
|
||||||
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
|
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
|
||||||
@@ -152,11 +123,7 @@ class AndroidSecureStore(
|
|||||||
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
|
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
|
||||||
|
|
||||||
override var backgroundMode: String
|
override var backgroundMode: String
|
||||||
get() =
|
get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" }
|
||||||
prefs
|
|
||||||
.getString(KEY_BACKGROUND_MODE, BackgroundMode.Image.name.lowercase())
|
|
||||||
.orEmpty()
|
|
||||||
.ifBlank { BackgroundMode.Image.name.lowercase() }
|
|
||||||
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
|
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
|
||||||
|
|
||||||
override var backgroundColor: Int
|
override var backgroundColor: Int
|
||||||
@@ -164,54 +131,16 @@ class AndroidSecureStore(
|
|||||||
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
|
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
|
||||||
|
|
||||||
override var backgroundImagePath: String
|
override var backgroundImagePath: String
|
||||||
get() =
|
get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty()
|
||||||
prefs
|
|
||||||
.getString(KEY_BACKGROUND_IMAGE_PATH, Backdrop.DEFAULT.path)
|
|
||||||
.orEmpty()
|
|
||||||
.ifBlank { Backdrop.DEFAULT.path }
|
|
||||||
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
|
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
|
||||||
|
|
||||||
override var fontSizeScale: Float
|
override fun savePairing(url: String, token: String) {
|
||||||
get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f)
|
|
||||||
set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply()
|
|
||||||
|
|
||||||
override var runtimeFooterEnabled: Boolean
|
|
||||||
get() = prefs.getBoolean(KEY_RUNTIME_FOOTER_ENABLED, false)
|
|
||||||
set(value) = prefs.edit().putBoolean(KEY_RUNTIME_FOOTER_ENABLED, value).apply()
|
|
||||||
|
|
||||||
override var runtimeFooterFields: String
|
|
||||||
get() = prefs.getString(KEY_RUNTIME_FOOTER_FIELDS, "").orEmpty()
|
|
||||||
set(value) = prefs.edit().putString(KEY_RUNTIME_FOOTER_FIELDS, value).apply()
|
|
||||||
|
|
||||||
override fun savePairing(
|
|
||||||
url: String,
|
|
||||||
token: String,
|
|
||||||
) {
|
|
||||||
serverUrl = url
|
serverUrl = url
|
||||||
this.token = token
|
this.token = token
|
||||||
// A (re-)pair may target a different gateway: the old per-device
|
|
||||||
// token is dead there. The next hello.ack re-mints/returns it.
|
|
||||||
deviceToken = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clear() {
|
override fun clear() {
|
||||||
// M-10: clearing pairing must also wipe the device identity + push
|
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
|
||||||
// state, otherwise a re-pair to a different gateway would keep the old
|
|
||||||
// deviceId/syncCursor/ntfyTopic and the server would treat the new
|
|
||||||
// pairing as the same device.
|
|
||||||
prefs
|
|
||||||
.edit()
|
|
||||||
.remove(KEY_URL)
|
|
||||||
.remove(KEY_TOKEN)
|
|
||||||
.remove(KEY_DEVICE_TOKEN)
|
|
||||||
.remove(KEY_DEVICE_ID)
|
|
||||||
.remove(KEY_SYNC_CURSOR)
|
|
||||||
.remove(KEY_FCM_TOKEN)
|
|
||||||
.remove(KEY_NTFY_TOPIC)
|
|
||||||
.remove(KEY_NTFY_SERVER)
|
|
||||||
.remove(KEY_PUSH_BACKEND)
|
|
||||||
.remove(KEY_PINNED_CERT)
|
|
||||||
.apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -219,26 +148,19 @@ class AndroidSecureStore(
|
|||||||
const val SECURE_PREFS_NAME = "iris_secure"
|
const val SECURE_PREFS_NAME = "iris_secure"
|
||||||
const val KEY_URL = "server_url"
|
const val KEY_URL = "server_url"
|
||||||
const val KEY_TOKEN = "token"
|
const val KEY_TOKEN = "token"
|
||||||
const val KEY_DEVICE_TOKEN = "device_token"
|
|
||||||
const val KEY_DEVICE_ID = "device_id"
|
const val KEY_DEVICE_ID = "device_id"
|
||||||
const val KEY_SYNC_CURSOR = "sync_cursor"
|
const val KEY_SYNC_CURSOR = "sync_cursor"
|
||||||
const val KEY_FCM_TOKEN = "fcm_token"
|
const val KEY_FCM_TOKEN = "fcm_token"
|
||||||
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
const val KEY_NTFY_TOPIC = "ntfy_topic"
|
||||||
const val KEY_NTFY_SERVER = "ntfy_server"
|
const val KEY_NTFY_SERVER = "ntfy_server"
|
||||||
const val KEY_PUSH_BACKEND = "push_backend"
|
|
||||||
const val KEY_PINNED_CERT = "pinned_cert_fingerprint"
|
|
||||||
const val KEY_THREADS_ENABLED = "threads_enabled"
|
const val KEY_THREADS_ENABLED = "threads_enabled"
|
||||||
const val KEY_TOOL_DETAIL = "tool_detail"
|
const val KEY_TOOL_DETAIL = "tool_detail"
|
||||||
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
const val KEY_STREAMING_ENABLED = "streaming_enabled"
|
||||||
const val KEY_STREAM_SMOOTHNESS = "stream_smoothness"
|
|
||||||
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
|
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
|
||||||
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
|
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
|
||||||
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
|
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
|
||||||
const val KEY_BACKGROUND_MODE = "background_mode"
|
const val KEY_BACKGROUND_MODE = "background_mode"
|
||||||
const val KEY_BACKGROUND_COLOR = "background_color"
|
const val KEY_BACKGROUND_COLOR = "background_color"
|
||||||
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
|
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
|
||||||
const val KEY_FONT_SIZE_SCALE = "font_size_scale"
|
|
||||||
const val KEY_RUNTIME_FOOTER_ENABLED = "runtime_footer_enabled"
|
|
||||||
const val KEY_RUNTIME_FOOTER_FIELDS = "runtime_footer_fields"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package iris.platform
|
|
||||||
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
|
||||||
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
|
|
||||||
import iris.db.IrisDatabase
|
|
||||||
|
|
||||||
actual fun appDataDir(): String = AndroidEnv.context.filesDir.absolutePath
|
|
||||||
|
|
||||||
// The schema-aware driver creates the schema on first run and applies
|
|
||||||
// pending .sqm migrations on existing DBs (e.g. v1 -> v2: the `tool`
|
|
||||||
// table), stamping PRAGMA user_version along the way.
|
|
||||||
actual fun createCacheDriver(): SqlDriver = AndroidSqliteDriver(IrisDatabase.Schema, AndroidEnv.context, "iris_cache.db")
|
|
||||||
@@ -10,24 +10,12 @@ import com.multiplatform.webview.web.WebView
|
|||||||
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M9: HTML artifact preview on Android — the platform WebView.
|
* M9: HTML artifact preview on Android — the platform WebView (JavaScript
|
||||||
*
|
* enabled by default in the library's WebSettings).
|
||||||
* 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
|
@Composable
|
||||||
actual fun PlatformWebView(
|
actual fun PlatformWebView(html: String, modifier: Modifier) {
|
||||||
html: String,
|
val state = rememberWebViewStateWithHTMLData(data = html)
|
||||||
modifier: Modifier,
|
|
||||||
) {
|
|
||||||
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
|
|
||||||
state.webSettings.androidWebSettings.domStorageEnabled = true
|
|
||||||
WebView(state, modifier = modifier)
|
WebView(state, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,18 +24,16 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
|
|||||||
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||||
val currentOnBack = rememberUpdatedState(onBack)
|
val currentOnBack = rememberUpdatedState(onBack)
|
||||||
DisposableEffect(dispatcher) {
|
DisposableEffect(dispatcher) {
|
||||||
val callback =
|
if (dispatcher != null) {
|
||||||
dispatcher?.let { d ->
|
val callback = object : OnBackPressedCallback(true) {
|
||||||
val c =
|
|
||||||
object : OnBackPressedCallback(true) {
|
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
currentOnBack.value()
|
currentOnBack.value()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.addCallback(c)
|
dispatcher.addCallback(callback)
|
||||||
c
|
onDispose { callback.remove() }
|
||||||
}
|
}
|
||||||
onDispose { callback?.remove() }
|
onDispose { }
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
@@ -14,14 +14,7 @@ object AppBridge {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var controller: IrisController? = null
|
var controller: IrisController? = null
|
||||||
|
|
||||||
/** True while the launcher activity is resumed (set by MainActivity).
|
/** True while the launcher activity is resumed (set by MainActivity). */
|
||||||
* A change is forwarded to the controller (M8: unread clear on focus). */
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var foreground: Boolean = false
|
var foreground: Boolean = false
|
||||||
set(value) {
|
|
||||||
if (field != value) {
|
|
||||||
field = value
|
|
||||||
controller?.setForeground(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -4,59 +4,39 @@ import android.content.pm.PackageManager
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
import com.google.firebase.messaging.RemoteMessage
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
import iris.net.GatewayClient
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M5: FCM handler (docs/08 §8.1).
|
* M5: FCM handler (docs/08 §8.1).
|
||||||
*
|
*
|
||||||
* - [onNewToken]: persist the rotated token and push it to the server via
|
* - [onNewToken]: persist the rotated token and push it to the server via
|
||||||
* `fcm.register` (so the next push targets the current token).
|
* `fcm.register` (so the next push targets the current token).
|
||||||
* - [onMessageReceived]: posts a system notification from the data payload.
|
* - [onMessageReceived]: the data payload drives a silent sync. When the app
|
||||||
* When the app is foregrounded the SSE path already delivered the frame
|
* is foregrounded the WS path already delivered the frame (in-app banner),
|
||||||
* (in-app banner), so we only post a notification when backgrounded.
|
* so we only post a system notification when backgrounded.
|
||||||
*
|
*
|
||||||
* Inert without a Firebase project (no google-services.json): the service is
|
* Inert without a Firebase project (no google-services.json): the service is
|
||||||
* declared in the manifest but never receives messages, and the app falls
|
* declared in the manifest but never receives messages, and the app falls
|
||||||
* back to the ntfy listener.
|
* back to the ntfy listener.
|
||||||
*/
|
*/
|
||||||
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
override fun onNewToken(token: String) {
|
override fun onNewToken(token: String) {
|
||||||
val store = AndroidSecureStore(applicationContext)
|
val store = AndroidSecureStore(applicationContext)
|
||||||
store.fcmToken = token
|
store.fcmToken = token
|
||||||
// Push the rotation to the server if we're connected (the ntfy topic
|
// Push the rotation to the server if we're connected.
|
||||||
// rides along so a wiped registry recovers both push tokens).
|
|
||||||
AppBridge.controller?.client?.sendFrame(
|
AppBridge.controller?.client?.sendFrame(
|
||||||
iris.protocol.fcmRegisterFrame(
|
iris.protocol.fcmRegisterFrame(fcmToken = token),
|
||||||
fcmToken = token,
|
|
||||||
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
// Foreground + live SSE: the in-app banner already showed this.
|
|
||||||
if (AppBridge.foreground) return
|
|
||||||
// Live SSE: the frame arrives over the stream and the controller
|
|
||||||
// mirrors it to a system notification itself — posting here would
|
|
||||||
// duplicate it (docs/08 §8.7).
|
|
||||||
if (AppBridge.controller
|
|
||||||
?.client
|
|
||||||
?.state
|
|
||||||
?.value is GatewayClient.State.Connected
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Backgrounded/killed: FCM already displayed the `notification`
|
|
||||||
// payload on our behalf (the data payload only carries sync
|
|
||||||
// metadata). Posting again would show a second notification with a
|
|
||||||
// different id. Data-only messages (no notification payload) are the
|
|
||||||
// exception: the app must display them itself.
|
|
||||||
if (message.notification != null) return
|
|
||||||
val data = message.data
|
val data = message.data
|
||||||
val chatId = data["chat_id"] ?: "default"
|
val chatId = data["chat_id"] ?: "android:default"
|
||||||
val threadId = data["thread_id"]
|
val threadId = data["thread_id"]
|
||||||
val title = data["title"] ?: "Iris"
|
val title = data["title"] ?: "Iris"
|
||||||
val body = data["body"] ?: data["title"].orEmpty()
|
val body = data["body"] ?: data["title"].orEmpty()
|
||||||
|
// Foreground + live WS: the in-app banner already showed this.
|
||||||
|
if (AppBridge.foreground) return
|
||||||
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
|
if (ContextCompat.checkSelfPermission(applicationContext, android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,22 +21,15 @@ object IrisNotifications {
|
|||||||
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
|
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
|
||||||
private const val NOTIF_ID_BASE = 1_000_000
|
private const val NOTIF_ID_BASE = 1_000_000
|
||||||
|
|
||||||
// L-31: notification channel ids are capped at 64 chars (Android limit) and
|
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
|
||||||
// are user-visible, so a long server-provided chatId must be truncated.
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
private fun channelIdFor(chatId: String): String = (CHANNEL_PREFIX + chatId).take(64)
|
|
||||||
|
|
||||||
fun ensureChannel(
|
|
||||||
context: Context,
|
|
||||||
chatId: String,
|
|
||||||
chatName: String? = null,
|
|
||||||
) {
|
|
||||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
val id = channelIdFor(chatId)
|
val id = CHANNEL_PREFIX + chatId
|
||||||
val name = chatName ?: chatId
|
val name = chatName ?: chatId
|
||||||
if (nm.getNotificationChannel(id) == null) {
|
if (nm.getNotificationChannel(id) == null) {
|
||||||
nm.createNotificationChannel(
|
nm.createNotificationChannel(
|
||||||
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
|
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
.apply { description = "Iris messages for $name" },
|
.apply { description = "Iris messages for $name" }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,18 +43,14 @@ object IrisNotifications {
|
|||||||
threadId: String?,
|
threadId: String?,
|
||||||
) {
|
) {
|
||||||
ensureChannel(context, chatId, chatName)
|
ensureChannel(context, chatId, chatName)
|
||||||
// L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
|
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
|
||||||
// to the same notification id and clobber each other.
|
val intent = Intent(ACTION_OPEN_CHAT).apply {
|
||||||
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff)
|
|
||||||
val intent =
|
|
||||||
Intent(ACTION_OPEN_CHAT).apply {
|
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
putExtra("chat_id", chatId)
|
putExtra("chat_id", chatId)
|
||||||
if (threadId != null) putExtra("thread_id", threadId)
|
if (threadId != null) putExtra("thread_id", threadId)
|
||||||
}
|
}
|
||||||
val pi =
|
val pi = PendingIntent.getActivity(
|
||||||
PendingIntent.getActivity(
|
|
||||||
context,
|
context,
|
||||||
id,
|
id,
|
||||||
intent,
|
intent,
|
||||||
@@ -69,8 +59,7 @@ object IrisNotifications {
|
|||||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
nm.notify(
|
nm.notify(
|
||||||
id,
|
id,
|
||||||
NotificationCompat
|
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
|
||||||
.Builder(context, channelIdFor(chatId))
|
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ import android.os.IBinder
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import iris.protocol.IrisJson
|
import iris.protocol.IrisJson
|
||||||
import iris.util.IrisLog
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
@@ -30,31 +28,18 @@ import java.util.concurrent.TimeUnit
|
|||||||
*
|
*
|
||||||
* A foreground service that subscribes to this device's ntfy topic and posts
|
* A foreground service that subscribes to this device's ntfy topic and posts
|
||||||
* a system notification for each push. The structured payload rides in the
|
* a system notification for each push. The structured payload rides in the
|
||||||
* `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
|
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
|
||||||
* body is the short preview. When the app is foregrounded the SSE path
|
* is the short preview. When the app is foregrounded the WS path already
|
||||||
* already delivered the frame, so the service skips posting to avoid a
|
* delivered the frame, so the service skips posting to avoid a duplicate.
|
||||||
* duplicate.
|
|
||||||
*
|
|
||||||
* The stream is reconnected with capped exponential backoff when it drops
|
|
||||||
* (EOF, network error, or a non-2xx response) — `START_STICKY` alone only
|
|
||||||
* restarts the service after process death, not after a failed read.
|
|
||||||
*/
|
*/
|
||||||
class NtfyListenerService : Service() {
|
class NtfyListenerService : Service() {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var streamJob: Job? = null
|
private var streamJob: Job? = null
|
||||||
private val client =
|
private val client = OkHttpClient.Builder()
|
||||||
OkHttpClient
|
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
|
||||||
.Builder()
|
|
||||||
// ntfy sends keep-alive comments every ~10 s; a 60 s read timeout
|
|
||||||
// detects a half-open connection instead of hanging forever.
|
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun onStartCommand(
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
intent: Intent?,
|
|
||||||
flags: Int,
|
|
||||||
startId: Int,
|
|
||||||
): Int {
|
|
||||||
startForeground(NOTIF_ID, foregroundNotification())
|
startForeground(NOTIF_ID, foregroundNotification())
|
||||||
streamJob?.cancel()
|
streamJob?.cancel()
|
||||||
streamJob = scope.launch { stream() }
|
streamJob = scope.launch { stream() }
|
||||||
@@ -75,56 +60,24 @@ class NtfyListenerService : Service() {
|
|||||||
if (topic.isBlank()) return
|
if (topic.isBlank()) return
|
||||||
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
|
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
|
||||||
val url = "$server/$topic"
|
val url = "$server/$topic"
|
||||||
val request =
|
val request = Request.Builder()
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
.build()
|
.build()
|
||||||
var backoff = 1_000L
|
|
||||||
while (true) {
|
|
||||||
try {
|
try {
|
||||||
client.newCall(request).execute().use { resp ->
|
client.newCall(request).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) return
|
||||||
IrisLog.w("ntfy stream HTTP ${resp.code}")
|
|
||||||
} else {
|
|
||||||
val body = resp.body ?: return
|
val body = resp.body ?: return
|
||||||
readEvents(body.source())
|
val source = body.source()
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
IrisLog.w("ntfy stream dropped: ${e.message}")
|
|
||||||
}
|
|
||||||
// Stream ended (EOF, error, or non-2xx): back off and reconnect.
|
|
||||||
delay(backoff)
|
|
||||||
backoff = (backoff * 2).coerceAtMost(30_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF;
|
|
||||||
* do NOT use `source.exhausted()` here — it reads until EOF and would
|
|
||||||
* block forever on a live stream.
|
|
||||||
*/
|
|
||||||
private fun readEvents(source: okio.BufferedSource) {
|
|
||||||
var data: String? = null
|
var data: String? = null
|
||||||
var title: String? = null
|
var title: String? = null
|
||||||
var msgBody: String? = null
|
var msgBody: String? = null
|
||||||
while (true) {
|
while (!source.exhausted()) {
|
||||||
val line = source.readUtf8Line() ?: break
|
val line = source.readUtf8Line() ?: break
|
||||||
when {
|
when {
|
||||||
line.startsWith("X-Data:") -> {
|
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
|
||||||
data = line.removePrefix("X-Data:").trim()
|
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
|
||||||
}
|
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
|
||||||
|
|
||||||
line.startsWith("X-Title:") -> {
|
|
||||||
title = line.removePrefix("X-Title:").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith("data:") -> {
|
|
||||||
msgBody = line.removePrefix("data:").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
line.isEmpty() -> {
|
line.isEmpty() -> {
|
||||||
// Event boundary: process the accumulated message.
|
// Event boundary: process the accumulated message.
|
||||||
data?.let { handleData(it, title, msgBody) }
|
data?.let { handleData(it, title, msgBody) }
|
||||||
@@ -135,19 +88,19 @@ class NtfyListenerService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Stream dropped; the service is START_STICKY so the system
|
||||||
|
// restarts it. If it keeps failing, the WS path still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleData(
|
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
|
||||||
dataJson: String,
|
val data = try {
|
||||||
title: String?,
|
|
||||||
msgBody: String?,
|
|
||||||
) {
|
|
||||||
val data =
|
|
||||||
try {
|
|
||||||
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val chatId = data?.str("chat_id") ?: "default"
|
val chatId = data?.str("chat_id") ?: "android:default"
|
||||||
val threadId = data?.str("thread_id")
|
val threadId = data?.str("thread_id")
|
||||||
// The short preview rides in the SSE `data:` field; fall back to the
|
// The short preview rides in the SSE `data:` field; fall back to the
|
||||||
// X-Title, then a generic label.
|
// X-Title, then a generic label.
|
||||||
@@ -173,12 +126,11 @@ class NtfyListenerService : Service() {
|
|||||||
LISTENER_CHANNEL,
|
LISTENER_CHANNEL,
|
||||||
"Iris push listener",
|
"Iris push listener",
|
||||||
NotificationManager.IMPORTANCE_MIN,
|
NotificationManager.IMPORTANCE_MIN,
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NotificationCompat
|
return NotificationCompat.Builder(context, LISTENER_CHANNEL)
|
||||||
.Builder(context, LISTENER_CHANNEL)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle("Iris")
|
.setContentTitle("Iris")
|
||||||
.setContentText("Listening for messages")
|
.setContentText("Listening for messages")
|
||||||
@@ -194,4 +146,5 @@ class NtfyListenerService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Read a string field from a JSON object (null when absent / not a string). */
|
/** Read a string field from a JSON object (null when absent / not a string). */
|
||||||
private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
|
private fun JsonObject?.str(key: String): String? =
|
||||||
|
(this?.get(key) as? JsonPrimitive)?.content
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package iris.platform
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Android QR scanner (docs/20): launches [QrScanActivity] and returns the
|
|
||||||
* scanned text (or null on cancel) to [onResult].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val launcher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult(),
|
|
||||||
) { result ->
|
|
||||||
val text =
|
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
|
||||||
result.data?.getStringExtra(QrScanActivity.EXTRA_QR)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
onResult(text)
|
|
||||||
}
|
|
||||||
Button(onClick = {
|
|
||||||
val activity = context.resolveActivity()
|
|
||||||
if (activity != null) {
|
|
||||||
launcher.launch(Intent(activity, QrScanActivity::class.java))
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Text("Scan QR")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Walk a (possibly wrapped) context to the hosting [Activity], if any. */
|
|
||||||
private fun Context.resolveActivity(): Activity? =
|
|
||||||
when (this) {
|
|
||||||
is Activity -> this
|
|
||||||
is ContextWrapper -> baseContext.resolveActivity()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package iris.platform
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.addCallback
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.camera.core.CameraSelector
|
|
||||||
import androidx.camera.core.ImageAnalysis
|
|
||||||
import androidx.camera.core.ImageProxy
|
|
||||||
import androidx.camera.core.Preview
|
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
||||||
import androidx.camera.view.PreviewView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
|
||||||
import com.google.mlkit.vision.common.InputImage
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full-screen QR scanner (docs/20). Launched from the Connect screen's
|
|
||||||
* "Scan QR" button; returns the raw QR text via [EXTRA_QR] on
|
|
||||||
* [Activity.RESULT_OK], or [Activity.RESULT_CANCELED] on back/cancel.
|
|
||||||
*
|
|
||||||
* Uses the camera2 CameraX backend + ML Kit's barcode model. The camera is
|
|
||||||
* stopped in [onDestroy].
|
|
||||||
*/
|
|
||||||
class QrScanActivity : ComponentActivity() {
|
|
||||||
companion object {
|
|
||||||
/** Intent extra carrying the scanned QR text. */
|
|
||||||
const val EXTRA_QR = "qr"
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var previewView: PreviewView
|
|
||||||
private var cameraProvider: ProcessCameraProvider? = null
|
|
||||||
private var settled = false
|
|
||||||
|
|
||||||
private val permissionLauncher =
|
|
||||||
registerForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted ->
|
|
||||||
if (granted) startCamera() else finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
previewView =
|
|
||||||
PreviewView(this).apply {
|
|
||||||
layoutParams =
|
|
||||||
ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setContentView(previewView)
|
|
||||||
|
|
||||||
onBackPressedDispatcher.addCallback(this) {
|
|
||||||
if (!settled) setResult(Activity.RESULT_CANCELED)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
|
||||||
== PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
startCamera()
|
|
||||||
} else {
|
|
||||||
permissionLauncher.launch(Manifest.permission.CAMERA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startCamera() {
|
|
||||||
val future = ProcessCameraProvider.getInstance(this)
|
|
||||||
future.addListener(
|
|
||||||
{
|
|
||||||
val provider = future.get()
|
|
||||||
cameraProvider = provider
|
|
||||||
val preview =
|
|
||||||
Preview.Builder().build().also {
|
|
||||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
|
||||||
}
|
|
||||||
val analyzer =
|
|
||||||
ImageAnalysis
|
|
||||||
.Builder()
|
|
||||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
provider.unbindAll()
|
|
||||||
provider.bindToLifecycle(
|
|
||||||
this,
|
|
||||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
|
||||||
preview,
|
|
||||||
analyzer,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// No usable camera (e.g. headless emulator): bail out.
|
|
||||||
finish()
|
|
||||||
return@addListener
|
|
||||||
}
|
|
||||||
analyzer.setAnalyzer(ContextCompat.getMainExecutor(this)) { proxy ->
|
|
||||||
analyzeImage(proxy)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ContextCompat.getMainExecutor(this),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun analyzeImage(proxy: ImageProxy) {
|
|
||||||
val mediaImage = proxy.image
|
|
||||||
if (mediaImage == null) {
|
|
||||||
proxy.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val inputImage = InputImage.fromMediaImage(mediaImage, proxy.imageInfo.rotationDegrees)
|
|
||||||
BarcodeScanning
|
|
||||||
.getClient()
|
|
||||||
.process(inputImage)
|
|
||||||
.addOnSuccessListener { barcodes ->
|
|
||||||
val text = barcodes.firstOrNull { it.rawValue != null }?.rawValue
|
|
||||||
if (text != null) finishWithResult(text)
|
|
||||||
}.addOnCompleteListener { proxy.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun finishWithResult(text: String) {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_QR, text))
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
cameraProvider?.unbindAll()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,12 +10,9 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.Density
|
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.net.GatewayClient
|
import iris.net.GatewayClient
|
||||||
import iris.platform.setActiveController
|
import iris.platform.setActiveController
|
||||||
@@ -27,21 +24,19 @@ import iris.ui.theme.IrisColors
|
|||||||
import iris.ui.theme.IrisTheme
|
import iris.ui.theme.IrisTheme
|
||||||
import iris.ui.theme.LocalUserTheme
|
import iris.ui.theme.LocalUserTheme
|
||||||
import iris.ui.theme.rememberBackgroundImage
|
import iris.ui.theme.rememberBackgroundImage
|
||||||
import iris.util.PairLink
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root composable shared by the Android and Desktop shells.
|
* Root composable shared by the Android and Desktop shells.
|
||||||
*
|
*
|
||||||
* Routes between the Connect screen (unpaired / auth failed) and the main
|
* M1: routes between the Connect screen (unpaired / auth failed) and the
|
||||||
* app (paired), which hosts the channel list, chat, search, settings, and
|
* Chat screen (paired). Later milestones add the channel list, search,
|
||||||
* media (docs/10-android-app.md).
|
* settings, and media (docs/10-android-app.md).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun IrisApp(
|
fun IrisApp(
|
||||||
store: SecureStore,
|
store: SecureStore,
|
||||||
deepLinkChatId: String? = null,
|
deepLinkChatId: String? = null,
|
||||||
deepLinkThreadId: String? = null,
|
deepLinkThreadId: String? = null,
|
||||||
deepLinkPair: PairLink? = null,
|
|
||||||
) {
|
) {
|
||||||
val controller = remember(store) { IrisController(store) }
|
val controller = remember(store) { IrisController(store) }
|
||||||
DisposableEffect(controller) {
|
DisposableEffect(controller) {
|
||||||
@@ -60,21 +55,9 @@ fun IrisApp(
|
|||||||
}
|
}
|
||||||
val state by controller.client.state.collectAsState()
|
val state by controller.client.state.collectAsState()
|
||||||
val theme by controller.theme.collectAsState()
|
val theme by controller.theme.collectAsState()
|
||||||
val fontScale by controller.fontSizeScale.collectAsState()
|
|
||||||
val baseDensity = LocalDensity.current
|
|
||||||
|
|
||||||
IrisTheme {
|
IrisTheme {
|
||||||
// Font-size setting (Settings → Appearance): a multiplier on top of the
|
CompositionLocalProvider(LocalUserTheme provides theme) {
|
||||||
// system font scale. fontScale affects sp text only — dp layout is
|
|
||||||
// untouched, so the UI keeps its proportions at any size.
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalUserTheme provides theme,
|
|
||||||
LocalDensity provides
|
|
||||||
Density(
|
|
||||||
density = baseDensity.density,
|
|
||||||
fontScale = baseDensity.fontScale * fontScale,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
// The color goes through Surface's `color` parameter: a
|
// The color goes through Surface's `color` parameter: a
|
||||||
// Modifier.background on the Surface would be painted *under* the
|
// Modifier.background on the Surface would be painted *under* the
|
||||||
// Surface's own colorScheme.surface fill and never show through.
|
// Surface's own colorScheme.surface fill and never show through.
|
||||||
@@ -99,49 +82,20 @@ fun IrisApp(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val s = state
|
val s = state
|
||||||
// QR pairing (docs/20): an iris://pair deep link prefills
|
|
||||||
// the Connect screen, taking priority over stored creds.
|
|
||||||
val pairUrl = deepLinkPair?.url ?: store.serverUrl
|
|
||||||
val pairToken = deepLinkPair?.token ?: store.token
|
|
||||||
when (s) {
|
when (s) {
|
||||||
GatewayClient.State.Disconnected -> {
|
GatewayClient.State.Disconnected ->
|
||||||
key(deepLinkPair) {
|
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
|
||||||
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
|
is GatewayClient.State.AuthFailed ->
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is GatewayClient.State.AuthFailed -> {
|
|
||||||
key(deepLinkPair) {
|
|
||||||
ConnectScreen(
|
ConnectScreen(
|
||||||
controller,
|
controller,
|
||||||
prefillUrl = pairUrl,
|
prefillUrl = store.serverUrl,
|
||||||
prefillToken = pairToken,
|
prefillToken = store.token,
|
||||||
initialError = "Pairing rejected: ${s.message}",
|
initialError = "Pairing rejected: ${s.message}",
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLS: the gateway's certificate is untrusted and not
|
|
||||||
// the pinned one (docs/09 §9.4) — ask the user to
|
|
||||||
// confirm its fingerprint (SSH host-key style).
|
|
||||||
is GatewayClient.State.TlsConfirmRequired -> {
|
|
||||||
key(deepLinkPair) {
|
|
||||||
ConnectScreen(
|
|
||||||
controller,
|
|
||||||
prefillUrl = pairUrl,
|
|
||||||
prefillToken = pairToken,
|
|
||||||
initialError = "Gateway certificate needs confirmation — verify its fingerprint on the gateway host, then confirm below.",
|
|
||||||
tlsFingerprint = s.fingerprint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connecting / Reconnecting / Connected all render the chat; the header
|
// Connecting / Reconnecting / Connected all render the chat; the header
|
||||||
// status bubble + connection banner show the link state
|
// status bubble + connection banner show the link state
|
||||||
// without blocking the view (M7's full-screen spinner is gone).
|
// without blocking the view (M7's full-screen spinner is gone).
|
||||||
else -> {
|
else -> ChatScreen(controller)
|
||||||
ChatScreen(controller)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// M9: full-screen HTML artifact preview (opened from an
|
// M9: full-screen HTML artifact preview (opened from an
|
||||||
// artifact card in the chat); covers everything while open.
|
// artifact card in the chat); covers everything while open.
|
||||||
|
|||||||
@@ -26,32 +26,15 @@ class ChannelStore {
|
|||||||
_channels.value = sorted(channels)
|
_channels.value = sorted(channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restore the directory from the persistent local cache on startup
|
|
||||||
* (offline: the drawer is populated before the gateway connection is
|
|
||||||
* up). The server re-seeds it on connect (`hello.ack`). No-op when
|
|
||||||
* [channels] is empty (first launch). */
|
|
||||||
fun loadFromCache(channels: List<ChannelInfo>) {
|
|
||||||
if (channels.isNotEmpty()) _channels.value = sorted(channels)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reconcile a server frame into the cache. */
|
/** Reconcile a server frame into the cache. */
|
||||||
fun onFrame(frame: Frame) {
|
fun onFrame(frame: Frame) {
|
||||||
when (frame.type) {
|
when (frame.type) {
|
||||||
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> {
|
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame)
|
||||||
upsert(frame)
|
TYPE_CHANNEL_DELETED -> remove(frame)
|
||||||
}
|
|
||||||
|
|
||||||
TYPE_CHANNEL_DELETED -> {
|
|
||||||
remove(frame)
|
|
||||||
}
|
|
||||||
|
|
||||||
TYPE_CHANNEL_LIST -> {
|
TYPE_CHANNEL_LIST -> {
|
||||||
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
|
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
|
||||||
}
|
}
|
||||||
|
else -> Unit
|
||||||
else -> {
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,58 +48,7 @@ class ChannelStore {
|
|||||||
|
|
||||||
private fun remove(frame: Frame) {
|
private fun remove(frame: Frame) {
|
||||||
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
|
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
|
||||||
// A channel delete also removes its threads (child entries); a thread
|
_channels.value = _channels.value.filter { it.chatId != p.chatId }
|
||||||
// delete removes just that thread. Both are hard deletes server-side.
|
|
||||||
_channels.value =
|
|
||||||
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Optimistic local updates (M-7) ────────────────────────────────────
|
|
||||||
//
|
|
||||||
// The gateway only broadcasts channel.created/renamed/deleted — there are
|
|
||||||
// no favorite/icon/automation/default events. So a toggle sent by THIS
|
|
||||||
// device would not update its own UI until a full channel.list re-fetch.
|
|
||||||
// These apply the change locally (optimistically); a later channel.list /
|
|
||||||
// hello.ack re-seed reconciles any divergence (e.g. a server rejection).
|
|
||||||
|
|
||||||
/** Toggle the cosmetic favorite flag locally. */
|
|
||||||
fun setFavorite(
|
|
||||||
chatId: String,
|
|
||||||
on: Boolean,
|
|
||||||
) = update(chatId) { it.copy(favorite = on) }
|
|
||||||
|
|
||||||
/** Toggle the automation flag locally. */
|
|
||||||
fun setAutomation(
|
|
||||||
chatId: String,
|
|
||||||
on: Boolean,
|
|
||||||
) = update(chatId) { it.copy(automation = on) }
|
|
||||||
|
|
||||||
/** Set the icon (base64) and/or avatar color locally; null clears a field. */
|
|
||||||
fun setIcon(
|
|
||||||
chatId: String,
|
|
||||||
icon: String?,
|
|
||||||
color: String?,
|
|
||||||
) = update(chatId) { it.copy(icon = icon, color = color) }
|
|
||||||
|
|
||||||
/** Make [chatId] the default channel locally (clearing the previous one). */
|
|
||||||
fun setDefault(chatId: String) {
|
|
||||||
_channels.value =
|
|
||||||
sorted(
|
|
||||||
_channels.value.map {
|
|
||||||
when {
|
|
||||||
it.chatId == chatId -> it.copy(isDefault = true)
|
|
||||||
it.isDefault -> it.copy(isDefault = false)
|
|
||||||
else -> it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun update(
|
|
||||||
chatId: String,
|
|
||||||
transform: (ChannelInfo) -> ChannelInfo,
|
|
||||||
) {
|
|
||||||
_channels.value = sorted(_channels.value.map { if (it.chatId == chatId) transform(it) else it })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
||||||
@@ -130,14 +62,18 @@ class ChannelStore {
|
|||||||
// ── UI helpers ────────────────────────────────────────────────────────
|
// ── UI helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Channels (default + user channels) for the drawer/rail. */
|
/** Channels (default + user channels) for the drawer/rail. */
|
||||||
fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" }
|
fun channelsForDrawer(): List<ChannelInfo> =
|
||||||
|
_channels.value.filter { it.kind != "thread" }
|
||||||
|
|
||||||
/** Threads under a channel, for the topic switcher. */
|
/** Threads under a channel, for the topic switcher. */
|
||||||
fun threadsFor(chatId: String): List<ChannelInfo> = _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
|
fun threadsFor(chatId: String): List<ChannelInfo> =
|
||||||
|
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
|
||||||
|
|
||||||
fun byId(chatId: String): ChannelInfo? = _channels.value.firstOrNull { it.chatId == chatId }
|
fun byId(chatId: String): ChannelInfo? =
|
||||||
|
_channels.value.firstOrNull { it.chatId == chatId }
|
||||||
|
|
||||||
fun defaultChannel(): ChannelInfo? = _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
|
fun defaultChannel(): ChannelInfo? =
|
||||||
|
_channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_channels.value = emptyList()
|
_channels.value = emptyList()
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
package iris.data
|
|
||||||
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
|
||||||
import iris.db.IrisDatabase
|
|
||||||
import iris.protocol.ChannelInfo
|
|
||||||
import iris.protocol.IrisJson
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persistent local cache (SQLite via SQLDelight — the locked "Local DB (KMP)"
|
|
||||||
* decision, docs/16; schema docs/10 §10.7). The server is authoritative; this
|
|
||||||
* is a cache that lets the app start instantly and read chats offline:
|
|
||||||
*
|
|
||||||
* - [loadLanes] / [loadChannels] restore the in-memory stores on startup,
|
|
||||||
* before the gateway connection is up (snappy start, offline reading).
|
|
||||||
* - [saveLanes] / [saveChannels] snapshot the in-memory stores (debounced by
|
|
||||||
* the controller) so every reconciled frame survives a process death.
|
|
||||||
* - [metaGet] / [metaPut] hold small UI state (last-viewed lane).
|
|
||||||
*
|
|
||||||
* [MessageItem]s and [ToolItem]s are persisted; live streaming state and
|
|
||||||
* local system notices are ephemeral. Rows are JSON payloads keyed by
|
|
||||||
* (lane, id), so the schema does not drift with the model fields.
|
|
||||||
*
|
|
||||||
* All access is synchronized: the debounced save collectors run on the
|
|
||||||
* controller scope while [dispose] may flush from the UI thread.
|
|
||||||
*/
|
|
||||||
class ChatDb(
|
|
||||||
driver: SqlDriver,
|
|
||||||
) {
|
|
||||||
private val db = IrisDatabase(driver)
|
|
||||||
private val json = IrisJson.instance
|
|
||||||
private val lock = Any()
|
|
||||||
|
|
||||||
// ── messages ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** All persisted lanes (lane key -> items: messages ordered by ts with
|
|
||||||
* tool cards interleaved at their anchored position). */
|
|
||||||
fun loadLanes(): Map<String, List<ChatItem>> =
|
|
||||||
synchronized(lock) {
|
|
||||||
val messages = linkedMapOf<String, MutableList<ChatItem>>()
|
|
||||||
for (row in db.cacheQueries.allMessages().executeAsList()) {
|
|
||||||
val item = decodeMessage(row.payload) ?: continue
|
|
||||||
messages.getOrPut(row.lane) { mutableListOf() }.add(item)
|
|
||||||
}
|
|
||||||
val tools = linkedMapOf<String, MutableList<ToolItem>>()
|
|
||||||
for (row in db.cacheQueries.allTools().executeAsList()) {
|
|
||||||
val item = decodeTool(row.payload) ?: continue
|
|
||||||
tools.getOrPut(row.lane) { mutableListOf() }.add(item)
|
|
||||||
}
|
|
||||||
(messages.keys + tools.keys).distinct().associateWith { lane ->
|
|
||||||
val items: MutableList<ChatItem> = messages[lane].orEmpty().toMutableList()
|
|
||||||
// Insert each tool card after its anchor message (the message
|
|
||||||
// it followed live). Cards sharing an anchor keep their seq
|
|
||||||
// order; a card whose anchor is gone (deleted message) falls
|
|
||||||
// to the end of the lane. The lastPos cache assumes anchors
|
|
||||||
// are monotonically non-decreasing per lane (true for the
|
|
||||||
// onToolStart anchor rule: last non-streaming message) — a
|
|
||||||
// tool anchored to an EARLIER message processed after a
|
|
||||||
// later-anchored one would be misplaced.
|
|
||||||
val lastPos = mutableMapOf<String, Int>()
|
|
||||||
for (tool in tools[lane].orEmpty()) {
|
|
||||||
val anchor = tool.anchorId
|
|
||||||
val pos =
|
|
||||||
if (anchor == null) {
|
|
||||||
-1
|
|
||||||
} else {
|
|
||||||
lastPos.getOrPut(anchor) { items.indexOfFirst { it.id == anchor } }
|
|
||||||
}
|
|
||||||
if (pos >= 0) {
|
|
||||||
items.add(pos + 1, tool)
|
|
||||||
if (anchor != null) lastPos[anchor] = pos + 1
|
|
||||||
} else {
|
|
||||||
items.add(tool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the whole message + tool cache with [lanes] (atomic snapshot).
|
|
||||||
* Local system notices are skipped (ephemeral). */
|
|
||||||
fun saveLanes(lanes: Map<String, List<ChatItem>>) {
|
|
||||||
synchronized(lock) {
|
|
||||||
db.transaction {
|
|
||||||
db.cacheQueries.clearMessages()
|
|
||||||
db.cacheQueries.clearTools()
|
|
||||||
for ((lane, items) in lanes) {
|
|
||||||
var toolSeq = 0
|
|
||||||
for (item in items) {
|
|
||||||
when (item) {
|
|
||||||
is MessageItem -> {
|
|
||||||
if (!item.isSystem) {
|
|
||||||
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ToolItem -> {
|
|
||||||
db.cacheQueries.upsertTool(lane, item.id, toolSeq++.toLong(), json.encodeToString(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Picker cards ride in the message table (JSON
|
|
||||||
// payload; decodeMessage picks the type back out).
|
|
||||||
is PickerItem -> {
|
|
||||||
db.cacheQueries.upsertMessage(lane, item.id, item.ts, json.encodeToString(item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── channels ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** The persisted channel directory (channels + threads). */
|
|
||||||
fun loadChannels(): List<ChannelInfo> =
|
|
||||||
synchronized(lock) {
|
|
||||||
val list = mutableListOf<ChannelInfo>()
|
|
||||||
for (row in db.cacheQueries.allChannels().executeAsList()) {
|
|
||||||
try {
|
|
||||||
list.add(json.decodeFromString(row.payload))
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Corrupt row — skip it; the server re-seeds on connect.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
list
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Replace the whole channel directory (atomic snapshot). */
|
|
||||||
fun saveChannels(channels: List<ChannelInfo>) {
|
|
||||||
synchronized(lock) {
|
|
||||||
db.transaction {
|
|
||||||
db.cacheQueries.clearChannels()
|
|
||||||
for (c in channels) {
|
|
||||||
db.cacheQueries.upsertChannel(c.chatId, json.encodeToString(c))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── meta ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun metaGet(key: String): String? =
|
|
||||||
synchronized(lock) {
|
|
||||||
db.cacheQueries.metaGet(key).executeAsOneOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun metaPut(
|
|
||||||
key: String,
|
|
||||||
value: String,
|
|
||||||
) {
|
|
||||||
synchronized(lock) {
|
|
||||||
db.cacheQueries.metaPut(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wipe the whole cache (forget pairing → a different gateway). */
|
|
||||||
fun clearAll() {
|
|
||||||
synchronized(lock) {
|
|
||||||
db.transaction {
|
|
||||||
db.cacheQueries.clearMessages()
|
|
||||||
db.cacheQueries.clearTools()
|
|
||||||
db.cacheQueries.clearChannels()
|
|
||||||
db.cacheQueries.clearMeta()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeMessage(payload: String): ChatItem? =
|
|
||||||
try {
|
|
||||||
json.decodeFromString<MessageItem>(payload).sanitizeForRestore()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Not a message payload — a picker card (MessageItem requires
|
|
||||||
// "role", PickerItem requires "title": the two never cross-decode).
|
|
||||||
try {
|
|
||||||
json.decodeFromString<PickerItem>(payload)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeTool(payload: String): ToolItem? =
|
|
||||||
try {
|
|
||||||
json.decodeFromString<ToolItem>(payload).sanitizeForRestore()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A restored message is never mid-flight: a streaming bubble is
|
|
||||||
* finalized (the sync replay finalizes it for real on reconnect) and a
|
|
||||||
* pending send becomes failed (tap to retry) — the gateway never
|
|
||||||
* acknowledged it before the process died. */
|
|
||||||
private fun MessageItem.sanitizeForRestore(): MessageItem =
|
|
||||||
copy(
|
|
||||||
streaming = false,
|
|
||||||
pending = false,
|
|
||||||
status = if (status == MsgStatus.Pending) MsgStatus.Failed else status,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** A restored tool card is never mid-flight: an open card (the process
|
|
||||||
* died before tool.end) is closed as interrupted, mirroring
|
|
||||||
* [ChatStore.finalizeInterrupted]. */
|
|
||||||
private fun ToolItem.sanitizeForRestore(): ToolItem = if (done) this else copy(done = true, ok = false)
|
|
||||||
}
|
|
||||||
@@ -8,24 +8,17 @@ import iris.protocol.MessagePayload
|
|||||||
import iris.protocol.MessageStartPayload
|
import iris.protocol.MessageStartPayload
|
||||||
import iris.protocol.MessageStopPayload
|
import iris.protocol.MessageStopPayload
|
||||||
import iris.protocol.MessageUpdatePayload
|
import iris.protocol.MessageUpdatePayload
|
||||||
import iris.protocol.PickerChoice
|
|
||||||
import iris.protocol.PickerChoicePayload
|
|
||||||
import iris.protocol.ROLE_ASSISTANT
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
import iris.protocol.ROLE_USER
|
import iris.protocol.ROLE_USER
|
||||||
import iris.protocol.RuntimeMeta
|
|
||||||
import iris.protocol.TYPE_COMMENTARY
|
import iris.protocol.TYPE_COMMENTARY
|
||||||
import iris.protocol.TYPE_MEDIA_OFFER
|
import iris.protocol.TYPE_MEDIA_OFFER
|
||||||
import iris.protocol.TYPE_MESSAGE
|
import iris.protocol.TYPE_MESSAGE
|
||||||
import iris.protocol.TYPE_MESSAGE_START
|
import iris.protocol.TYPE_MESSAGE_START
|
||||||
import iris.protocol.TYPE_MESSAGE_STOP
|
import iris.protocol.TYPE_MESSAGE_STOP
|
||||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||||
import iris.protocol.TYPE_PICKER_CHOICE
|
|
||||||
import iris.protocol.TYPE_TODO_UPDATE
|
|
||||||
import iris.protocol.TYPE_TOOL_END
|
import iris.protocol.TYPE_TOOL_END
|
||||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||||
import iris.protocol.TYPE_TOOL_START
|
import iris.protocol.TYPE_TOOL_START
|
||||||
import iris.protocol.TodoItem
|
|
||||||
import iris.protocol.TodoUpdatePayload
|
|
||||||
import iris.protocol.ToolEndPayload
|
import iris.protocol.ToolEndPayload
|
||||||
import iris.protocol.ToolProgressPayload
|
import iris.protocol.ToolProgressPayload
|
||||||
import iris.protocol.ToolStartPayload
|
import iris.protocol.ToolStartPayload
|
||||||
@@ -33,7 +26,6 @@ import iris.util.nowMillis
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@@ -61,9 +53,7 @@ enum class MsgStatus {
|
|||||||
|
|
||||||
/** A chat message (user / assistant / commentary / streaming bubble).
|
/** A chat message (user / assistant / commentary / streaming bubble).
|
||||||
* [isSystem] marks a locally generated, centered notice (gateway restart /
|
* [isSystem] marks a locally generated, centered notice (gateway restart /
|
||||||
* online) — not a user or agent turn, not selectable or deletable.
|
* online) — not a user or agent turn, not selectable or deletable. */
|
||||||
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
|
|
||||||
@Serializable
|
|
||||||
data class MessageItem(
|
data class MessageItem(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
val role: String,
|
val role: String,
|
||||||
@@ -76,7 +66,6 @@ data class MessageItem(
|
|||||||
val streaming: Boolean = false,
|
val streaming: Boolean = false,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val tokens: Int? = null,
|
val tokens: Int? = null,
|
||||||
val runtime: RuntimeMeta? = null,
|
|
||||||
val media: List<MediaItem> = emptyList(),
|
val media: List<MediaItem> = emptyList(),
|
||||||
val isSystem: Boolean = false,
|
val isSystem: Boolean = false,
|
||||||
) : ChatItem
|
) : ChatItem
|
||||||
@@ -86,7 +75,6 @@ data class MessageItem(
|
|||||||
* has been pulled to the local cache (outbound) — inbound attachments the
|
* has been pulled to the local cache (outbound) — inbound attachments the
|
||||||
* app itself uploaded carry no local path (the agent reads the server copy).
|
* app itself uploaded carry no local path (the agent reads the server copy).
|
||||||
*/
|
*/
|
||||||
@Serializable
|
|
||||||
data class MediaItem(
|
data class MediaItem(
|
||||||
val mediaId: String,
|
val mediaId: String,
|
||||||
val kind: String,
|
val kind: String,
|
||||||
@@ -96,42 +84,18 @@ data class MediaItem(
|
|||||||
val localPath: String? = null,
|
val localPath: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A structured tool-activity card (spinner until [done]).
|
/** A structured tool-activity card (spinner until [done]). */
|
||||||
* [anchorId] is the id of the message this card follows in the lane (the
|
|
||||||
* last non-streaming message when the tool started) — persisted with the
|
|
||||||
* card so a restart restores it in its correct position (user message →
|
|
||||||
* tool card → answer) instead of dropping it or appending it at the end.
|
|
||||||
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
|
|
||||||
@Serializable
|
|
||||||
data class ToolItem(
|
data class ToolItem(
|
||||||
override val id: String,
|
override val id: String,
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val name: String,
|
val name: String,
|
||||||
val preview: String? = null,
|
val preview: String? = null,
|
||||||
val args: JsonElement? = null,
|
val args: JsonElement? = null,
|
||||||
/** Cosmetic per-tool glyph from the gateway (hermes get_tool_emoji);
|
|
||||||
* null for unknown tools / older frames — the UI falls back to 🔧. */
|
|
||||||
val emoji: String? = null,
|
|
||||||
val note: String? = null,
|
val note: String? = null,
|
||||||
val done: Boolean = false,
|
val done: Boolean = false,
|
||||||
val ok: Boolean = true,
|
val ok: Boolean = true,
|
||||||
val duration: Double? = null,
|
val duration: Double? = null,
|
||||||
val outputPreview: String? = null,
|
val outputPreview: String? = null,
|
||||||
val anchorId: String? = null,
|
|
||||||
) : ChatItem
|
|
||||||
|
|
||||||
/** An interactive choice picker card (one tap → one value), sent by
|
|
||||||
* finite-choice slash commands (/reasoning, /fast, …) via `picker.choice`.
|
|
||||||
* [selected] is the value the user tapped (null = still pending); the
|
|
||||||
* server's reply arrives as a normal message afterwards.
|
|
||||||
* [Serializable]: persisted as a JSON payload in the local cache (ChatDb). */
|
|
||||||
@Serializable
|
|
||||||
data class PickerItem(
|
|
||||||
override val id: String, // picker_id (unique; the picker.select key)
|
|
||||||
val title: String,
|
|
||||||
val choices: List<PickerChoice> = emptyList(),
|
|
||||||
val ts: Long = 0,
|
|
||||||
val selected: String? = null,
|
|
||||||
) : ChatItem
|
) : ChatItem
|
||||||
|
|
||||||
class ChatStore {
|
class ChatStore {
|
||||||
@@ -143,25 +107,8 @@ class ChatStore {
|
|||||||
private val _currentLane = MutableStateFlow(DEFAULT_LANE)
|
private val _currentLane = MutableStateFlow(DEFAULT_LANE)
|
||||||
val currentLane: StateFlow<String> = _currentLane.asStateFlow()
|
val currentLane: StateFlow<String> = _currentLane.asStateFlow()
|
||||||
|
|
||||||
/** The agent's live todo list per lane (todo.update; last-write-wins).
|
private var localSeq = 0
|
||||||
* Ephemeral: not persisted — the gateway re-sends a snapshot when the
|
private var toolSeq = 0
|
||||||
* app reconnects, and the next `todo` tool call refreshes it. */
|
|
||||||
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
|
|
||||||
val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow()
|
|
||||||
|
|
||||||
/** Unread message count per lane (M8: unread indicator). Ephemeral
|
|
||||||
* (in-memory): a process death resets it, and the `sync` delta re-counts
|
|
||||||
* genuinely new messages on reconnect. A lane absent from the map has
|
|
||||||
* no unread messages. */
|
|
||||||
private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
|
|
||||||
val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
|
|
||||||
|
|
||||||
/** Single lock for the lane/todo/unread maps: they are mutated from the
|
|
||||||
* UI thread (addPending via send) and the frame-collector thread
|
|
||||||
* (Dispatchers.Default). A non-atomic read-modify-write loses a frame
|
|
||||||
* that lands between the read and the write (e.g. a streaming delta
|
|
||||||
* dropped while the user sends). */
|
|
||||||
private val lock = Any()
|
|
||||||
|
|
||||||
/** When false, `message.start`/`message.update` frames are ignored and each
|
/** When false, `message.start`/`message.update` frames are ignored and each
|
||||||
* reply materializes as a single final message on `message.stop`
|
* reply materializes as a single final message on `message.stop`
|
||||||
@@ -170,20 +117,18 @@ class ChatStore {
|
|||||||
var streamingEnabled: Boolean = true
|
var streamingEnabled: Boolean = true
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_LANE = "default"
|
const val DEFAULT_LANE = "android:default"
|
||||||
|
|
||||||
fun randomId(prefix: String): String = "${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
fun randomId(prefix: String): String =
|
||||||
|
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lane helpers ──────────────────────────────────────────────────────
|
// ── Lane helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Lane key for a (chat, thread) pair. Uses `::` as the separator so a
|
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
|
||||||
* thread lane can never collide with a chat id (chat ids are direct,
|
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
|
||||||
* e.g. `chan_1`, and never contain `:`). */
|
fun laneKey(chatId: String, threadId: String?): String =
|
||||||
fun laneKey(
|
if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
|
||||||
chatId: String,
|
|
||||||
threadId: String?,
|
|
||||||
): String = if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
|
|
||||||
|
|
||||||
/** Split a lane key back into (chatId, threadId). */
|
/** Split a lane key back into (chatId, threadId). */
|
||||||
fun parseLane(key: String): Pair<String, String?> {
|
fun parseLane(key: String): Pair<String, String?> {
|
||||||
@@ -200,45 +145,18 @@ class ChatStore {
|
|||||||
return laneKey(chatId, frame.threadId)
|
return laneKey(chatId, frame.threadId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateLane(
|
private fun updateLane(lane: String, transform: (List<ChatItem>) -> List<ChatItem>) {
|
||||||
lane: String,
|
|
||||||
transform: (List<ChatItem>) -> List<ChatItem>,
|
|
||||||
) {
|
|
||||||
synchronized(lock) {
|
|
||||||
val map = _lanes.value.toMutableMap()
|
val map = _lanes.value.toMutableMap()
|
||||||
map[lane] = transform(map[lane].orEmpty())
|
map[lane] = transform(map[lane].orEmpty())
|
||||||
_lanes.value = map
|
_lanes.value = map
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply [transform] to every lane, writing back only when something
|
|
||||||
* changed. Callers must hold [lock]. */
|
|
||||||
private fun mapLanes(transform: (List<ChatItem>) -> List<ChatItem>) {
|
|
||||||
val map = _lanes.value.toMutableMap()
|
|
||||||
var changed = false
|
|
||||||
for ((lane, list) in map) {
|
|
||||||
val updated = transform(list)
|
|
||||||
if (updated != list) {
|
|
||||||
map[lane] = updated
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) _lanes.value = map
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Optimistic send ───────────────────────────────────────────────────
|
// ── Optimistic send ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Optimistic add: show the user's message immediately (pending) in [lane]. */
|
/** Optimistic add: show the user's message immediately (pending) in [lane]. */
|
||||||
fun addPending(
|
fun addPending(text: String, lane: String, media: List<MediaItem> = emptyList()): String {
|
||||||
text: String,
|
localSeq++
|
||||||
lane: String,
|
val id = "local_$localSeq"
|
||||||
media: List<MediaItem> = emptyList(),
|
|
||||||
): String {
|
|
||||||
// Process-unique id: the in-memory seq resets on every ChatStore
|
|
||||||
// creation, and failed sends are persisted — a restart would
|
|
||||||
// otherwise re-mint local_1 and collide with the restored bubble
|
|
||||||
// (duplicate list key).
|
|
||||||
val id = randomId("local")
|
|
||||||
updateLane(lane) {
|
updateLane(lane) {
|
||||||
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
|
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
|
||||||
}
|
}
|
||||||
@@ -248,14 +166,9 @@ class ChatStore {
|
|||||||
/** Append a locally generated, centered system notice (gateway restart /
|
/** Append a locally generated, centered system notice (gateway restart /
|
||||||
* online) to [lane]. Not a user or agent turn: it is not selectable,
|
* online) to [lane]. Not a user or agent turn: it is not selectable,
|
||||||
* deletable, or echoed to the server. */
|
* deletable, or echoed to the server. */
|
||||||
fun addSystemMessage(
|
fun addSystemMessage(lane: String, text: String) {
|
||||||
lane: String,
|
localSeq++
|
||||||
text: String,
|
val id = "sys_$localSeq"
|
||||||
) {
|
|
||||||
// randomId (not a process-local seq): system messages are persisted,
|
|
||||||
// and a seq that resets on restart would re-mint sys_0 and collide
|
|
||||||
// with the restored one (upsert overwrite).
|
|
||||||
val id = randomId("sys")
|
|
||||||
updateLane(lane) {
|
updateLane(lane) {
|
||||||
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
|
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
|
||||||
}
|
}
|
||||||
@@ -274,20 +187,15 @@ class ChatStore {
|
|||||||
TYPE_TOOL_START -> onToolStart(lane, frame)
|
TYPE_TOOL_START -> onToolStart(lane, frame)
|
||||||
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
|
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
|
||||||
TYPE_TOOL_END -> onToolEnd(lane, frame)
|
TYPE_TOOL_END -> onToolEnd(lane, frame)
|
||||||
TYPE_TODO_UPDATE -> onTodoUpdate(lane, frame)
|
|
||||||
TYPE_COMMENTARY -> onCommentary(lane, frame)
|
TYPE_COMMENTARY -> onCommentary(lane, frame)
|
||||||
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
|
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
|
||||||
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
|
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── message (final / standalone, incl. non-streaming + user echo) ─────
|
// ── message (final / standalone, incl. non-streaming + user echo) ─────
|
||||||
|
|
||||||
private fun onMessage(
|
private fun onMessage(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<MessagePayload>() ?: return
|
val p = frame.payloadAs<MessagePayload>() ?: return
|
||||||
// Auto-threaded send: the gateway created a fresh thread for this
|
// Auto-threaded send: the gateway created a fresh thread for this
|
||||||
// message; the optimistic bubble is still in the parent flat lane.
|
// message; the optimistic bubble is still in the parent flat lane.
|
||||||
@@ -297,8 +205,7 @@ class ChatStore {
|
|||||||
val byId = list.indexOfFirst { it.id == p.messageId }
|
val byId = list.indexOfFirst { it.id == p.messageId }
|
||||||
if (byId >= 0) {
|
if (byId >= 0) {
|
||||||
val cur = list[byId] as? MessageItem ?: return@updateLane list
|
val cur = list[byId] as? MessageItem ?: return@updateLane list
|
||||||
val updated =
|
val updated = cur.copy(
|
||||||
cur.copy(
|
|
||||||
text = p.text,
|
text = p.text,
|
||||||
reasoning = p.reasoning,
|
reasoning = p.reasoning,
|
||||||
pending = false,
|
pending = false,
|
||||||
@@ -306,58 +213,34 @@ class ChatStore {
|
|||||||
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
|
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
|
||||||
model = p.model,
|
model = p.model,
|
||||||
tokens = p.tokens,
|
tokens = p.tokens,
|
||||||
runtime = p.runtime ?: cur.runtime,
|
|
||||||
ts = p.ts ?: cur.ts,
|
ts = p.ts ?: cur.ts,
|
||||||
media = mergeMedia(cur.media, p.media),
|
media = mergeMedia(cur.media, p.media),
|
||||||
)
|
)
|
||||||
list.toMutableList().also { it[byId] = updated }
|
list.toMutableList().also { it[byId] = updated }
|
||||||
} else if (p.role == ROLE_USER) {
|
} else if (p.role == ROLE_USER) {
|
||||||
// Replace the matching optimistic bubble (server echo). Also
|
// Replace the matching optimistic pending bubble (server echo).
|
||||||
// matches a FAILED bubble: the send may have arrived after
|
val pendingIdx = list.indexOfLast {
|
||||||
// its POST response was lost in a network drop — the echo is
|
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||||
// the proof of delivery, so reconcile instead of duplicating.
|
|
||||||
val pendingIdx =
|
|
||||||
list.indexOfLast {
|
|
||||||
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
|
||||||
(it.pending || it.status == MsgStatus.Failed)
|
|
||||||
}
|
}
|
||||||
if (pendingIdx >= 0) {
|
if (pendingIdx >= 0) {
|
||||||
list.toMutableList().also {
|
list.toMutableList().also {
|
||||||
it[pendingIdx] =
|
it[pendingIdx] = MessageItem(
|
||||||
MessageItem(
|
id = p.messageId, role = p.role, text = p.text,
|
||||||
id = p.messageId,
|
ts = p.ts ?: 0, reasoning = p.reasoning,
|
||||||
role = p.role,
|
|
||||||
text = p.text,
|
|
||||||
ts = p.ts ?: 0,
|
|
||||||
reasoning = p.reasoning,
|
|
||||||
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
|
media = mergeMedia((list[pendingIdx] as MessageItem).media, p.media),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
list +
|
list + MessageItem(
|
||||||
MessageItem(
|
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
|
||||||
id = p.messageId,
|
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
|
||||||
role = p.role,
|
|
||||||
text = p.text,
|
|
||||||
ts = p.ts ?: 0,
|
|
||||||
reasoning = p.reasoning,
|
|
||||||
model = p.model,
|
|
||||||
tokens = p.tokens,
|
|
||||||
runtime = p.runtime,
|
|
||||||
media = p.media.map { it.toMediaItem() },
|
media = p.media.map { it.toMediaItem() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
list +
|
list + MessageItem(
|
||||||
MessageItem(
|
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
|
||||||
id = p.messageId,
|
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
|
||||||
role = p.role,
|
|
||||||
text = p.text,
|
|
||||||
ts = p.ts ?: 0,
|
|
||||||
reasoning = p.reasoning,
|
|
||||||
model = p.model,
|
|
||||||
tokens = p.tokens,
|
|
||||||
runtime = p.runtime,
|
|
||||||
media = p.media.map { it.toMediaItem() },
|
media = p.media.map { it.toMediaItem() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -371,76 +254,50 @@ class ChatStore {
|
|||||||
* it in the thread lane. No-op when [lane] is not a thread lane or no
|
* it in the thread lane. No-op when [lane] is not a thread lane or no
|
||||||
* pending bubble with the same text exists in the flat lane.
|
* pending bubble with the same text exists in the flat lane.
|
||||||
*/
|
*/
|
||||||
private fun relocatePendingToThreadLane(
|
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) {
|
||||||
lane: String,
|
|
||||||
p: MessagePayload,
|
|
||||||
) {
|
|
||||||
val (chatId, threadId) = parseLane(lane)
|
val (chatId, threadId) = parseLane(lane)
|
||||||
if (threadId == null) return
|
if (threadId == null) return
|
||||||
val flatLane = chatId
|
val flatLane = chatId
|
||||||
synchronized(lock) {
|
|
||||||
val map = _lanes.value.toMutableMap()
|
val map = _lanes.value.toMutableMap()
|
||||||
val flatList = map[flatLane].orEmpty()
|
val flatList = map[flatLane].orEmpty()
|
||||||
val idx =
|
val idx = flatList.indexOfLast {
|
||||||
flatList.indexOfLast {
|
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
||||||
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
|
||||||
(it.pending || it.status == MsgStatus.Failed)
|
|
||||||
}
|
}
|
||||||
if (idx < 0) return@synchronized
|
if (idx < 0) return
|
||||||
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
||||||
_lanes.value = map
|
_lanes.value = map
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Merge server media refs into existing items, keeping local paths. */
|
/** Merge server media refs into existing items, keeping local paths. */
|
||||||
private fun mergeMedia(
|
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> {
|
||||||
existing: List<MediaItem>,
|
|
||||||
incoming: List<MediaRef>,
|
|
||||||
): List<MediaItem> {
|
|
||||||
if (incoming.isEmpty()) return existing
|
if (incoming.isEmpty()) return existing
|
||||||
val byId = existing.associateBy { it.mediaId }
|
val byId = existing.associateBy { it.mediaId }
|
||||||
return incoming.map { ref ->
|
return incoming.map { ref ->
|
||||||
byId[ref.mediaId]?.copy(
|
byId[ref.mediaId]?.copy(
|
||||||
kind = ref.kind,
|
kind = ref.kind, mime = ref.mime, size = ref.size, filename = ref.filename,
|
||||||
mime = ref.mime,
|
|
||||||
size = ref.size,
|
|
||||||
filename = ref.filename,
|
|
||||||
) ?: ref.toMediaItem()
|
) ?: ref.toMediaItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MediaRef.toMediaItem() = MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
|
private fun MediaRef.toMediaItem() =
|
||||||
|
MediaItem(mediaId = mediaId, kind = kind, mime = mime, size = size, filename = filename)
|
||||||
|
|
||||||
// ── message.start (open a live streaming bubble) ──────────────────────
|
// ── message.start (open a live streaming bubble) ──────────────────────
|
||||||
|
|
||||||
private fun onMessageStart(
|
private fun onMessageStart(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
if (!streamingEnabled) return
|
if (!streamingEnabled) return
|
||||||
val p = frame.payloadAs<MessageStartPayload>() ?: return
|
val p = frame.payloadAs<MessageStartPayload>() ?: return
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
if (list.any { it.id == p.messageId }) {
|
if (list.any { it.id == p.messageId }) list
|
||||||
list
|
else list + MessageItem(
|
||||||
} else {
|
id = p.messageId, role = p.role, text = "", ts = 0, streaming = true,
|
||||||
list +
|
|
||||||
MessageItem(
|
|
||||||
id = p.messageId,
|
|
||||||
role = p.role,
|
|
||||||
text = "",
|
|
||||||
ts = 0,
|
|
||||||
streaming = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── message.update (replace live bubble text; full snapshot) ──────────
|
// ── message.update (replace live bubble text; full snapshot) ──────────
|
||||||
|
|
||||||
private fun onMessageUpdate(
|
private fun onMessageUpdate(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
|
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
val idx = list.indexOfFirst { it.id == p.messageId }
|
val idx = list.indexOfFirst { it.id == p.messageId }
|
||||||
@@ -452,36 +309,25 @@ class ChatStore {
|
|||||||
|
|
||||||
// ── message.stop (finalize the live bubble) ───────────────────────────
|
// ── message.stop (finalize the live bubble) ───────────────────────────
|
||||||
|
|
||||||
private fun onMessageStop(
|
private fun onMessageStop(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<MessageStopPayload>() ?: return
|
val p = frame.payloadAs<MessageStopPayload>() ?: return
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
val idx = list.indexOfFirst { it.id == p.messageId }
|
val idx = list.indexOfFirst { it.id == p.messageId }
|
||||||
if (idx < 0) {
|
if (idx < 0) {
|
||||||
// No live bubble (e.g. missed start) — materialize a final one.
|
// No live bubble (e.g. missed start) — materialize a final one.
|
||||||
list +
|
list + MessageItem(
|
||||||
MessageItem(
|
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
|
||||||
id = p.messageId,
|
ts = p.ts ?: 0, reasoning = p.reasoning,
|
||||||
role = ROLE_ASSISTANT,
|
model = p.model, tokens = p.tokens,
|
||||||
text = p.finalText,
|
|
||||||
ts = p.ts ?: 0,
|
|
||||||
reasoning = p.reasoning,
|
|
||||||
model = p.model,
|
|
||||||
tokens = p.tokens,
|
|
||||||
runtime = p.runtime,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val cur = list[idx] as? MessageItem ?: return@updateLane list
|
val cur = list[idx] as? MessageItem ?: return@updateLane list
|
||||||
list.toMutableList().also {
|
list.toMutableList().also {
|
||||||
it[idx] =
|
it[idx] = cur.copy(
|
||||||
cur.copy(
|
|
||||||
text = p.finalText,
|
text = p.finalText,
|
||||||
reasoning = p.reasoning,
|
reasoning = p.reasoning,
|
||||||
model = p.model,
|
model = p.model,
|
||||||
tokens = p.tokens,
|
tokens = p.tokens,
|
||||||
runtime = p.runtime ?: cur.runtime,
|
|
||||||
streaming = false,
|
streaming = false,
|
||||||
ts = p.ts ?: cur.ts,
|
ts = p.ts ?: cur.ts,
|
||||||
)
|
)
|
||||||
@@ -492,41 +338,21 @@ class ChatStore {
|
|||||||
|
|
||||||
// ── tool.start (new tool card) ────────────────────────────────────────
|
// ── tool.start (new tool card) ────────────────────────────────────────
|
||||||
|
|
||||||
private fun onToolStart(
|
private fun onToolStart(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<ToolStartPayload>() ?: return
|
val p = frame.payloadAs<ToolStartPayload>() ?: return
|
||||||
// Process-unique id: the in-memory seq resets on every ChatStore
|
toolSeq++
|
||||||
// creation, and cards are persisted — a restart would otherwise
|
val id = "tool_$toolSeq"
|
||||||
// re-mint tool_1 and collide with the restored card (duplicate list
|
|
||||||
// key, upsert overwrite). onToolProgress/onToolEnd match by index,
|
|
||||||
// so non-sequential ids are safe.
|
|
||||||
val id = randomId("tool")
|
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
// Anchor the card to the message it follows: the last non-streaming
|
list + ToolItem(
|
||||||
// message (a live streaming bubble is the answer that arrives AFTER
|
id = id, index = p.index, name = p.name,
|
||||||
// the tool, so it is skipped). Restored with the card on restart.
|
preview = p.preview, args = p.args,
|
||||||
val anchorId = list.lastOrNull { it is MessageItem && !it.streaming }?.id
|
|
||||||
list +
|
|
||||||
ToolItem(
|
|
||||||
id = id,
|
|
||||||
index = p.index,
|
|
||||||
name = p.name,
|
|
||||||
preview = p.preview,
|
|
||||||
args = p.args,
|
|
||||||
emoji = p.emoji,
|
|
||||||
anchorId = anchorId,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tool.progress (in-progress note) ──────────────────────────────────
|
// ── tool.progress (in-progress note) ──────────────────────────────────
|
||||||
|
|
||||||
private fun onToolProgress(
|
private fun onToolProgress(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<ToolProgressPayload>() ?: return
|
val p = frame.payloadAs<ToolProgressPayload>() ?: return
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
||||||
@@ -538,70 +364,41 @@ class ChatStore {
|
|||||||
|
|
||||||
// ── tool.end (mark the tool card complete) ────────────────────────────
|
// ── tool.end (mark the tool card complete) ────────────────────────────
|
||||||
|
|
||||||
private fun onToolEnd(
|
private fun onToolEnd(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<ToolEndPayload>() ?: return
|
val p = frame.payloadAs<ToolEndPayload>() ?: return
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
|
||||||
if (idx < 0) return@updateLane list
|
if (idx < 0) return@updateLane list
|
||||||
val cur = list[idx] as ToolItem
|
val cur = list[idx] as ToolItem
|
||||||
list.toMutableList().also {
|
list.toMutableList().also {
|
||||||
it[idx] =
|
it[idx] = cur.copy(
|
||||||
cur.copy(
|
done = true, ok = p.ok, duration = p.duration,
|
||||||
done = true,
|
|
||||||
ok = p.ok,
|
|
||||||
duration = p.duration,
|
|
||||||
outputPreview = p.outputPreview,
|
outputPreview = p.outputPreview,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── todo.update (the agent's live todo list, last-write-wins) ─────────
|
|
||||||
|
|
||||||
private fun onTodoUpdate(
|
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<TodoUpdatePayload>() ?: return
|
|
||||||
synchronized(lock) {
|
|
||||||
val map = _todos.value.toMutableMap()
|
|
||||||
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
|
|
||||||
_todos.value = map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── commentary (dimmed interim beat) ──────────────────────────────────
|
// ── commentary (dimmed interim beat) ──────────────────────────────────
|
||||||
|
|
||||||
private fun onCommentary(
|
private fun onCommentary(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<CommentaryPayload>() ?: return
|
val p = frame.payloadAs<CommentaryPayload>() ?: return
|
||||||
// Gateway lifecycle notices (restart / shutdown / online) are the
|
// Gateway lifecycle notices (restart / shutdown / online) are rendered
|
||||||
// controller's business: it renders the "restarting" / "online" pair
|
// as a centered system notice by the controller, on the down/up state
|
||||||
// as centered system notices on the down/up state transitions (gated
|
// transition. The server also emits the "restarting" notice as a
|
||||||
// on the gateway's status{restarting} frame). The server also emits
|
// commentary frame (and the sync catch-up can replay it *after* the
|
||||||
// these as commentary frames (and the sync catch-up can replay them
|
// local "online" notice), so drop it here: the app is the source of
|
||||||
// out of order), so drop them here to prevent duplicates.
|
// truth for these notices, which keeps the order (restarting → online)
|
||||||
|
// and prevents duplicates.
|
||||||
if (isGatewayLifecycleNotice(p.text)) return
|
if (isGatewayLifecycleNotice(p.text)) return
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
if (list.any { it.id == p.messageId }) {
|
if (list.any { it.id == p.messageId }) list
|
||||||
list
|
else list + MessageItem(
|
||||||
} else {
|
id = p.messageId, role = ROLE_ASSISTANT, text = p.text,
|
||||||
list +
|
ts = 0, isCommentary = true,
|
||||||
MessageItem(
|
|
||||||
id = p.messageId,
|
|
||||||
role = ROLE_ASSISTANT,
|
|
||||||
text = p.text,
|
|
||||||
ts = 0,
|
|
||||||
isCommentary = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** True for hermes gateway lifecycle notices (same wording on all apps). */
|
/** True for hermes gateway lifecycle notices (same wording on all apps). */
|
||||||
private fun isGatewayLifecycleNotice(text: String): Boolean {
|
private fun isGatewayLifecycleNotice(text: String): Boolean {
|
||||||
@@ -618,24 +415,16 @@ class ChatStore {
|
|||||||
* falling back to the lane's last assistant message). The item has no local
|
* falling back to the lane's last assistant message). The item has no local
|
||||||
* path yet — the controller pulls it and calls [setMediaLocalPath].
|
* path yet — the controller pulls it and calls [setMediaLocalPath].
|
||||||
*/
|
*/
|
||||||
private fun onMediaOffer(
|
private fun onMediaOffer(lane: String, frame: Frame) {
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<MediaOfferPayload>() ?: return
|
val p = frame.payloadAs<MediaOfferPayload>() ?: return
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
// Already attached (offer replayed)? Skip.
|
// Already attached (offer replayed)? Skip.
|
||||||
if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list
|
if (list.any { it is MessageItem && it.media.any { m -> m.mediaId == p.mediaId } }) return@updateLane list
|
||||||
val item =
|
val item = MediaItem(
|
||||||
MediaItem(
|
mediaId = p.mediaId, kind = p.kind, mime = p.mime,
|
||||||
mediaId = p.mediaId,
|
size = p.size, filename = p.filename,
|
||||||
kind = p.kind,
|
|
||||||
mime = p.mime,
|
|
||||||
size = p.size,
|
|
||||||
filename = p.filename,
|
|
||||||
)
|
)
|
||||||
val targetIdx =
|
val targetIdx = if (p.messageId != null) {
|
||||||
if (p.messageId != null) {
|
|
||||||
list.indexOfFirst { it.id == p.messageId }
|
list.indexOfFirst { it.id == p.messageId }
|
||||||
} else {
|
} else {
|
||||||
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
|
list.indexOfLast { it is MessageItem && it.role == ROLE_ASSISTANT }
|
||||||
@@ -647,132 +436,43 @@ class ChatStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Record a pulled file's local path for [mediaId] (all lanes). */
|
/** Record a pulled file's local path for [mediaId] (all lanes). */
|
||||||
fun setMediaLocalPath(
|
fun setMediaLocalPath(mediaId: String, localPath: String) {
|
||||||
mediaId: String,
|
val map = _lanes.value.toMutableMap()
|
||||||
localPath: String,
|
var changed = false
|
||||||
) {
|
for ((lane, list) in map) {
|
||||||
synchronized(lock) {
|
val updated = list.map { item ->
|
||||||
mapLanes { list ->
|
|
||||||
list.map { item ->
|
|
||||||
if (item is MessageItem) {
|
if (item is MessageItem) {
|
||||||
item.copy(
|
item.copy(media = item.media.map { m ->
|
||||||
media =
|
|
||||||
item.media.map { m ->
|
|
||||||
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
|
if (m.mediaId == mediaId) m.copy(localPath = localPath) else m
|
||||||
},
|
})
|
||||||
)
|
} else item
|
||||||
} else {
|
}
|
||||||
item
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── picker.choice (interactive slash-command menu) ──────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Append a choice-picker card to [lane]. Idempotent by picker id: the
|
|
||||||
* frame is outboxed, so a sync replay of an already-rendered picker is a
|
|
||||||
* no-op (a locally resolved card is never re-opened by a replay).
|
|
||||||
*/
|
|
||||||
private fun onPickerChoice(
|
|
||||||
lane: String,
|
|
||||||
frame: Frame,
|
|
||||||
) {
|
|
||||||
val p = frame.payloadAs<PickerChoicePayload>() ?: return
|
|
||||||
updateLane(lane) { list ->
|
|
||||||
if (list.any { it.id == p.pickerId }) {
|
|
||||||
list
|
|
||||||
} else {
|
|
||||||
list +
|
|
||||||
PickerItem(
|
|
||||||
id = p.pickerId,
|
|
||||||
title = p.title,
|
|
||||||
choices = p.choices,
|
|
||||||
ts = nowMillis(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mark the picker [pickerId] as answered with [value] (all lanes; the
|
|
||||||
* picker id is unique). Optimistic: the server's reply message follows
|
|
||||||
* as a normal message; an expired picker (gateway restart) simply never
|
|
||||||
* replies. */
|
|
||||||
fun resolvePicker(
|
|
||||||
pickerId: String,
|
|
||||||
value: String,
|
|
||||||
) {
|
|
||||||
synchronized(lock) {
|
|
||||||
mapLanes { list ->
|
|
||||||
list.map { item ->
|
|
||||||
if (item is PickerItem && item.id == pickerId && item.selected == null) {
|
|
||||||
item.copy(selected = value)
|
|
||||||
} else {
|
|
||||||
item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** M8: a new message arrived in [lane] that the user hasn't seen —
|
|
||||||
* increment its unread count. */
|
|
||||||
fun markUnread(lane: String) {
|
|
||||||
synchronized(lock) {
|
|
||||||
val map = _unread.value.toMutableMap()
|
|
||||||
map[lane] = (map[lane] ?: 0) + 1
|
|
||||||
_unread.value = map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** M8: the user is now viewing [lane]'s newest content — clear its unread
|
|
||||||
* count. Idempotent (a lane with no unread is a no-op). */
|
|
||||||
fun markLaneRead(lane: String) {
|
|
||||||
synchronized(lock) {
|
|
||||||
val map = _unread.value.toMutableMap()
|
|
||||||
if (map.remove(lane) != null) _unread.value = map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** M8: unread count for a single lane (0 when none). */
|
|
||||||
fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0
|
|
||||||
|
|
||||||
/** M5: mark the user message [messageId] as read (read.receipt). */
|
/** M5: mark the user message [messageId] as read (read.receipt). */
|
||||||
fun markRead(messageId: String) {
|
fun markRead(messageId: String) {
|
||||||
synchronized(lock) {
|
val map = _lanes.value.toMutableMap()
|
||||||
mapLanes { list ->
|
var changed = false
|
||||||
list.map { item ->
|
for ((lane, list) in map) {
|
||||||
|
val updated = list.map { item ->
|
||||||
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||||
item.status != MsgStatus.Read
|
item.status != MsgStatus.Read
|
||||||
) {
|
) {
|
||||||
item.copy(pending = false, status = MsgStatus.Read)
|
item.copy(pending = false, status = MsgStatus.Read)
|
||||||
} else {
|
} else item
|
||||||
item
|
|
||||||
}
|
}
|
||||||
}
|
if (updated != list) {
|
||||||
}
|
map[lane] = updated
|
||||||
}
|
changed = true
|
||||||
}
|
|
||||||
|
|
||||||
/** M7: mark a single user message as failed (the send never reached the
|
|
||||||
* gateway — network drop, or the gateway rejected it); tap the bubble
|
|
||||||
* to retry. */
|
|
||||||
fun failMessage(messageId: String) {
|
|
||||||
synchronized(lock) {
|
|
||||||
mapLanes { list ->
|
|
||||||
list.map { item ->
|
|
||||||
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
|
||||||
item.status != MsgStatus.Failed
|
|
||||||
) {
|
|
||||||
item.copy(pending = false, status = MsgStatus.Failed)
|
|
||||||
} else {
|
|
||||||
item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -783,10 +483,17 @@ class ChatStore {
|
|||||||
*/
|
*/
|
||||||
fun removeMessages(messageIds: Set<String>) {
|
fun removeMessages(messageIds: Set<String>) {
|
||||||
if (messageIds.isEmpty()) return
|
if (messageIds.isEmpty()) return
|
||||||
synchronized(lock) {
|
val map = _lanes.value.toMutableMap()
|
||||||
mapLanes { list -> list.filterNot { it.id in messageIds } }
|
var changed = false
|
||||||
|
for ((lane, list) in map) {
|
||||||
|
val updated = list.filterNot { it.id in messageIds }
|
||||||
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize in-flight items after the gateway goes away (restart / network
|
* Finalize in-flight items after the gateway goes away (restart / network
|
||||||
@@ -796,46 +503,48 @@ class ChatStore {
|
|||||||
* message.stop) will never arrive to close them.
|
* message.stop) will never arrive to close them.
|
||||||
*/
|
*/
|
||||||
fun finalizeInterrupted() {
|
fun finalizeInterrupted() {
|
||||||
synchronized(lock) {
|
val map = _lanes.value.toMutableMap()
|
||||||
mapLanes { list ->
|
var changed = false
|
||||||
list.map { item ->
|
for ((lane, list) in map) {
|
||||||
|
val updated = list.map { item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
|
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
|
||||||
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
|
is MessageItem -> if (item.streaming) item.copy(streaming = false) else item
|
||||||
is PickerItem -> item
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M7: mark all pending user messages as failed (gateway error frame). */
|
/** M7: mark all pending user messages as failed (gateway error frame). */
|
||||||
fun failPending() {
|
fun failPending() {
|
||||||
synchronized(lock) {
|
val map = _lanes.value.toMutableMap()
|
||||||
mapLanes { list ->
|
var changed = false
|
||||||
list.map { item ->
|
for ((lane, list) in map) {
|
||||||
|
val updated = list.map { item ->
|
||||||
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
|
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
|
||||||
item.copy(pending = false, status = MsgStatus.Failed)
|
item.copy(pending = false, status = MsgStatus.Failed)
|
||||||
} else {
|
} else item
|
||||||
item
|
|
||||||
}
|
}
|
||||||
}
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M7: re-arm a failed user message for a retry send. */
|
/** M7: re-arm a failed user message for a retry send. */
|
||||||
fun rearmForRetry(
|
fun rearmForRetry(lane: String, messageId: String) {
|
||||||
lane: String,
|
|
||||||
messageId: String,
|
|
||||||
) {
|
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
list.map { item ->
|
list.map { item ->
|
||||||
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
|
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
|
||||||
item.copy(pending = true, status = MsgStatus.Pending)
|
item.copy(pending = true, status = MsgStatus.Pending)
|
||||||
} else {
|
} else item
|
||||||
item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,96 +553,24 @@ class ChatStore {
|
|||||||
* Load a history page into [lane] (oldest → newest). The history is the
|
* Load a history page into [lane] (oldest → newest). The history is the
|
||||||
* authoritative full list of final messages for the lane; it replaces the
|
* authoritative full list of final messages for the lane; it replaces the
|
||||||
* lane's final messages and preserves non-final items (tool cards, live
|
* lane's final messages and preserves non-final items (tool cards, live
|
||||||
* streaming bubbles, commentary) that are not part of the history. Tool
|
* streaming bubbles, commentary) that are not part of the history. Used to
|
||||||
* cards sort at their anchor message's position, so a history refresh
|
* restore the view on first open of a chat / after a process death, where
|
||||||
* keeps them between the user message and the answer. Used to restore the
|
* the in-memory store is empty and the `sync` delta does not cover older
|
||||||
* view on first open of a chat / after a process death, where the
|
|
||||||
* in-memory store is empty and the `sync` delta does not cover older
|
|
||||||
* messages.
|
* messages.
|
||||||
*/
|
*/
|
||||||
fun loadHistory(
|
fun loadHistory(lane: String, messages: List<MessageItem>) {
|
||||||
lane: String,
|
|
||||||
messages: List<MessageItem>,
|
|
||||||
) {
|
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
val historyIds = messages.map { it.id }.toSet()
|
val historyIds = messages.map { it.id }.toSet()
|
||||||
// A local FAILED bubble whose text+media matches a history user
|
val preserved = list.filter { item ->
|
||||||
// message was actually delivered (the POST response was lost in
|
item !is MessageItem || item.id !in historyIds
|
||||||
// the network drop) — the history copy is authoritative, so drop
|
|
||||||
// the local duplicate instead of showing the message twice.
|
|
||||||
val historyUser = messages.filter { it.role == ROLE_USER }
|
|
||||||
val preserved =
|
|
||||||
list.filter { item ->
|
|
||||||
if (item !is MessageItem) return@filter true
|
|
||||||
if (item.id in historyIds) return@filter false
|
|
||||||
if (item.role == ROLE_USER && item.status == MsgStatus.Failed &&
|
|
||||||
historyUser.any {
|
|
||||||
it.text == item.text &&
|
|
||||||
it.media.map { m -> m.mediaId } == item.media.map { m -> m.mediaId }
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
return@filter false
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
// ts of every item in the current lane: ts-less items (commentary,
|
|
||||||
// tool cards) inherit the ts of the item before them, so a tool
|
|
||||||
// card anchored to a commentary still sorts at the right place.
|
|
||||||
val tsOf = mutableMapOf<String, Long>()
|
|
||||||
var lastTs = 0L
|
|
||||||
for (item in list) {
|
|
||||||
val t = (item as? MessageItem)?.ts?.takeIf { it > 0 } ?: lastTs
|
|
||||||
if (t > 0) lastTs = t
|
|
||||||
tsOf[item.id] = t
|
|
||||||
}
|
|
||||||
// History is authoritative for the ts of its messages.
|
|
||||||
for (m in messages) {
|
|
||||||
if (m.ts > 0) tsOf[m.id] = m.ts
|
|
||||||
}
|
}
|
||||||
(messages + preserved).sortedBy { item ->
|
(messages + preserved).sortedBy { item ->
|
||||||
when (item) {
|
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||||
is MessageItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
|
|
||||||
|
|
||||||
// A resolved ts of 0 means the anchor itself is ts-less
|
|
||||||
// (lane start) — sort with it (end) instead of to the top.
|
|
||||||
is ToolItem -> tsOf[item.anchorId]?.takeIf { it > 0 } ?: Long.MAX_VALUE
|
|
||||||
|
|
||||||
is PickerItem -> item.ts.takeIf { it > 0 } ?: Long.MAX_VALUE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore lanes from the persistent local cache on startup (before the
|
|
||||||
* gateway connection is up), so the UI is populated instantly and chats
|
|
||||||
* are readable offline. The server remains authoritative: the `sync`
|
|
||||||
* delta and the `history` refresh reconcile the cache on connect.
|
|
||||||
* No-op when [lanes] is empty (first launch).
|
|
||||||
*/
|
|
||||||
fun loadFromCache(lanes: Map<String, List<ChatItem>>) {
|
|
||||||
if (lanes.isEmpty()) return
|
|
||||||
synchronized(lock) {
|
|
||||||
// The cache is ordered by (ts, id); pending/failed sends are
|
|
||||||
// persisted with ts = 0, so the DB returns them FIRST — but in
|
|
||||||
// memory addPending appends them to the END of the lane. Move the
|
|
||||||
// ts=0 message bubbles to the end (preserving their relative
|
|
||||||
// order); everything else keeps its stored order, so a restored
|
|
||||||
// lane looks like the live one until loadHistory re-sorts it
|
|
||||||
// after a connect.
|
|
||||||
_lanes.value =
|
|
||||||
lanes.mapValues { (_, items) ->
|
|
||||||
val (zeroTs, rest) = items.partition { (it as? MessageItem)?.ts == 0L }
|
|
||||||
rest + zeroTs
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
synchronized(lock) {
|
|
||||||
_lanes.value = emptyMap()
|
_lanes.value = emptyMap()
|
||||||
_unread.value = emptyMap()
|
|
||||||
_todos.value = emptyMap()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,26 +2,16 @@ package iris.data
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pairing settings storage. The token is a secret: platform actuals keep it
|
* Pairing settings storage. The token is a secret: platform actuals keep it
|
||||||
* in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
|
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
|
||||||
* encrypted file on desktop).
|
* SharedPreferences for M1 dev, file on desktop).
|
||||||
*/
|
*/
|
||||||
interface SecureStore {
|
interface SecureStore {
|
||||||
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
/** ws(s)://host:port/ws */
|
||||||
var serverUrl: String
|
var serverUrl: String
|
||||||
|
|
||||||
/** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */
|
/** ANDROID_TOKEN presented in the hello frame. */
|
||||||
var token: String
|
var token: String
|
||||||
|
|
||||||
/** Per-device token minted at pairing (hello.ack ``device_token``,
|
|
||||||
* docs/09 §9.3). Presented INSTEAD of [token] when non-empty; the
|
|
||||||
* gateway can revoke it per device. Empty until the first hello.ack. */
|
|
||||||
var deviceToken: String
|
|
||||||
|
|
||||||
/** SHA-256 fingerprint (colon-separated pairs) of the gateway's TLS
|
|
||||||
* certificate, confirmed by the user on first pair (docs/09 §9.4).
|
|
||||||
* Empty = nothing pinned (CA-signed certs need no pin). */
|
|
||||||
var pinnedCertFingerprint: String
|
|
||||||
|
|
||||||
/** Stable app-generated device id (persisted). */
|
/** Stable app-generated device id (persisted). */
|
||||||
val deviceId: String
|
val deviceId: String
|
||||||
|
|
||||||
@@ -40,11 +30,6 @@ interface SecureStore {
|
|||||||
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
|
/** ntfy server URL (M5; from hello.ack server_caps; default ntfy.sh). */
|
||||||
var ntfyServer: String
|
var ntfyServer: String
|
||||||
|
|
||||||
/** Push backend of the paired gateway ("fcm"/"ntfy"; from hello.ack
|
|
||||||
* server_caps; empty when unknown — decides whether the ntfy listener
|
|
||||||
* foreground service runs at all). */
|
|
||||||
var pushBackend: String
|
|
||||||
|
|
||||||
/** UI setting: show threads (topics) in the chat view. */
|
/** UI setting: show threads (topics) in the chat view. */
|
||||||
var threadsEnabled: Boolean
|
var threadsEnabled: Boolean
|
||||||
|
|
||||||
@@ -54,10 +39,6 @@ interface SecureStore {
|
|||||||
/** UI setting: stream assistant replies live (token by token). */
|
/** UI setting: stream assistant replies live (token by token). */
|
||||||
var streamingEnabled: Boolean
|
var streamingEnabled: Boolean
|
||||||
|
|
||||||
/** UI setting: streaming smoothness — seconds between visible text
|
|
||||||
* updates while streaming (0.2–0.8; lower = faster/smoother). */
|
|
||||||
var streamSmoothness: Float
|
|
||||||
|
|
||||||
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
|
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
|
||||||
var reasoningAutoCollapse: Boolean
|
var reasoningAutoCollapse: Boolean
|
||||||
|
|
||||||
@@ -76,23 +57,6 @@ interface SecureStore {
|
|||||||
/** UI setting: background image path (empty when unused). */
|
/** UI setting: background image path (empty when unused). */
|
||||||
var backgroundImagePath: String
|
var backgroundImagePath: String
|
||||||
|
|
||||||
/** UI setting: text size multiplier (1.0 = default; applied on top of the
|
fun savePairing(url: String, token: String)
|
||||||
* system font scale). */
|
|
||||||
var fontSizeScale: Float
|
|
||||||
|
|
||||||
/** UI setting: show the runtime-metadata footer under final assistant
|
|
||||||
* messages (model / context % / cwd / latency / cost). */
|
|
||||||
var runtimeFooterEnabled: Boolean
|
|
||||||
|
|
||||||
/** UI setting: which runtime-footer fields to show, as a comma-separated
|
|
||||||
* list in display order (e.g. "model,context_pct,cwd"). Empty = the
|
|
||||||
* default set. Valid keys: model, context_pct, cwd, latency, cost. */
|
|
||||||
var runtimeFooterFields: String
|
|
||||||
|
|
||||||
fun savePairing(
|
|
||||||
url: String,
|
|
||||||
token: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun clear()
|
fun clear()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package iris.media
|
||||||
|
|
||||||
|
/** A readable local file (expect/actual; JVM impl in jvmMain). */
|
||||||
|
expect class FileSource(path: String) : AutoCloseable {
|
||||||
|
/** Total size in bytes. */
|
||||||
|
fun size(): Long
|
||||||
|
|
||||||
|
/** Read up to [buf.size] bytes into [buf]; returns bytes read or -1 at EOF. */
|
||||||
|
fun read(buf: ByteArray): Int
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@ import iris.protocol.KIND_IMAGE
|
|||||||
import iris.protocol.KIND_VIDEO
|
import iris.protocol.KIND_VIDEO
|
||||||
|
|
||||||
/** Map a MIME type to a media kind (docs/07 §7.1). */
|
/** Map a MIME type to a media kind (docs/07 §7.1). */
|
||||||
fun kindFromMime(mime: String): String =
|
fun kindFromMime(mime: String): String = when {
|
||||||
when {
|
|
||||||
mime.startsWith("image/") -> KIND_IMAGE
|
mime.startsWith("image/") -> KIND_IMAGE
|
||||||
mime.startsWith("video/") -> KIND_VIDEO
|
mime.startsWith("video/") -> KIND_VIDEO
|
||||||
mime.startsWith("audio/") -> KIND_AUDIO
|
mime.startsWith("audio/") -> KIND_AUDIO
|
||||||
@@ -15,8 +14,7 @@ fun kindFromMime(mime: String): String =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort file extension for a MIME type (cache file naming). */
|
/** Best-effort file extension for a MIME type (cache file naming). */
|
||||||
fun extForMime(mime: String): String =
|
fun extForMime(mime: String): String = when {
|
||||||
when {
|
|
||||||
mime == "image/jpeg" -> ".jpg"
|
mime == "image/jpeg" -> ".jpg"
|
||||||
mime == "image/png" -> ".png"
|
mime == "image/png" -> ".png"
|
||||||
mime == "image/webp" -> ".webp"
|
mime == "image/webp" -> ".webp"
|
||||||
@@ -37,12 +35,3 @@ fun extForMime(mime: String): String =
|
|||||||
mime == "text/plain" -> ".txt"
|
mime == "text/plain" -> ".txt"
|
||||||
else -> ".bin"
|
else -> ".bin"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a server-provided media id before it is used in a file path or a
|
|
||||||
* `GET /v1/media/{id}` URL (M-2 / S-1). The id comes from the gateway's
|
|
||||||
* `media.offer` frame; a value like `../../x` would write outside the media
|
|
||||||
* directory and break the pull URL. Only a conservative token charset is
|
|
||||||
* accepted.
|
|
||||||
*/
|
|
||||||
fun isValidMediaId(id: String): Boolean = id.length in 1..128 && id.all { it.isLetterOrDigit() || it == '_' || it == '-' }
|
|
||||||
@@ -1,48 +1,66 @@
|
|||||||
package iris.net
|
package iris.net
|
||||||
|
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
|
import iris.media.FileSource
|
||||||
|
import iris.media.Sha256
|
||||||
import iris.protocol.ChannelInfo
|
import iris.protocol.ChannelInfo
|
||||||
|
import iris.protocol.ErrorPayload
|
||||||
import iris.protocol.Frame
|
import iris.protocol.Frame
|
||||||
import iris.protocol.HelloAckPayload
|
import iris.protocol.HelloAckPayload
|
||||||
|
import iris.protocol.IrisJson
|
||||||
|
import iris.protocol.MediaPullEndPayload
|
||||||
|
import iris.protocol.MediaUploadAckPayload
|
||||||
import iris.protocol.ServerCaps
|
import iris.protocol.ServerCaps
|
||||||
|
import iris.protocol.TYPE_ERROR
|
||||||
|
import iris.protocol.TYPE_HELLO_ACK
|
||||||
|
import iris.protocol.TYPE_MEDIA_PULL_END
|
||||||
|
import iris.protocol.TYPE_MEDIA_UPLOAD_ACK
|
||||||
|
import iris.protocol.TYPE_PONG
|
||||||
|
import iris.protocol.helloFrame
|
||||||
|
import iris.protocol.mediaPullFrame
|
||||||
|
import iris.protocol.mediaUploadEndFrame
|
||||||
|
import iris.protocol.mediaUploadStartFrame
|
||||||
import iris.protocol.messageSendFrame
|
import iris.protocol.messageSendFrame
|
||||||
|
import iris.protocol.pingFrame
|
||||||
import iris.protocol.syncFrame
|
import iris.protocol.syncFrame
|
||||||
import iris.util.IrisLog
|
|
||||||
import iris.util.nowMillis
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlin.time.TimeMark
|
||||||
|
import kotlin.time.TimeSource
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.ByteString
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP client for the hermes iris gateway (docs/19).
|
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
|
||||||
*
|
*
|
||||||
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
* - connect + hello (real auth leg), hello.ack
|
||||||
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
|
||||||
*
|
|
||||||
* - connect: health probe + SSE hello (the HTTP hello.ack)
|
|
||||||
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
|
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
|
||||||
* - events: server frames on [events]
|
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
|
||||||
* - correlation: the POST body carries the synchronous reply (e.g. read
|
* - events: server frames (minus hello.ack) on [events]
|
||||||
* receipt, errors); everything else arrives on the event stream, and the
|
* - request/response correlation by id (M2+ consumers)
|
||||||
* app reconciles by frame id (docs/19 §19.7)
|
|
||||||
*/
|
*/
|
||||||
class GatewayClient(
|
class GatewayClient(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
@@ -50,30 +68,10 @@ class GatewayClient(
|
|||||||
) {
|
) {
|
||||||
sealed interface State {
|
sealed interface State {
|
||||||
data object Disconnected : State
|
data object Disconnected : State
|
||||||
|
|
||||||
data object Connecting : State
|
data object Connecting : State
|
||||||
|
data class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
|
||||||
data class Connected(
|
|
||||||
val caps: ServerCaps,
|
|
||||||
val channels: List<ChannelInfo>,
|
|
||||||
/** M5: highest outbox cursor already pushed to this device
|
|
||||||
* (from hello.ack; 0 = never). Sync-replayed frames at/below
|
|
||||||
* it must not re-post system notifications (docs/08 §8.7). */
|
|
||||||
val lastPushedCursor: Long = 0,
|
|
||||||
) : State
|
|
||||||
|
|
||||||
data object Reconnecting : State
|
data object Reconnecting : State
|
||||||
|
data class AuthFailed(val message: String) : State
|
||||||
data class AuthFailed(
|
|
||||||
val message: String,
|
|
||||||
) : State
|
|
||||||
|
|
||||||
/** The gateway's TLS certificate is untrusted and doesn't match the
|
|
||||||
* pinned fingerprint (docs/09 §9.4). Terminal: the connect loop
|
|
||||||
* stops and the UI asks the user to confirm the fingerprint. */
|
|
||||||
data class TlsConfirmRequired(
|
|
||||||
val fingerprint: String,
|
|
||||||
) : State
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _state = MutableStateFlow<State>(State.Disconnected)
|
private val _state = MutableStateFlow<State>(State.Disconnected)
|
||||||
@@ -86,79 +84,36 @@ class GatewayClient(
|
|||||||
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
|
||||||
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
val events: SharedFlow<Frame> = _events.asSharedFlow()
|
||||||
|
|
||||||
// TLS: the pinning trust manager wraps the platform default (CA-signed
|
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||||
// certs behave as before); a self-signed gateway cert is accepted only
|
|
||||||
// after the user confirms its fingerprint (docs/09 §9.4). The pin is
|
|
||||||
// read live from the store so a fresh confirm takes effect without
|
|
||||||
// rebuilding the client.
|
|
||||||
private val pinningTm = PinningTrustManager { store.pinnedCertFingerprint }
|
|
||||||
|
|
||||||
private val client: OkHttpClient =
|
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
|
||||||
.pingInterval(20, TimeUnit.SECONDS)
|
.pingInterval(20, TimeUnit.SECONDS)
|
||||||
.sslSocketFactory(pinningSslSocketFactory(pinningTm), pinningTm)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private var connectJob: Job? = null
|
private var connectJob: Job? = null
|
||||||
|
private var socket: WebSocket? = null
|
||||||
private var nextRequestId = 1
|
private var nextRequestId = 1
|
||||||
|
|
||||||
// Incremented from the SSE callback thread (onHttpHello) and the UI
|
|
||||||
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
|
|
||||||
// request/response correlation breaks.
|
|
||||||
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
|
|
||||||
|
|
||||||
// Written by poke() (UI thread) and the connect loop (Default).
|
|
||||||
@Volatile
|
|
||||||
private var attempt = 0
|
private var attempt = 0
|
||||||
|
|
||||||
// Set by poke() (app returned to the foreground): the connect loop's
|
|
||||||
// backoff waits in 500 ms slices and re-probes immediately when set.
|
|
||||||
@Volatile
|
|
||||||
private var wakeRequested = false
|
|
||||||
|
|
||||||
// True once a connection has been established this session; reset by
|
// True once a connection has been established this session; reset by
|
||||||
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
|
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
|
||||||
// drop) so the UI can show the right status without a blocking screen.
|
// drop) so the UI can show the right status without a blocking screen.
|
||||||
private var hasConnected = false
|
private var hasConnected = false
|
||||||
|
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
|
||||||
|
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
||||||
|
|
||||||
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
|
// M4: binary frames (media upload chunks / pull stream) have no per-frame
|
||||||
// the resume cursor (SSE id / outbox high-water mark), updated from the
|
// id, so at most one binary session is active per socket. The gateway
|
||||||
// SSE/poll callback threads.
|
// allows one upload per connection; pull is request/response.
|
||||||
private var http: HttpGateway? = null
|
private sealed interface BinarySession {
|
||||||
|
data class Pulling(
|
||||||
|
val requestId: Int,
|
||||||
|
val chunks: Channel<ByteArray>,
|
||||||
|
val end: CompletableDeferred<Frame>,
|
||||||
|
) : BinarySession
|
||||||
|
}
|
||||||
|
|
||||||
@Volatile
|
private var binarySession: BinarySession? = null
|
||||||
private var httpCursor: Long = 0
|
|
||||||
private var sseFailures = 0
|
|
||||||
private var usingLongPoll = false
|
|
||||||
|
|
||||||
// Last hello.ack payload — used to restore State.Connected after a
|
// M4: only one pull may be in flight at a time (binarySession is a single
|
||||||
// reconnect state race in the connect loop. Written by the SSE callback
|
// slot). Serialize concurrent offers so their byte streams don't interleave.
|
||||||
// thread, read by the long-poll loop.
|
|
||||||
@Volatile
|
|
||||||
private var lastAck: HelloAckPayload? = null
|
|
||||||
|
|
||||||
// Epoch ms of the last frame delivered by the receive stream (SSE or
|
|
||||||
// long-poll). The watchdog uses this to detect a STALE stream: a live
|
|
||||||
// connection (heartbeats / poll answers keep it open, so the read timeout
|
|
||||||
// never fires) that has stopped delivering frames — the state a gateway
|
|
||||||
// restart can leave the app in, where sent messages sit at "sending…"
|
|
||||||
// because their echo is never delivered (issue #6). Set when the receive
|
|
||||||
// loop (re)starts so the first window isn't treated as stale.
|
|
||||||
@Volatile
|
|
||||||
private var lastFrameMs: Long = 0L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fired promptly the moment the SSE hello (hello.ack) is received — on
|
|
||||||
* every (re)connect. Used for time-critical work that must not wait for
|
|
||||||
* the state collector, which can be starved for seconds during app
|
|
||||||
* startup (Dispatchers.Default) and would push a history request past a
|
|
||||||
* flaky network's window. Set before [start].
|
|
||||||
*/
|
|
||||||
var onHelloAck: ((State.Connected) -> Unit)? = null
|
|
||||||
|
|
||||||
// Only one pull may be in flight at a time. Serialize concurrent offers so
|
|
||||||
// their byte streams don't interleave.
|
|
||||||
private val pullMutex = Mutex()
|
private val pullMutex = Mutex()
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||||
@@ -171,34 +126,13 @@ class GatewayClient(
|
|||||||
connectJob = scope.launch { connectLoop() }
|
connectJob = scope.launch { connectLoop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Stop the connect loop and close the socket. */
|
||||||
* Stop the connect loop and reset the HTTP leg. Without the reset, a
|
|
||||||
* re-pair to a *different* gateway would keep using the cached
|
|
||||||
* [HttpGateway] (old URL, old token, old resume cursor) until the process
|
|
||||||
* is killed.
|
|
||||||
*/
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
connectJob?.cancel()
|
connectJob?.cancel()
|
||||||
connectJob = null
|
connectJob = null
|
||||||
|
socket?.close(1000, "client shutdown")
|
||||||
|
socket = null
|
||||||
_state.value = State.Disconnected
|
_state.value = State.Disconnected
|
||||||
http?.close()
|
|
||||||
http = null
|
|
||||||
httpCursor = 0
|
|
||||||
sseFailures = 0
|
|
||||||
usingLongPoll = false
|
|
||||||
lastAck = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call when the app returns to the foreground: if the connect loop is
|
|
||||||
* between attempts (backing off after failed health probes — up to 30 s),
|
|
||||||
* wake it so it re-probes immediately instead of making the user wait out
|
|
||||||
* the backoff with a "Connecting…" banner. No-op while connected.
|
|
||||||
*/
|
|
||||||
fun poke() {
|
|
||||||
if (_state.value is State.Connected) return
|
|
||||||
attempt = 0
|
|
||||||
wakeRequested = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-pair: stop, then start fresh (used after saving new settings). */
|
/** Re-pair: stop, then start fresh (used after saving new settings). */
|
||||||
@@ -210,294 +144,178 @@ class GatewayClient(
|
|||||||
private suspend fun connectLoop() {
|
private suspend fun connectLoop() {
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
val url = store.serverUrl.trim()
|
val url = store.serverUrl.trim()
|
||||||
// Per-device token when the gateway minted one (docs/09 §9.3),
|
val token = store.token
|
||||||
// else the shared IRIS_TOKEN (bootstrap).
|
|
||||||
val token = store.deviceToken.ifBlank { store.token }
|
|
||||||
if (url.isBlank() || token.isBlank()) {
|
if (url.isBlank() || token.isBlank()) {
|
||||||
_state.value = State.Disconnected
|
_state.value = State.Disconnected
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
||||||
val gw = httpGateway() ?: continue
|
val dial = dial(url, token)
|
||||||
// Health probe: if the gateway is alive, open the SSE receive loop
|
when (val result = dial.result) {
|
||||||
// (which delivers the hello.ack). Otherwise back off and retry.
|
is DialResult.AuthFailed -> {
|
||||||
val healthOk =
|
_state.value = State.AuthFailed(result.message)
|
||||||
try {
|
dial.socket.close(1000, "auth failed")
|
||||||
gw.health()
|
return
|
||||||
} catch (e: Exception) {
|
|
||||||
if (failTls(e)) return
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
if (!healthOk) {
|
DialResult.Connected -> {
|
||||||
attempt++
|
|
||||||
backoffOrWake(backoffMs(attempt))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
attempt = 0
|
|
||||||
hasConnected = true
|
hasConnected = true
|
||||||
sseFailures = 0
|
attempt = 0
|
||||||
usingLongPoll = false
|
lastLiveness = TimeSource.Monotonic.markNow()
|
||||||
httpCursor = store.syncCursor
|
dial.closed.await()
|
||||||
lastFrameMs = nowMs()
|
if (!currentCoroutineContext().isActive) return
|
||||||
// Provisional Connected state (previous caps/channels) until the
|
// socket dropped -> loop again (Reconnecting)
|
||||||
// SSE hello arrives with the real ones.
|
}
|
||||||
val prev = _state.value
|
is DialResult.Failed -> {
|
||||||
_state.value =
|
attempt++
|
||||||
State.Connected(
|
delay(backoffMs(attempt))
|
||||||
caps = (prev as? State.Connected)?.caps ?: ServerCaps(),
|
}
|
||||||
channels = (prev as? State.Connected)?.channels ?: emptyList(),
|
}
|
||||||
lastPushedCursor = (prev as? State.Connected)?.lastPushedCursor ?: 0,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dial (one connect + hello) ────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed interface DialResult {
|
||||||
|
data object Connected : DialResult
|
||||||
|
data class AuthFailed(val message: String) : DialResult
|
||||||
|
data class Failed(val message: String) : DialResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Dial(
|
||||||
|
val result: DialResult,
|
||||||
|
val socket: WebSocket,
|
||||||
|
val closed: CompletableDeferred<Unit>,
|
||||||
)
|
)
|
||||||
// Run the HTTP receive loop (SSE/long-poll) until cancelled.
|
|
||||||
coroutineScope {
|
|
||||||
val receiveJob = launch { httpReceiveLoop(gw) }
|
|
||||||
// Dead-stream watchdog: the SSE read timeout (45 s) is the
|
|
||||||
// only in-stream dead-connection detector; probe /v1/health
|
|
||||||
// in parallel so a dropped network flips the state to
|
|
||||||
// Reconnecting within ~20 s (2 failed probes) instead of 45,
|
|
||||||
// and a stuck stream can't keep a stale "Connected".
|
|
||||||
var probeFailures = 0
|
|
||||||
while (receiveJob.isActive) {
|
|
||||||
delay(10_000)
|
|
||||||
val ok =
|
|
||||||
try {
|
|
||||||
gw.health()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (failTls(e)) {
|
|
||||||
receiveJob.cancel()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
probeFailures = if (ok) 0 else probeFailures + 1
|
|
||||||
if (probeFailures >= 2) {
|
|
||||||
receiveJob.cancel()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// Stale-stream watchdog: the gateway is up (health ok) but
|
|
||||||
// the receive stream has delivered no frames for a while —
|
|
||||||
// a live-but-dead connection the read timeout can't see.
|
|
||||||
// Force a fresh (re)connect so parked frames (e.g. our own
|
|
||||||
// echo) are re-delivered from the outbox.
|
|
||||||
if (lastFrameMs > 0L && nowMs() - lastFrameMs > STALE_STREAM_TIMEOUT_MS) {
|
|
||||||
IrisLog.w("receive stream stale (no frames for ${STALE_STREAM_TIMEOUT_MS}ms); forcing reconnect")
|
|
||||||
receiveJob.cancel()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
receiveJob.join()
|
|
||||||
}
|
|
||||||
// Terminal failures: don't redial with the same bad token / the
|
|
||||||
// same untrusted certificate (the UI asks the user to act).
|
|
||||||
if (_state.value is State.AuthFailed || _state.value is State.TlsConfirmRequired) return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP receive leg ──────────────────────────────────────────────────
|
private suspend fun dial(url: String, token: String): Dial {
|
||||||
|
val closed = CompletableDeferred<Unit>()
|
||||||
|
val helloAck = CompletableDeferred<HelloAckPayload>()
|
||||||
|
val authError = CompletableDeferred<String>()
|
||||||
|
val fail = CompletableDeferred<String>()
|
||||||
|
|
||||||
/** Lazily build the HTTP client from the stored URL. Synchronized: two
|
val request = Request.Builder().url(url).build()
|
||||||
* threads racing the check-then-create would leak a gateway (and its
|
val ws = client.newWebSocket(
|
||||||
* OkHttp clients). */
|
request,
|
||||||
private fun httpGateway(): HttpGateway? =
|
object : WebSocketListener() {
|
||||||
synchronized(this) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
val url = store.serverUrl.trim()
|
webSocket.send(
|
||||||
val token = store.deviceToken.ifBlank { store.token }
|
helloFrame(
|
||||||
if (url.isBlank() || token.isBlank()) return@synchronized null
|
token = token,
|
||||||
http
|
deviceId = store.deviceId,
|
||||||
?: HttpGateway(
|
|
||||||
client,
|
|
||||||
HttpGateway.deriveHttpUrl(url),
|
|
||||||
// Live provider: a device token minted by the next
|
|
||||||
// hello.ack is picked up without rebuilding the client.
|
|
||||||
token = { store.deviceToken.ifBlank { store.token } },
|
|
||||||
store.deviceId,
|
|
||||||
deviceName = store.deviceName,
|
deviceName = store.deviceName,
|
||||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
fcmToken = store.fcmToken.ifBlank { null },
|
||||||
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||||
).also { http = it }
|
).toWire(),
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The HTTP receive loop: SSE by default; after two consecutive SSE open
|
|
||||||
* failures (buffering proxy) it switches to long-poll until the next full
|
|
||||||
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
|
|
||||||
*/
|
|
||||||
private suspend fun httpReceiveLoop(gw: HttpGateway) {
|
|
||||||
var backoff = 1_000L
|
|
||||||
while (currentCoroutineContext().isActive) {
|
|
||||||
if (usingLongPoll) {
|
|
||||||
try {
|
|
||||||
val res = gw.poll(httpCursor)
|
|
||||||
res.frames.forEach { emitHttpFrame(it) }
|
|
||||||
if (res.cursor > httpCursor) httpCursor = res.cursor
|
|
||||||
// The poll answered: the link is back (long-poll has no
|
|
||||||
// hello — restore the Connected state from the last one).
|
|
||||||
restoreConnected()
|
|
||||||
} catch (e: HttpGateway.HttpAuthException) {
|
|
||||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
|
||||||
return
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (failTls(e)) return
|
|
||||||
markStreamLost()
|
|
||||||
IrisLog.w("http poll failed: ${e.message}")
|
|
||||||
delay(backoff)
|
|
||||||
backoff = minOf(backoff * 2, 15_000)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
gw.events(
|
|
||||||
cursor = httpCursor,
|
|
||||||
onHello = { onHttpHello(it) },
|
|
||||||
onFrame = { emitHttpFrame(it) },
|
|
||||||
onCursor = { if (it > httpCursor) httpCursor = it },
|
|
||||||
// A keep-alive comment proves the stream is alive —
|
|
||||||
// count it toward liveness so an idle-but-healthy
|
|
||||||
// stream isn't force-reconnected by the stale
|
|
||||||
// watchdog every 3 minutes.
|
|
||||||
onKeepAlive = { lastFrameMs = nowMs() },
|
|
||||||
)
|
)
|
||||||
// Clean EOF: back off briefly so a server that keeps
|
|
||||||
// closing cleanly can't tight-loop the reconnect.
|
|
||||||
backoff = 1_000L
|
|
||||||
delay(backoff)
|
|
||||||
} catch (e: HttpGateway.HttpAuthException) {
|
|
||||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
|
||||||
return
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (failTls(e)) return
|
|
||||||
sseFailures++
|
|
||||||
markStreamLost()
|
|
||||||
if (sseFailures >= 2) {
|
|
||||||
// SSE seems blocked: switch to long-poll.
|
|
||||||
usingLongPoll = true
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
IrisLog.w("sse read failed: ${e.message}")
|
|
||||||
delay(backoff)
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
backoff = minOf(backoff * 2, 15_000)
|
lastLiveness = TimeSource.Monotonic.markNow()
|
||||||
|
val frame = try {
|
||||||
|
IrisJson.instance.decodeFromString(Frame.serializer(), text)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (frame.type) {
|
||||||
|
TYPE_HELLO_ACK -> {
|
||||||
|
val ack = frame.payloadAs<HelloAckPayload>()
|
||||||
|
if (ack != null) helloAck.complete(ack)
|
||||||
|
}
|
||||||
|
TYPE_ERROR -> {
|
||||||
|
val err = frame.payloadAs<ErrorPayload>()
|
||||||
|
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed")
|
||||||
|
// M7: post-connect error frames are app events, not
|
||||||
|
// auth failures — let the controller react.
|
||||||
|
_events.tryEmit(frame)
|
||||||
|
}
|
||||||
|
TYPE_PONG -> Unit
|
||||||
|
else -> {
|
||||||
|
_events.tryEmit(frame)
|
||||||
|
frame.id?.let { id ->
|
||||||
|
pending[id]?.complete(frame)
|
||||||
|
// M4: terminal frame of a pull stream — close
|
||||||
|
// the chunk channel so the pull loop exits.
|
||||||
|
if (frame.type == TYPE_MEDIA_PULL_END) {
|
||||||
|
(binarySession as? BinarySession.Pulling)?.let {
|
||||||
|
it.chunks.close()
|
||||||
|
binarySession = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The SSE `event: hello` (the HTTP hello.ack). */
|
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||||
private fun onHttpHello(ack: HelloAckPayload) {
|
lastLiveness = TimeSource.Monotonic.markNow()
|
||||||
lastAck = ack
|
// M4: binary frames belong to the active pull stream
|
||||||
// Per-device token (docs/09 §9.3): minted at pairing, stable across
|
// (uploads are outbound; stray inbound chunks are dropped).
|
||||||
// (re)connects. Store it — from the next request on the app presents
|
(binarySession as? BinarySession.Pulling)
|
||||||
// it instead of the shared IRIS_TOKEN, so the gateway can revoke
|
?.chunks
|
||||||
// THIS device without touching the others.
|
?.trySend(bytes.toByteArray())
|
||||||
if (ack.deviceToken.isNotBlank() && ack.deviceToken != store.deviceToken) {
|
|
||||||
store.deviceToken = ack.deviceToken
|
|
||||||
}
|
}
|
||||||
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
|
||||||
_state.value = connected
|
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.
|
// M5: reconnect catch-up — replay frames parked while offline.
|
||||||
val local = store.syncCursor
|
val local = store.syncCursor
|
||||||
if (local < ack.syncCursor) {
|
if (local < ack.syncCursor) {
|
||||||
val id = nextId()
|
val id = nextRequestId++
|
||||||
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
|
ws.send(syncFrame(id, local).toWire())
|
||||||
}
|
}
|
||||||
// Prompt fast path (before the possibly-starved state collector).
|
winner.complete(DialResult.Connected)
|
||||||
onHelloAck?.invoke(connected)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The receive stream just died: don't keep claiming "Connected" while
|
|
||||||
* between attempts (a stale green dot through a Wi-Fi drop). */
|
|
||||||
private fun markStreamLost() {
|
|
||||||
if (_state.value is State.Connected) _state.value = State.Reconnecting
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The receive stream is open again (long-poll answered): the link is
|
|
||||||
* back. Long-poll has no hello, so restore the Connected state from the
|
|
||||||
* last hello.ack (the SSE path gets a fresh one). Fire [onHelloAck] too:
|
|
||||||
* the long-poll path has no `event: hello`, so without this the app's
|
|
||||||
* "on connected" side effects (resend queued offline sends, load the
|
|
||||||
* active lane's history) never run when a gateway restart lands while
|
|
||||||
* the app is on the long-poll fallback — queued messages would sit at
|
|
||||||
* "sending…" forever until an app restart (issue #6). */
|
|
||||||
private fun restoreConnected() {
|
|
||||||
if (_state.value !is State.Reconnecting) return
|
|
||||||
val connected =
|
|
||||||
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
|
|
||||||
?: State.Connected(ServerCaps(), emptyList())
|
|
||||||
_state.value = connected
|
|
||||||
onHelloAck?.invoke(connected)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */
|
|
||||||
private fun emitHttpFrame(frame: Frame) {
|
|
||||||
lastFrameMs = nowMs()
|
|
||||||
if (!_events.tryEmit(frame)) {
|
|
||||||
// The collector is starved beyond the 128-frame buffer: the frame
|
|
||||||
// is dropped. Log it — a silent drop here loses user-visible
|
|
||||||
// content (echoes, deltas, notifications).
|
|
||||||
IrisLog.e("events buffer full — dropped frame ${frame.type} (id=${frame.id})")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
authError.invokeOnCompletion { e ->
|
||||||
/** Deliver the POST body's synchronous reply (or null) and handle a 401.
|
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
|
||||||
* Shared by [sendMessage] and [sendFrame]. */
|
|
||||||
private fun handlePostResult(res: HttpGateway.PostResult?) {
|
|
||||||
res?.frame?.let { emitHttpFrame(it) }
|
|
||||||
if (res?.status == 401) {
|
|
||||||
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
|
||||||
}
|
}
|
||||||
|
fail.invokeOnCompletion { e ->
|
||||||
|
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
|
||||||
}
|
}
|
||||||
|
val result = withTimeoutOrNull(15_000) { winner.await() }
|
||||||
/** Terminal TLS failure: the presented certificate is untrusted and not
|
?: DialResult.Failed("timeout waiting for hello.ack")
|
||||||
* the pinned one (docs/09 §9.4). Retry can't succeed — stop the connect
|
return Dial(result, ws, closed)
|
||||||
* loop and let the UI ask the user to confirm the fingerprint. True when
|
|
||||||
* the state was set. */
|
|
||||||
private fun failTls(e: Exception): Boolean {
|
|
||||||
val tls = tlsFingerprintRequired(e) ?: return false
|
|
||||||
IrisLog.w("tls: untrusted gateway certificate (fingerprint ${tls.fingerprint})")
|
|
||||||
_state.value = State.TlsConfirmRequired(tls.fingerprint)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Outbound ──────────────────────────────────────────────────────────
|
// ── Outbound ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||||
* [mediaRefs] reference completed uploads (POST /v1/media refs).
|
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs).
|
||||||
* [autoThread] asks the gateway to mint a fresh thread for the message
|
* [autoThread] asks the gateway to mint a fresh thread for the message
|
||||||
* (auto-threading, docs/06 §6.3).
|
* (auto-threading, docs/06 §6.3). */
|
||||||
* [onResult] is called with the POST's HTTP status — 0 means "no
|
|
||||||
* response" (not connected, or the network failed); 2xx means the
|
|
||||||
* gateway accepted it; 4xx is a gateway rejection (error frame already
|
|
||||||
* delivered via [events]). Used to fail the optimistic bubble instead
|
|
||||||
* of leaving it at "sending…" forever. */
|
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
text: String,
|
text: String,
|
||||||
threadId: String? = null,
|
threadId: String? = null,
|
||||||
mediaRefs: List<String> = emptyList(),
|
mediaRefs: List<String> = emptyList(),
|
||||||
autoThread: Boolean = false,
|
autoThread: Boolean = false,
|
||||||
onResult: ((Int) -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
if (_state.value !is State.Connected) {
|
val ws = socket ?: return
|
||||||
onResult?.invoke(0)
|
val id = nextRequestId++
|
||||||
return
|
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
|
||||||
}
|
|
||||||
val id = nextId()
|
|
||||||
scope.launch {
|
|
||||||
val res =
|
|
||||||
httpGateway()?.postFrame(
|
|
||||||
messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread),
|
|
||||||
)
|
|
||||||
// The synchronous reply (e.g. the read receipt, or an error frame
|
|
||||||
// on 4xx) comes back in the POST body, not on the event stream —
|
|
||||||
// deliver it or it is lost (docs/19 §19.7).
|
|
||||||
handlePostResult(res)
|
|
||||||
onResult?.invoke(res?.status ?: 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Media upload / pull ───────────────────────────────────────────────
|
// ── M4: media upload / pull ───────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
|
* Upload a local file as media (docs/07 §7.2): media.upload.start,
|
||||||
* Returns the server's media_ref (for message.send media_refs) on success.
|
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's
|
||||||
|
* media_ref (for message.send media_refs) on success.
|
||||||
*/
|
*/
|
||||||
suspend fun uploadMedia(
|
suspend fun uploadMedia(
|
||||||
path: String,
|
path: String,
|
||||||
@@ -506,140 +324,163 @@ class GatewayClient(
|
|||||||
filename: String,
|
filename: String,
|
||||||
mediaRef: String,
|
mediaRef: String,
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
|
val ws = socket ?: return Result.failure(IllegalStateException("not connected"))
|
||||||
return http.uploadMedia(path, mime, kind, filename, mediaRef)
|
val source = FileSource(path)
|
||||||
|
val size = source.size()
|
||||||
|
if (size <= 0) {
|
||||||
|
source.close()
|
||||||
|
return Result.failure(IllegalStateException("empty file"))
|
||||||
|
}
|
||||||
|
val id = nextRequestId++
|
||||||
|
val reply = CompletableDeferred<Frame>()
|
||||||
|
pending[id] = reply
|
||||||
|
try {
|
||||||
|
ws.send(mediaUploadStartFrame(id, mediaRef, kind, mime, filename, size).toWire())
|
||||||
|
val sha = Sha256()
|
||||||
|
source.use {
|
||||||
|
val buf = ByteArray(UPLOAD_CHUNK_BYTES)
|
||||||
|
while (true) {
|
||||||
|
val n = it.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
if (n == 0) continue
|
||||||
|
sha.update(buf, 0, n)
|
||||||
|
ws.send(buf.copyOfRange(0, n).toByteString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.send(mediaUploadEndFrame(id, mediaRef, sha.hex()).toWire())
|
||||||
|
val frame = withTimeout(UPLOAD_TIMEOUT_MS) { reply.await() }
|
||||||
|
return when (frame.type) {
|
||||||
|
TYPE_MEDIA_UPLOAD_ACK -> {
|
||||||
|
val p = frame.payloadAs<MediaUploadAckPayload>()
|
||||||
|
if (p != null && p.ok) Result.success(p.mediaRef)
|
||||||
|
else Result.failure(IllegalStateException("upload rejected by server"))
|
||||||
|
}
|
||||||
|
TYPE_ERROR -> {
|
||||||
|
val e = frame.payloadAs<ErrorPayload>()
|
||||||
|
Result.failure(IllegalStateException(e?.message ?: "upload failed"))
|
||||||
|
}
|
||||||
|
else -> Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return Result.failure(e)
|
||||||
|
} finally {
|
||||||
|
pending.remove(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull offered media via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
|
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until
|
||||||
* chunk is handed to [onChunk] (write to cache).
|
* media.pull.end. Each chunk is handed to [onChunk] (write to cache).
|
||||||
*/
|
*/
|
||||||
suspend fun pullMedia(
|
suspend fun pullMedia(mediaId: String, onChunk: suspend (ByteArray) -> Unit): Result<Unit> =
|
||||||
mediaId: String,
|
|
||||||
onChunk: suspend (ByteArray) -> Unit,
|
|
||||||
): Result<Unit> =
|
|
||||||
pullMutex.withLock {
|
pullMutex.withLock {
|
||||||
val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
||||||
http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
|
val id = nextRequestId++
|
||||||
|
val chunks = Channel<ByteArray>(Channel.UNLIMITED)
|
||||||
|
val end = CompletableDeferred<Frame>()
|
||||||
|
pending[id] = end
|
||||||
|
binarySession = BinarySession.Pulling(id, chunks, end)
|
||||||
|
try {
|
||||||
|
ws.send(mediaPullFrame(id, mediaId).toWire())
|
||||||
|
val frame = withTimeout(PULL_TIMEOUT_MS) {
|
||||||
|
for (chunk in chunks) onChunk(chunk)
|
||||||
|
end.await()
|
||||||
|
}
|
||||||
|
when (frame.type) {
|
||||||
|
TYPE_MEDIA_PULL_END -> {
|
||||||
|
val p = frame.payloadAs<MediaPullEndPayload>()
|
||||||
|
if (p != null && p.ok) Result.success(Unit)
|
||||||
|
else Result.failure(IllegalStateException("pull failed"))
|
||||||
|
}
|
||||||
|
TYPE_ERROR -> {
|
||||||
|
val e = frame.payloadAs<ErrorPayload>()
|
||||||
|
Result.failure(IllegalStateException(e?.message ?: "pull failed"))
|
||||||
|
}
|
||||||
|
else -> Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
} finally {
|
||||||
|
pending.remove(id)
|
||||||
|
chunks.cancel()
|
||||||
|
val s = binarySession
|
||||||
|
if (s is BinarySession.Pulling && s.requestId == id) binarySession = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** No frames (or keep-alive comments) from the receive stream for
|
/** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */
|
||||||
* this long (gateway still
|
const val UPLOAD_CHUNK_BYTES = 256 * 1024
|
||||||
* healthy) = stale stream: force a fresh (re)connect. 3 min keeps an
|
const val UPLOAD_TIMEOUT_MS = 120_000L
|
||||||
* idle app from churning while still recovering a stuck stream well
|
const val PULL_TIMEOUT_MS = 300_000L
|
||||||
* before a user notices (issue #6). */
|
|
||||||
const val STALE_STREAM_TIMEOUT_MS = 180_000L
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an arbitrary frame with a fresh request id (fire-and-forget). The
|
* Send an arbitrary frame with a fresh request id (fire-and-forget).
|
||||||
* server replies (or broadcasts) a frame carrying the same id; the app
|
* The server replies (or broadcasts) a frame carrying the same id; the
|
||||||
* reconciles from [events]. Returns the id used, or -1 if not connected.
|
* app reconciles from [events]. Returns the id used, or -1 if not connected.
|
||||||
*/
|
*/
|
||||||
fun sendFrame(frame: Frame): Int {
|
fun sendFrame(frame: Frame): Int {
|
||||||
if (_state.value !is State.Connected) return -1
|
val ws = socket ?: return -1
|
||||||
val id = nextId()
|
val id = nextRequestId++
|
||||||
scope.launch {
|
ws.send(frame.copy(id = id).toWire())
|
||||||
val res = httpGateway()?.postFrame(frame.copy(id = id))
|
|
||||||
// Single-frame responses (commands.catalog, channel.list, search,
|
|
||||||
// history, sync, errors) come back in the POST body, not on the
|
|
||||||
// event stream — deliver it or it is lost (docs/19 §19.7).
|
|
||||||
handlePostResult(res)
|
|
||||||
}
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send a ping (heartbeat). */
|
||||||
|
fun ping() {
|
||||||
|
socket?.send(pingFrame().toWire())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
|
||||||
|
fun isStale(timeoutMs: Long = 60_000): Boolean =
|
||||||
|
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
|
||||||
|
|
||||||
|
fun reapStale() {
|
||||||
|
if (isStale()) {
|
||||||
|
socket?.close(1000, "heartbeat timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── One-shot hello test (Connect screen) ──────────────────────────────
|
// ── One-shot hello test (Connect screen) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real connection test: health probe + SSE open. The auth leg is proven
|
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
|
||||||
* by the stream being accepted (200 vs 401) — we do NOT wait for the
|
* Exercises the auth leg, not just TCP (docs/10 §10.8).
|
||||||
* hello event, because the server replays the outbox (up to 72 h of
|
|
||||||
* frames) before it and a large outbox would time out a healthy gateway
|
|
||||||
* (docs/10 §10.8).
|
|
||||||
*/
|
*/
|
||||||
suspend fun testHello(
|
suspend fun testHello(url: String, token: String): Result<Unit> {
|
||||||
url: String,
|
val dial = dial(url, token)
|
||||||
token: String,
|
return when (val result = dial.result) {
|
||||||
): Result<Unit> {
|
DialResult.Connected -> {
|
||||||
val gw =
|
dial.socket.close(1000, "test complete")
|
||||||
HttpGateway(
|
|
||||||
client,
|
|
||||||
HttpGateway.deriveHttpUrl(url),
|
|
||||||
// An already-paired device presents its per-device token
|
|
||||||
// (docs/09 §9.3); a fresh pairing falls back to the entered
|
|
||||||
// shared token (bootstrap).
|
|
||||||
token = { store.deviceToken.ifBlank { token } },
|
|
||||||
store.deviceId,
|
|
||||||
deviceName = store.deviceName,
|
|
||||||
fcmToken = { store.fcmToken.ifBlank { null } },
|
|
||||||
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
|
||||||
)
|
|
||||||
return try {
|
|
||||||
if (!gw.health()) {
|
|
||||||
Result.failure(IllegalStateException("gateway unreachable"))
|
|
||||||
} else {
|
|
||||||
// Open the SSE stream briefly: 200 = auth leg proven, 401 =
|
|
||||||
// bad token. Don't wait for the hello (outbox replay first).
|
|
||||||
val opened = CompletableDeferred<Unit>()
|
|
||||||
val job =
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
gw.events(
|
|
||||||
cursor = store.syncCursor,
|
|
||||||
onOpen = { opened.complete(Unit) },
|
|
||||||
onHello = { },
|
|
||||||
onFrame = { },
|
|
||||||
onCursor = { },
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
opened.completeExceptionally(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (withTimeoutOrNull(10_000) { opened.await() } == null) {
|
|
||||||
Result.failure(IllegalStateException("timeout opening the event stream"))
|
|
||||||
} else {
|
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
} catch (e: HttpGateway.HttpAuthException) {
|
is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message))
|
||||||
Result.failure(IllegalStateException("unauthorized — check the pairing token"))
|
is DialResult.Failed -> Result.failure(IllegalStateException(result.message))
|
||||||
} catch (e: CancellationException) {
|
}
|
||||||
throw e
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
// Unwrap the pinning manager's signal so the Connect
|
// ── Heartbeat job ─────────────────────────────────────────────────────
|
||||||
// screen can offer the fingerprint confirm dialog.
|
|
||||||
Result.failure(tlsFingerprintRequired(e) ?: IllegalStateException("connection failed: ${e.message}"))
|
fun startHeartbeat() {
|
||||||
} finally {
|
scope.launch {
|
||||||
job.cancel()
|
while (isActive) {
|
||||||
|
delay(20_000)
|
||||||
|
if (_state.value is State.Connected) {
|
||||||
|
ping()
|
||||||
|
reapStale()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(tlsFingerprintRequired(e) ?: e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Backoff that wakes early when [poke] is called (app foregrounded). */
|
|
||||||
private suspend fun backoffOrWake(ms: Long) {
|
|
||||||
var remaining = ms
|
|
||||||
while (remaining > 0 && currentCoroutineContext().isActive) {
|
|
||||||
delay(minOf(remaining, 500L))
|
|
||||||
if (wakeRequested) {
|
|
||||||
wakeRequested = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
remaining -= 500L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun backoffMs(attempt: Int): Long {
|
private fun backoffMs(attempt: Int): Long {
|
||||||
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
||||||
val capped = minOf(base, 30_000L)
|
val capped = minOf(base, 30_000L)
|
||||||
return capped + Random.nextLong(0, 500)
|
return capped + Random.nextLong(0, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun nowMs(): Long = nowMillis()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
package iris.net
|
|
||||||
|
|
||||||
import iris.AppVersion
|
|
||||||
import iris.media.Sha256
|
|
||||||
import iris.media.isValidMediaId
|
|
||||||
import iris.protocol.ErrorPayload
|
|
||||||
import iris.protocol.Frame
|
|
||||||
import iris.protocol.HelloAckPayload
|
|
||||||
import iris.protocol.IrisJson
|
|
||||||
import iris.protocol.MediaUploadAckPayload
|
|
||||||
import iris.util.IrisLog
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.jsonArray
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP transport client (docs/19) — the only transport.
|
|
||||||
*
|
|
||||||
* The app sends over `POST /v1/frame` and receives over SSE
|
|
||||||
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
|
|
||||||
* media travels via `POST/GET /v1/media`.
|
|
||||||
*
|
|
||||||
* [events] is ONE SSE connection attempt (blocking read on
|
|
||||||
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
|
|
||||||
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
|
|
||||||
* outbox cursor, so resume is just the last seen id).
|
|
||||||
*/
|
|
||||||
class HttpGateway(
|
|
||||||
private val client: OkHttpClient,
|
|
||||||
private val baseUrl: String,
|
|
||||||
/** Live auth-token provider, read per request: the per-device token
|
|
||||||
* (docs/09 §9.3) when the gateway minted one, else the shared
|
|
||||||
* IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is
|
|
||||||
* picked up without rebuilding the client. */
|
|
||||||
private val token: () -> String,
|
|
||||||
private val deviceId: String,
|
|
||||||
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
|
|
||||||
* upserts it into the device registry on every SSE open — the HTTP
|
|
||||||
* equivalent of the old WS hello upsert). */
|
|
||||||
private val deviceName: String? = null,
|
|
||||||
/** Live push-token providers, read per request so a rotated FCM token or
|
|
||||||
* a fresh ntfy topic is picked up without rebuilding the client. */
|
|
||||||
private val fcmToken: () -> String? = { null },
|
|
||||||
private val ntfyTopic: () -> String? = { null },
|
|
||||||
) {
|
|
||||||
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
|
|
||||||
* with the same token can't succeed. */
|
|
||||||
class HttpAuthException : IOException("unauthorized (HTTP 401)")
|
|
||||||
|
|
||||||
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
|
|
||||||
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
|
|
||||||
// with extended call timeouts (see the *Client() helpers below).
|
|
||||||
private val healthClient: OkHttpClient = client.healthClient()
|
|
||||||
private val streamClient: OkHttpClient = client.streamClient()
|
|
||||||
private val pollClient: OkHttpClient = client.pollClient()
|
|
||||||
private val mediaClient: OkHttpClient = client.mediaClient()
|
|
||||||
|
|
||||||
/** Close the per-purpose clients (and their idle connections). The
|
|
||||||
* shared base [client] is owned by the caller. */
|
|
||||||
fun close() {
|
|
||||||
healthClient.dispatcher.executorService.shutdown()
|
|
||||||
streamClient.dispatcher.executorService.shutdown()
|
|
||||||
pollClient.dispatcher.executorService.shutdown()
|
|
||||||
mediaClient.dispatcher.executorService.shutdown()
|
|
||||||
healthClient.connectionPool.evictAll()
|
|
||||||
streamClient.connectionPool.evictAll()
|
|
||||||
pollClient.connectionPool.evictAll()
|
|
||||||
mediaClient.connectionPool.evictAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /v1/frame result. [frame] is the handler's synchronous reply
|
|
||||||
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
|
|
||||||
* 202 accept-and-ack. */
|
|
||||||
data class PostResult(
|
|
||||||
val ok: Boolean,
|
|
||||||
val status: Int,
|
|
||||||
val frame: Frame?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Long-poll result: new high-water [cursor] + frames with cursor >
|
|
||||||
* the requested one (may be empty at timeout). */
|
|
||||||
data class PollResult(
|
|
||||||
val cursor: Long,
|
|
||||||
val frames: List<Frame>,
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val JSON = "application/json".toMediaType()
|
|
||||||
|
|
||||||
/** Default port of the gateway's HTTP leg (WS default is 8790). */
|
|
||||||
const val DEFAULT_PORT = 8791
|
|
||||||
|
|
||||||
/** Media transfer chunk (docs/07 §7.5). */
|
|
||||||
private const val MEDIA_CHUNK_BYTES = 256 * 1024
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
|
|
||||||
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
|
|
||||||
* a different service, so the port is always replaced with the
|
|
||||||
* HTTP leg's default. Pure function (unit-tested).
|
|
||||||
*/
|
|
||||||
fun deriveHttpUrl(wsUrl: String): String {
|
|
||||||
val u = wsUrl.trim()
|
|
||||||
val (scheme, rest) =
|
|
||||||
when {
|
|
||||||
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
|
|
||||||
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
|
|
||||||
else -> return u // already http(s)
|
|
||||||
}
|
|
||||||
val authority = rest.substringBefore('/')
|
|
||||||
val host = authority.substringBefore(':')
|
|
||||||
return "$scheme://$host:$DEFAULT_PORT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun authHeaders(): Headers {
|
|
||||||
val b =
|
|
||||||
Headers
|
|
||||||
.Builder()
|
|
||||||
.add("Authorization", "Bearer ${token()}")
|
|
||||||
.add("X-Iris-Device", deviceId)
|
|
||||||
// Device registration (docs/19): the gateway upserts name + push
|
|
||||||
// tokens from these headers on every SSE open (COALESCE — absent
|
|
||||||
// headers never clobber a newer fcm.register value).
|
|
||||||
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
|
|
||||||
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
|
|
||||||
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
|
|
||||||
// Release version (repo-root VERSION baked in at build time); the
|
|
||||||
// gateway stores it in the device registry (docs/04 hello.ack note).
|
|
||||||
b.add("X-Iris-App-Version", AppVersion.VERSION)
|
|
||||||
return b.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse a response body as a protocol frame (null when not a frame,
|
|
||||||
* e.g. the plain `{"ok":true}` ack). */
|
|
||||||
private fun parseFrame(body: String): Frame? =
|
|
||||||
try {
|
|
||||||
if (body.startsWith("{")) {
|
|
||||||
val obj = IrisJson.instance.parseToJsonElement(body)
|
|
||||||
if (obj.jsonObject.containsKey("type")) {
|
|
||||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Liveness probe (unauthenticated by design). True on 200. */
|
|
||||||
suspend fun health(): Boolean =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val request =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url("$baseUrl/v1/health")
|
|
||||||
.build()
|
|
||||||
healthClient
|
|
||||||
.newCall(request)
|
|
||||||
.execute()
|
|
||||||
.use { response ->
|
|
||||||
response.body?.close()
|
|
||||||
response.code == 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
|
|
||||||
* (with the synchronous reply frame when the handler sent one); 4xx ->
|
|
||||||
* the error frame as the body. Network failures (timeout, reset, DNS —
|
|
||||||
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
|
|
||||||
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
|
|
||||||
* callers are fire-and-forget coroutines — an uncaught exception here
|
|
||||||
* kills the app process.
|
|
||||||
*/
|
|
||||||
suspend fun postFrame(frame: Frame): PostResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
|
|
||||||
val request =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url("$baseUrl/v1/frame")
|
|
||||||
.headers(authHeaders())
|
|
||||||
.post(wire.toRequestBody(JSON))
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client
|
|
||||||
.newCall(request)
|
|
||||||
.execute()
|
|
||||||
.use { response ->
|
|
||||||
val body = response.body?.string().orEmpty()
|
|
||||||
val parsed = parseFrame(body)
|
|
||||||
PostResult(response.isSuccessful, response.code, parsed)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
|
|
||||||
PostResult(ok = false, status = 0, frame = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One SSE connection attempt: outbox catch-up from [cursor], then live
|
|
||||||
* frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
|
|
||||||
* leg is proven; the server may still replay a large outbox before the
|
|
||||||
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
|
|
||||||
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
|
|
||||||
* cursor) when present; [onKeepAlive] for SSE comment lines (the
|
|
||||||
* heartbeat) so callers can count keep-alives toward liveness. Returns
|
|
||||||
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
|
|
||||||
* on the IO thread.
|
|
||||||
*/
|
|
||||||
suspend fun events(
|
|
||||||
cursor: Long,
|
|
||||||
onOpen: (() -> Unit)? = null,
|
|
||||||
onHello: (HelloAckPayload) -> Unit,
|
|
||||||
onFrame: (Frame) -> Unit,
|
|
||||||
onCursor: (Long) -> Unit,
|
|
||||||
onKeepAlive: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val request =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url("$baseUrl/v1/events?cursor=$cursor")
|
|
||||||
.headers(authHeaders())
|
|
||||||
.build()
|
|
||||||
streamClient
|
|
||||||
.newCall(request)
|
|
||||||
.execute()
|
|
||||||
.use { response ->
|
|
||||||
if (response.code == 401) throw HttpAuthException()
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw IOException("SSE open failed: HTTP ${response.code}")
|
|
||||||
}
|
|
||||||
onOpen?.invoke()
|
|
||||||
val source = response.body?.source() ?: throw IOException("empty SSE body")
|
|
||||||
var eventId: String? = null
|
|
||||||
val dataLines = mutableListOf<String>()
|
|
||||||
while (true) {
|
|
||||||
val line = source.readUtf8Line() ?: break // EOF
|
|
||||||
when {
|
|
||||||
line.isEmpty() -> {
|
|
||||||
if (dataLines.isNotEmpty()) {
|
|
||||||
val data = dataLines.joinToString("\n")
|
|
||||||
try {
|
|
||||||
val frame =
|
|
||||||
IrisJson.instance.decodeFromString(
|
|
||||||
Frame.serializer(),
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
when (eventId) {
|
|
||||||
"hello" -> {
|
|
||||||
onHello(
|
|
||||||
frame.payloadAs<HelloAckPayload>()
|
|
||||||
?: HelloAckPayload(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
onFrame(frame)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventId = null
|
|
||||||
dataLines.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith(":") -> {
|
|
||||||
// heartbeat comment
|
|
||||||
onKeepAlive?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith("id:") -> {
|
|
||||||
line
|
|
||||||
.removePrefix("id:")
|
|
||||||
.trim()
|
|
||||||
.toLongOrNull()
|
|
||||||
?.let(onCursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith("event:") -> {
|
|
||||||
eventId = line.removePrefix("event:").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith("data:") -> {
|
|
||||||
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
|
|
||||||
* Returns the new high-water cursor + any frames with cursor > [cursor].
|
|
||||||
*/
|
|
||||||
suspend fun poll(cursor: Long): PollResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val request =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url("$baseUrl/v1/poll?cursor=$cursor")
|
|
||||||
.headers(authHeaders())
|
|
||||||
.build()
|
|
||||||
pollClient
|
|
||||||
.newCall(request)
|
|
||||||
.execute()
|
|
||||||
.use { response ->
|
|
||||||
if (response.code == 401) throw HttpAuthException()
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw IOException("poll failed: HTTP ${response.code}")
|
|
||||||
}
|
|
||||||
val body = response.body?.string().orEmpty()
|
|
||||||
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
|
|
||||||
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
|
|
||||||
val frames =
|
|
||||||
obj["frames"]
|
|
||||||
?.jsonArray
|
|
||||||
?.mapNotNull { el ->
|
|
||||||
try {
|
|
||||||
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}.orEmpty()
|
|
||||||
PollResult(newCursor, frames)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
|
|
||||||
* with the whole file as the body and the metadata in `X-Iris-Media-*`
|
|
||||||
* headers (sha256 precomputed in a first pass). Returns the server's
|
|
||||||
* media_ref (for message.send media_refs) on success.
|
|
||||||
*/
|
|
||||||
suspend fun uploadMedia(
|
|
||||||
path: String,
|
|
||||||
mime: String,
|
|
||||||
kind: String,
|
|
||||||
filename: String,
|
|
||||||
mediaRef: String,
|
|
||||||
): Result<String> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val file = File(path)
|
|
||||||
if (!file.isFile() || file.length() <= 0) {
|
|
||||||
return@withContext Result.failure(IllegalStateException("empty file"))
|
|
||||||
}
|
|
||||||
val sha = Sha256()
|
|
||||||
file.inputStream().use { ins ->
|
|
||||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
|
||||||
while (true) {
|
|
||||||
val n = ins.read(buf)
|
|
||||||
if (n < 0) break
|
|
||||||
if (n > 0) sha.update(buf, 0, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val request =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url("$baseUrl/v1/media")
|
|
||||||
.headers(
|
|
||||||
authHeaders()
|
|
||||||
.newBuilder()
|
|
||||||
.add("X-Iris-Media-Ref", mediaRef)
|
|
||||||
.add("X-Iris-Media-Kind", kind)
|
|
||||||
.add("X-Iris-Media-Filename", filename)
|
|
||||||
.add("X-Iris-Media-Sha256", sha.hex())
|
|
||||||
.build(),
|
|
||||||
).post(file.asRequestBody(mime.toMediaType()))
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
mediaClient
|
|
||||||
.newCall(request)
|
|
||||||
.execute()
|
|
||||||
.use { response ->
|
|
||||||
val body = response.body?.string().orEmpty()
|
|
||||||
val frame = parseFrame(body)
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
val p = frame?.payloadAs<MediaUploadAckPayload>()
|
|
||||||
if (p != null && p.ok) {
|
|
||||||
Result.success(p.mediaRef)
|
|
||||||
} else {
|
|
||||||
Result.failure(IllegalStateException("upload rejected by server"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val e = frame?.payloadAs<ErrorPayload>()
|
|
||||||
Result.failure(
|
|
||||||
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Network failure mid-upload: report, don't throw (the caller
|
|
||||||
// is a fire-and-forget coroutine — an uncaught exception kills
|
|
||||||
// the app process).
|
|
||||||
IrisLog.w("media upload failed: ${e.message}")
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
|
|
||||||
* response body is the file, streamed to [onChunk] (write to cache).
|
|
||||||
*/
|
|
||||||
suspend fun pullMedia(
|
|
||||||
mediaId: String,
|
|
||||||
onChunk: suspend (ByteArray) -> Unit,
|
|
||||||
): Result<Unit> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
// Server-controlled id: reject path-traversal / URL-breaking
|
|
||||||
// values before they reach the request line (M-2 / S-1).
|
|
||||||
if (!isValidMediaId(mediaId)) {
|
|
||||||
return@withContext Result.failure(IllegalStateException("invalid media id"))
|
|
||||||
}
|
|
||||||
val request =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url("$baseUrl/v1/media/$mediaId")
|
|
||||||
.headers(authHeaders())
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
mediaClient
|
|
||||||
.newCall(request)
|
|
||||||
.execute()
|
|
||||||
.use { response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
|
|
||||||
Result.failure(
|
|
||||||
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
val source = response.body?.source() ?: throw IOException("empty body")
|
|
||||||
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
|
||||||
while (true) {
|
|
||||||
val n = source.read(buf)
|
|
||||||
if (n < 0) break
|
|
||||||
if (n == 0) continue
|
|
||||||
onChunk(buf.copyOfRange(0, n))
|
|
||||||
}
|
|
||||||
Result.success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Network failure before/while opening the pull: report, don't
|
|
||||||
// throw (fire-and-forget caller — uncaught = process death).
|
|
||||||
IrisLog.w("media pull failed: ${e.message}")
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
|
|
||||||
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
|
|
||||||
* (25 s) — it would kill both receive paths while they are simply waiting
|
|
||||||
* for the next byte, so the streaming clients override it. The read timeout
|
|
||||||
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
|
|
||||||
* comment every 15 s; a healthy poll answers within 25 s).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
|
|
||||||
* heartbeat (detects a dead connection within 45 s). */
|
|
||||||
internal fun OkHttpClient.streamClient(): OkHttpClient =
|
|
||||||
newBuilder()
|
|
||||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
|
||||||
.readTimeout(45_000, TimeUnit.MILLISECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
|
|
||||||
* hold + 15 s margin. */
|
|
||||||
internal fun OkHttpClient.pollClient(): OkHttpClient =
|
|
||||||
newBuilder()
|
|
||||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
|
||||||
.readTimeout(40_000, TimeUnit.MILLISECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
internal fun OkHttpClient.healthClient(): OkHttpClient =
|
|
||||||
newBuilder()
|
|
||||||
.callTimeout(2_000, TimeUnit.MILLISECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/** Media transfers (upload/pull) can take a while on large files. */
|
|
||||||
internal fun OkHttpClient.mediaClient(): OkHttpClient =
|
|
||||||
newBuilder()
|
|
||||||
.callTimeout(300_000, TimeUnit.MILLISECONDS)
|
|
||||||
.build()
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package iris.net
|
|
||||||
|
|
||||||
import iris.protocol.IrisJson
|
|
||||||
import iris.util.IrisLog
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Latest-release check (docs/04 hello.ack note): the repo is public on Gitea,
|
|
||||||
* so the app can ask for the newest release tag without any token. Settings
|
|
||||||
* shows "vX.Y.Z available" when the running build is older.
|
|
||||||
*
|
|
||||||
* Best-effort by design: any failure (offline, DNS, rate limit) just yields
|
|
||||||
* `null` — the check must never surface an error in the UI.
|
|
||||||
*/
|
|
||||||
object ReleaseCheck {
|
|
||||||
const val LATEST_RELEASE_URL =
|
|
||||||
"https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One shared client for the process lifetime: an OkHttpClient owns a
|
|
||||||
* thread pool and connection pool, so it must not be rebuilt per screen
|
|
||||||
* open (and never needs explicit shutdown — the pools idle out). Short
|
|
||||||
* timeouts so a black-holed network can't linger the LaunchedEffect.
|
|
||||||
*/
|
|
||||||
private val client: OkHttpClient =
|
|
||||||
OkHttpClient
|
|
||||||
.Builder()
|
|
||||||
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The latest release version (tag without the leading "v"), or `null`
|
|
||||||
* when the check could not be performed.
|
|
||||||
*/
|
|
||||||
suspend fun latestVersion(): String? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val request =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url(LATEST_RELEASE_URL)
|
|
||||||
.get()
|
|
||||||
.build()
|
|
||||||
try {
|
|
||||||
client.newCall(request).execute().use { response: Response ->
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
IrisLog.d("ReleaseCheck: HTTP ${response.code}")
|
|
||||||
return@withContext null
|
|
||||||
}
|
|
||||||
val body = response.body?.string() ?: return@withContext null
|
|
||||||
val tag =
|
|
||||||
IrisJson
|
|
||||||
.instance
|
|
||||||
.parseToJsonElement(body)
|
|
||||||
.jsonObject
|
|
||||||
.get("tag_name")
|
|
||||||
?.jsonPrimitive
|
|
||||||
?.content
|
|
||||||
.orEmpty()
|
|
||||||
tag.removePrefix("v").ifBlank { null }
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
IrisLog.d("ReleaseCheck: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when [latest] is a newer release than [running]. Numeric
|
|
||||||
* component-wise comparison (so "0.1.10" > "0.1.9"); anything that does
|
|
||||||
* not parse as dotted numbers is treated as "not newer" — the hint is
|
|
||||||
* best-effort and must never fire for dev builds ahead of the latest
|
|
||||||
* release.
|
|
||||||
*/
|
|
||||||
fun isNewer(
|
|
||||||
latest: String,
|
|
||||||
running: String,
|
|
||||||
): Boolean {
|
|
||||||
val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
|
|
||||||
val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
|
|
||||||
if (a.isEmpty() || b.isEmpty()) return false
|
|
||||||
val n = maxOf(a.size, b.size)
|
|
||||||
for (i in 0 until n) {
|
|
||||||
val x = a.getOrElse(i) { 0 }
|
|
||||||
val y = b.getOrElse(i) { 0 }
|
|
||||||
if (x != y) return x > y
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
package iris.net
|
|
||||||
|
|
||||||
import java.security.KeyStore
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
|
||||||
import javax.net.ssl.TrustManager
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TLS certificate pinning for self-signed gateways (docs/09 §9.4).
|
|
||||||
*
|
|
||||||
* A gateway behind `IRIS_HTTP_CERT` may present a
|
|
||||||
* self-signed certificate the platform doesn't trust. Instead of forcing the
|
|
||||||
* user to install it into the system trust store, the app shows the
|
|
||||||
* certificate's SHA-256 fingerprint on first pair (SSH host-key style); once
|
|
||||||
* the user confirms it, the fingerprint is pinned in secure storage and
|
|
||||||
* [PinningTrustManager] accepts exactly that certificate from then on.
|
|
||||||
*
|
|
||||||
* Hostname verification is NOT bypassed: OkHttp runs its own hostname check
|
|
||||||
* on top of the trust manager, so a pinned cert still has to match the URL's
|
|
||||||
* host. A *changed* certificate (different fingerprint) fails again with
|
|
||||||
* [TlsFingerprintRequired] — the user must re-confirm, like a changed SSH
|
|
||||||
* host key.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The gateway presented a certificate the platform doesn't trust and that
|
|
||||||
* doesn't match the pinned fingerprint. Carries the SHA-256 fingerprint the
|
|
||||||
* user must confirm. Thrown by [PinningTrustManager] during the handshake;
|
|
||||||
* the JSSE/OkHttp layers wrap it, so find it with [tlsFingerprintRequired].
|
|
||||||
*/
|
|
||||||
class TlsFingerprintRequired(
|
|
||||||
val fingerprint: String,
|
|
||||||
) : CertificateException("gateway certificate not trusted (fingerprint $fingerprint)")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SHA-256 of the certificate's DER encoding, colon-separated byte pairs
|
|
||||||
* (SSH host-key style) — the string the user verifies on the gateway host.
|
|
||||||
*/
|
|
||||||
fun certFingerprint(cert: X509Certificate): String =
|
|
||||||
MessageDigest
|
|
||||||
.getInstance("SHA-256")
|
|
||||||
.digest(cert.encoded)
|
|
||||||
.joinToString(":") { String.format("%02X", it) }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps the platform default trust manager:
|
|
||||||
* - CA-signed certs behave exactly as before (the default manager decides).
|
|
||||||
* - A cert the default manager REJECTS is accepted only when its fingerprint
|
|
||||||
* equals the user-confirmed pin ([pinnedFingerprint], read live so a fresh
|
|
||||||
* pin is picked up without rebuilding the client).
|
|
||||||
* - Anything else fails with [TlsFingerprintRequired] carrying the
|
|
||||||
* presented fingerprint, so the UI can offer the confirm dialog.
|
|
||||||
*/
|
|
||||||
class PinningTrustManager(
|
|
||||||
private val pinnedFingerprint: () -> String,
|
|
||||||
) : X509TrustManager {
|
|
||||||
private val default: X509TrustManager = defaultTrustManager()
|
|
||||||
|
|
||||||
override fun checkClientTrusted(
|
|
||||||
chain: Array<X509Certificate>,
|
|
||||||
authType: String,
|
|
||||||
) {
|
|
||||||
default.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = default.acceptedIssuers
|
|
||||||
|
|
||||||
override fun checkServerTrusted(
|
|
||||||
chain: Array<X509Certificate>,
|
|
||||||
authType: String,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
default.checkServerTrusted(chain, authType)
|
|
||||||
} catch (e: CertificateException) {
|
|
||||||
val fp = certFingerprint(chain.first())
|
|
||||||
if (pinnedFingerprint().isNotBlank() && fp == pinnedFingerprint()) return
|
|
||||||
throw TlsFingerprintRequired(fp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The platform default server trust manager (the JDK's CA store). */
|
|
||||||
fun defaultTrustManager(): X509TrustManager {
|
|
||||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
factory.init(null as KeyStore?)
|
|
||||||
return (factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager)
|
|
||||||
?: error("no X509TrustManager in the default trust store")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An [SSLSocketFactory] that trusts via [tm] (the pinning manager). */
|
|
||||||
fun pinningSslSocketFactory(tm: X509TrustManager): SSLSocketFactory {
|
|
||||||
val ctx = SSLContext.getInstance("TLS")
|
|
||||||
ctx.init(null, arrayOf<TrustManager>(tm), SecureRandom())
|
|
||||||
return ctx.socketFactory
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unwrap a [TlsFingerprintRequired] from a (possibly nested) transport
|
|
||||||
* exception: the trust manager throws it during the handshake and the
|
|
||||||
* JSSE/OkHttp layers wrap it in SSLHandshakeException/IOException. Null when
|
|
||||||
* the failure has nothing to do with an untrusted gateway certificate.
|
|
||||||
*/
|
|
||||||
fun tlsFingerprintRequired(e: Throwable): TlsFingerprintRequired? {
|
|
||||||
var t: Throwable? = e
|
|
||||||
var depth = 0
|
|
||||||
while (t != null && depth < 10) {
|
|
||||||
if (t is TlsFingerprintRequired) return t
|
|
||||||
t = t.cause
|
|
||||||
depth++
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -35,49 +35,21 @@ expect fun mediaCacheBaseDir(): String
|
|||||||
expect suspend fun loadMediaImage(path: String): ImageBitmap?
|
expect suspend fun loadMediaImage(path: String): ImageBitmap?
|
||||||
|
|
||||||
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
|
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
|
||||||
expect suspend fun loadScaledImage(
|
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
|
||||||
path: String,
|
|
||||||
maxSize: Int,
|
|
||||||
): ImageBitmap?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode an image from raw bytes (bundled backdrops), scaled to at most
|
|
||||||
* [maxSize] px on the longest edge. Null on failure.
|
|
||||||
*/
|
|
||||||
expect suspend fun decodeImageBytes(
|
|
||||||
bytes: ByteArray,
|
|
||||||
maxSize: Int,
|
|
||||||
): ImageBitmap?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a bundled backdrop image (shipped in the app) by its [id] (the file
|
|
||||||
* name without extension, e.g. `pexels-yunszyveli-12368637`). Android reads it
|
|
||||||
* from `assets/backdrops/`, desktop from the classpath. Null on failure.
|
|
||||||
*/
|
|
||||||
expect fun readBackdropBytes(id: String): ByteArray?
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
|
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
|
||||||
* the longest edge. Null on failure. Used for channel icons (stored on the
|
* the longest edge. Null on failure. Used for channel icons (stored on the
|
||||||
* gateway, synced across devices).
|
* gateway, synced across devices).
|
||||||
*/
|
*/
|
||||||
expect suspend fun encodeImageAsBase64(
|
expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String?
|
||||||
path: String,
|
|
||||||
maxSize: Int,
|
|
||||||
): String?
|
|
||||||
|
|
||||||
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
|
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
|
||||||
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
|
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
|
||||||
|
|
||||||
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
|
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
|
||||||
@Composable
|
@Composable
|
||||||
expect fun MediaPlayerView(
|
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
|
||||||
media: MediaItem,
|
|
||||||
modifier: Modifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Open a document with the system handler (best effort). */
|
/** Open a document with the system handler (best effort). */
|
||||||
expect fun openDocument(
|
expect fun openDocument(path: String, mime: String)
|
||||||
path: String,
|
|
||||||
mime: String,
|
|
||||||
)
|
|
||||||
@@ -6,7 +6,7 @@ package iris.platform
|
|||||||
* The controller (commonMain) needs to know whether the app is in the
|
* The controller (commonMain) needs to know whether the app is in the
|
||||||
* foreground (to decide between an in-app banner and a system notification)
|
* foreground (to decide between an in-app banner and a system notification)
|
||||||
* and to post a system notification when a `notification` frame arrives while
|
* and to post a system notification when a `notification` frame arrives while
|
||||||
* the app is backgrounded but the gateway connection is still live.
|
* the app is backgrounded but the WS is still live.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** True when the app's UI is visible (Android: activity resumed). */
|
/** True when the app's UI is visible (Android: activity resumed). */
|
||||||
@@ -29,12 +29,3 @@ expect fun postSystemNotification(
|
|||||||
* FCM / ntfy services can reach it). No-op on desktop.
|
* FCM / ntfy services can reach it). No-op on desktop.
|
||||||
*/
|
*/
|
||||||
expect fun setActiveController(controller: Any?)
|
expect fun setActiveController(controller: Any?)
|
||||||
|
|
||||||
/**
|
|
||||||
* Align the ntfy listener with the paired gateway's push backend (Android:
|
|
||||||
* start/stop the listener foreground service — and with it the permanent
|
|
||||||
* "Listening for messages" notification; no-op on desktop). The listener
|
|
||||||
* only runs when the gateway actually pushes via ntfy; FCM gateways need
|
|
||||||
* no persistent listener.
|
|
||||||
*/
|
|
||||||
expect fun syncNtfyListener(backend: String)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package iris.platform
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A "Scan QR" button that launches the platform QR scanner and delivers the
|
|
||||||
* scanned text (or null if the user cancelled) to [onResult] (docs/20).
|
|
||||||
*
|
|
||||||
* Android: opens [QrScanActivity] (CameraX + ML Kit). Desktop: renders nothing
|
|
||||||
* (the Connect screen hides it there).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
expect fun QrScanButton(onResult: (String?) -> Unit)
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package iris.platform
|
|
||||||
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persistent app-data directory. Unlike the media cache dir, it is not wiped
|
|
||||||
* by the system or by the user clearing the app cache — it holds the SQLite
|
|
||||||
* local cache (messages / channels / meta).
|
|
||||||
*/
|
|
||||||
expect fun appDataDir(): String
|
|
||||||
|
|
||||||
/** Open the SQLite driver for the local cache database. */
|
|
||||||
expect fun createCacheDriver(): SqlDriver
|
|
||||||
@@ -12,16 +12,15 @@ import kotlinx.serialization.json.put
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
|
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
|
||||||
* See docs/04-wire-protocol.md. Defines all frame types and payloads:
|
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
|
||||||
* hello/hello.ack, message (send + streaming), tool cards, commentary,
|
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
|
||||||
* reasoning, channels, media, notifications, sync, and error frames.
|
* progress/end, commentary, reasoning (on message / message.stop).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const val PROTOCOL_VERSION = 1
|
const val PROTOCOL_VERSION = 1
|
||||||
|
|
||||||
object IrisJson {
|
object IrisJson {
|
||||||
val instance: Json =
|
val instance: Json = Json {
|
||||||
Json {
|
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
isLenient = true
|
isLenient = true
|
||||||
@@ -30,10 +29,13 @@ object IrisJson {
|
|||||||
|
|
||||||
// ── Frame type constants ────────────────────────────────────────────────
|
// ── Frame type constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const val TYPE_HELLO = "hello"
|
||||||
const val TYPE_HELLO_ACK = "hello.ack"
|
const val TYPE_HELLO_ACK = "hello.ack"
|
||||||
const val TYPE_MESSAGE = "message"
|
const val TYPE_MESSAGE = "message"
|
||||||
const val TYPE_MESSAGE_SEND = "message.send"
|
const val TYPE_MESSAGE_SEND = "message.send"
|
||||||
const val TYPE_ERROR = "error"
|
const val TYPE_ERROR = "error"
|
||||||
|
const val TYPE_PING = "ping"
|
||||||
|
const val TYPE_PONG = "pong"
|
||||||
const val TYPE_TYPING = "typing"
|
const val TYPE_TYPING = "typing"
|
||||||
|
|
||||||
// M2 — streaming / tools / commentary
|
// M2 — streaming / tools / commentary
|
||||||
@@ -47,16 +49,15 @@ const val TYPE_MESSAGE_DELETED = "message.deleted"
|
|||||||
const val TYPE_TOOL_START = "tool.start"
|
const val TYPE_TOOL_START = "tool.start"
|
||||||
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
||||||
const val TYPE_TOOL_END = "tool.end"
|
const val TYPE_TOOL_END = "tool.end"
|
||||||
|
|
||||||
// Agent todo list (live planning state; ephemeral, never outboxed — a
|
|
||||||
// reconnecting device re-learns it from the snapshot after hello.ack).
|
|
||||||
const val TYPE_TODO_UPDATE = "todo.update"
|
|
||||||
|
|
||||||
const val TYPE_COMMENTARY = "commentary"
|
const val TYPE_COMMENTARY = "commentary"
|
||||||
|
|
||||||
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
|
// M4 — media (upload / offer / pull)
|
||||||
|
const val TYPE_MEDIA_UPLOAD_START = "media.upload.start"
|
||||||
|
const val TYPE_MEDIA_UPLOAD_END = "media.upload.end"
|
||||||
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
||||||
const val TYPE_MEDIA_OFFER = "media.offer"
|
const val TYPE_MEDIA_OFFER = "media.offer"
|
||||||
|
const val TYPE_MEDIA_PULL = "media.pull"
|
||||||
|
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
||||||
|
|
||||||
// M5 — push / notifications / read receipt / gateway status
|
// M5 — push / notifications / read receipt / gateway status
|
||||||
const val TYPE_NOTIFICATION = "notification"
|
const val TYPE_NOTIFICATION = "notification"
|
||||||
@@ -70,7 +71,6 @@ const val TYPE_CHANNEL_RENAME = "channel.rename"
|
|||||||
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
|
||||||
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
|
||||||
const val TYPE_CHANNEL_ICON = "channel.icon"
|
const val TYPE_CHANNEL_ICON = "channel.icon"
|
||||||
const val TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
|
|
||||||
const val TYPE_CHANNEL_DELETE = "channel.delete"
|
const val TYPE_CHANNEL_DELETE = "channel.delete"
|
||||||
const val TYPE_CHANNEL_CREATED = "channel.created"
|
const val TYPE_CHANNEL_CREATED = "channel.created"
|
||||||
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
|
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
|
||||||
@@ -78,22 +78,24 @@ const val TYPE_CHANNEL_DELETED = "channel.deleted"
|
|||||||
const val TYPE_CHANNEL_LIST = "channel.list"
|
const val TYPE_CHANNEL_LIST = "channel.list"
|
||||||
const val TYPE_SEARCH = "search"
|
const val TYPE_SEARCH = "search"
|
||||||
const val TYPE_SEARCH_RESULTS = "search.results"
|
const val TYPE_SEARCH_RESULTS = "search.results"
|
||||||
|
|
||||||
// Slash-command catalog (the composer's "/" drawer)
|
|
||||||
const val TYPE_COMMANDS_CATALOG = "commands.catalog"
|
|
||||||
|
|
||||||
// Interactive pickers (slash-command choice menus, e.g. /reasoning, /fast)
|
|
||||||
const val TYPE_PICKER_CHOICE = "picker.choice"
|
|
||||||
const val TYPE_PICKER_SELECT = "picker.select"
|
|
||||||
const val TYPE_SYNC = "sync"
|
const val TYPE_SYNC = "sync"
|
||||||
const val TYPE_SYNC_DONE = "sync.done"
|
const val TYPE_SYNC_DONE = "sync.done"
|
||||||
const val TYPE_HISTORY = "history"
|
const val TYPE_HISTORY = "history"
|
||||||
|
|
||||||
|
// ── Error codes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const val ERR_AUTH = "auth"
|
||||||
|
const val ERR_NOT_FOUND = "not_found"
|
||||||
|
const val ERR_UNSUPPORTED = "unsupported"
|
||||||
|
const val ERR_INTERNAL = "internal"
|
||||||
|
const val ERR_MEDIA_TOO_LARGE = "media_too_large"
|
||||||
|
|
||||||
// M4 — media kinds (docs/07 §7.1)
|
// M4 — media kinds (docs/07 §7.1)
|
||||||
const val KIND_IMAGE = "image"
|
const val KIND_IMAGE = "image"
|
||||||
const val KIND_AUDIO = "audio"
|
const val KIND_AUDIO = "audio"
|
||||||
const val KIND_VIDEO = "video"
|
const val KIND_VIDEO = "video"
|
||||||
const val KIND_DOCUMENT = "document"
|
const val KIND_DOCUMENT = "document"
|
||||||
|
const val KIND_VOICE = "voice"
|
||||||
|
|
||||||
// ── Roles ───────────────────────────────────────────────────────────────
|
// ── Roles ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -109,11 +111,6 @@ data class Frame(
|
|||||||
val type: String,
|
val type: String,
|
||||||
@SerialName("chat_id") val chatId: String? = null,
|
@SerialName("chat_id") val chatId: String? = null,
|
||||||
@SerialName("thread_id") val threadId: String? = null,
|
@SerialName("thread_id") val threadId: String? = null,
|
||||||
/** Outbox cursor this frame was parked under. Set only on frames
|
|
||||||
* replayed by `sync` (live frames carry none) — the app skips
|
|
||||||
* re-notifying replayed frames with `cursor <= lastPushedCursor`
|
|
||||||
* (they already woke the device via push, docs/08 §8.7). */
|
|
||||||
val cursor: Long? = null,
|
|
||||||
val payload: JsonElement = JsonObject(emptyMap()),
|
val payload: JsonElement = JsonObject(emptyMap()),
|
||||||
) {
|
) {
|
||||||
/** Payload as a JSON object (the wire format); parse per-type with
|
/** Payload as a JSON object (the wire format); parse per-type with
|
||||||
@@ -126,6 +123,18 @@ data class Frame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── hello (app -> server) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HelloPayload(
|
||||||
|
val token: String,
|
||||||
|
@SerialName("device_id") val deviceId: String,
|
||||||
|
@SerialName("device_name") val deviceName: String,
|
||||||
|
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
|
||||||
|
@SerialName("fcm_token") val fcmToken: String? = null,
|
||||||
|
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
// ── hello.ack (server -> app) ───────────────────────────────────────────
|
// ── hello.ack (server -> app) ───────────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -135,13 +144,9 @@ data class ServerCaps(
|
|||||||
val tools: Boolean = false,
|
val tools: Boolean = false,
|
||||||
val media: Boolean = false,
|
val media: Boolean = false,
|
||||||
val search: Boolean = false,
|
val search: Boolean = false,
|
||||||
/** The gateway answers `commands.catalog` (the composer's "/" drawer). */
|
|
||||||
@SerialName("commands_catalog") val commandsCatalog: Boolean = false,
|
|
||||||
val push: String = "fcm",
|
val push: String = "fcm",
|
||||||
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
|
||||||
val pickers: Boolean = false,
|
val pickers: Boolean = false,
|
||||||
/** Release version of the gateway plugin (repo-root VERSION file). */
|
|
||||||
@SerialName("app_version") val appVersion: String = "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -152,9 +157,6 @@ data class ChannelInfo(
|
|||||||
@SerialName("is_default") val isDefault: Boolean = false,
|
@SerialName("is_default") val isDefault: Boolean = false,
|
||||||
@SerialName("parent_chat_id") val parentChatId: String? = null,
|
@SerialName("parent_chat_id") val parentChatId: String? = null,
|
||||||
val archived: Boolean = false,
|
val archived: Boolean = false,
|
||||||
/** Unix timestamp (seconds) when the channel/thread was created; 0.0 if
|
|
||||||
* the gateway predates the field. Used to order threads newest-first. */
|
|
||||||
val created: Double = 0.0,
|
|
||||||
/** Gateway minted this thread for an incoming message (auto-threading);
|
/** Gateway minted this thread for an incoming message (auto-threading);
|
||||||
* the name is a derived title, upgraded by the LLM via channel.renamed. */
|
* the name is a derived title, upgraded by the LLM via channel.renamed. */
|
||||||
val auto: Boolean = false,
|
val auto: Boolean = false,
|
||||||
@@ -166,10 +168,6 @@ data class ChannelInfo(
|
|||||||
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
|
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
|
||||||
* auto-generated name-hash color. */
|
* auto-generated name-hash color. */
|
||||||
val color: String? = null,
|
val color: String? = null,
|
||||||
/** Automation channel: read-only for the user. It only receives
|
|
||||||
* gateway-originated output (cron jobs, webhooks); the app hides the
|
|
||||||
* composer and the gateway rejects direct sends into it. */
|
|
||||||
val automation: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -177,35 +175,10 @@ data class HelloAckPayload(
|
|||||||
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
|
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
|
||||||
@SerialName("sync_cursor") val syncCursor: Long = 0,
|
@SerialName("sync_cursor") val syncCursor: Long = 0,
|
||||||
val channels: List<ChannelInfo> = emptyList(),
|
val channels: List<ChannelInfo> = emptyList(),
|
||||||
/** M5: highest outbox cursor already delivered to THIS device via the
|
|
||||||
* push backend (0 = never). Sync-replayed frames at/below it must not
|
|
||||||
* re-post system notifications (dedupe, docs/08 §8.7). */
|
|
||||||
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0,
|
|
||||||
/** Per-device token minted at pairing (docs/09 §9.3). The app stores it
|
|
||||||
* and presents it INSTEAD of the shared IRIS_TOKEN from then on; the
|
|
||||||
* gateway can revoke it per device. Empty when the gateway didn't
|
|
||||||
* issue one (legacy). */
|
|
||||||
@SerialName("device_token") val deviceToken: String = "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── message (server -> app) ─────────────────────────────────────────────
|
// ── message (server -> app) ─────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Structured runtime metadata for the app's footer (mirror of the gateway's
|
|
||||||
* `runtime` object). The gateway ALWAYS sends this on final assistant
|
|
||||||
* messages; whether/what is shown is a per-app setting (Settings → Runtime
|
|
||||||
* footer), NOT a hermes config. Fields are absent when the data is
|
|
||||||
* unavailable (local models have no cost, etc.).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class RuntimeMeta(
|
|
||||||
val model: String? = null,
|
|
||||||
@SerialName("context_pct") val contextPct: Int? = null,
|
|
||||||
val cwd: String? = null,
|
|
||||||
val latency: Double? = null,
|
|
||||||
val cost: Double? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MessagePayload(
|
data class MessagePayload(
|
||||||
@SerialName("message_id") val messageId: String,
|
@SerialName("message_id") val messageId: String,
|
||||||
@@ -215,7 +188,6 @@ data class MessagePayload(
|
|||||||
@SerialName("reply_to") val replyTo: String? = null,
|
@SerialName("reply_to") val replyTo: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val tokens: Int? = null,
|
val tokens: Int? = null,
|
||||||
val runtime: RuntimeMeta? = null,
|
|
||||||
val ts: Long? = null,
|
val ts: Long? = null,
|
||||||
val media: List<MediaRef> = emptyList(),
|
val media: List<MediaRef> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -232,6 +204,21 @@ data class MediaRef(
|
|||||||
val filename: String,
|
val filename: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MediaUploadStartPayload(
|
||||||
|
@SerialName("media_ref") val mediaRef: String,
|
||||||
|
val kind: String,
|
||||||
|
val mime: String,
|
||||||
|
val filename: String,
|
||||||
|
val size: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MediaUploadEndPayload(
|
||||||
|
@SerialName("media_ref") val mediaRef: String,
|
||||||
|
@SerialName("sha256") val sha256: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaUploadAckPayload(
|
data class MediaUploadAckPayload(
|
||||||
val ok: Boolean,
|
val ok: Boolean,
|
||||||
@@ -248,6 +235,14 @@ data class MediaOfferPayload(
|
|||||||
@SerialName("message_id") val messageId: String? = null,
|
@SerialName("message_id") val messageId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MediaPullPayload(
|
||||||
|
@SerialName("media_id") val mediaId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MediaPullEndPayload(val ok: Boolean)
|
||||||
|
|
||||||
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -269,7 +264,6 @@ data class MessageStopPayload(
|
|||||||
val reasoning: String? = null,
|
val reasoning: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val tokens: Int? = null,
|
val tokens: Int? = null,
|
||||||
val runtime: RuntimeMeta? = null,
|
|
||||||
val ts: Long? = null,
|
val ts: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,9 +275,6 @@ data class ToolStartPayload(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val preview: String? = null,
|
val preview: String? = null,
|
||||||
val args: JsonElement? = null,
|
val args: JsonElement? = null,
|
||||||
/** Cosmetic per-tool glyph resolved server-side (hermes get_tool_emoji);
|
|
||||||
* absent for unknown tools — the UI falls back to its default. */
|
|
||||||
val emoji: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -302,30 +293,6 @@ data class ToolEndPayload(
|
|||||||
@SerialName("output_preview") val outputPreview: String? = null,
|
@SerialName("output_preview") val outputPreview: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── todo.update (server -> app): the agent's live todo list ────────────────
|
|
||||||
|
|
||||||
/** One item of the agent's todo list (hermes `todo` tool). [status] is one
|
|
||||||
* of [TODO_PENDING], [TODO_IN_PROGRESS], [TODO_COMPLETED], [TODO_CANCELLED]. */
|
|
||||||
@Serializable
|
|
||||||
data class TodoItem(
|
|
||||||
val id: String,
|
|
||||||
val content: String,
|
|
||||||
val status: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
const val TODO_PENDING = "pending"
|
|
||||||
const val TODO_IN_PROGRESS = "in_progress"
|
|
||||||
const val TODO_COMPLETED = "completed"
|
|
||||||
const val TODO_CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
/** The agent's FULL current todo list for a lane (last-write-wins; the
|
|
||||||
* gateway emits it whenever the `todo` tool completes — the tool result is
|
|
||||||
* authoritative even for merge writes — and as a snapshot on connect). */
|
|
||||||
@Serializable
|
|
||||||
data class TodoUpdatePayload(
|
|
||||||
val todos: List<TodoItem> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── M2: commentary frame (server -> app) ────────────────────────────────
|
// ── M2: commentary frame (server -> app) ────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -346,18 +313,16 @@ data class MessageSendPayload(
|
|||||||
@SerialName("auto_thread") val autoThread: Boolean = false,
|
@SerialName("auto_thread") val autoThread: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── typing / error ──────────────────────────────────────────────────────
|
// ── typing / error / ping ───────────────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TypingPayload(
|
data class TypingPayload(val on: Boolean)
|
||||||
val on: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ErrorPayload(
|
data class ErrorPayload(val code: String, val message: String)
|
||||||
val code: String,
|
|
||||||
val message: String,
|
@Serializable
|
||||||
)
|
data class PingPayload(val ts: Long? = null)
|
||||||
|
|
||||||
// ── M3: channel directory (app -> server requests) ──────────────────────
|
// ── M3: channel directory (app -> server requests) ──────────────────────
|
||||||
|
|
||||||
@@ -369,19 +334,10 @@ data class ChannelCreatePayload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChannelRenamePayload(
|
data class ChannelRenamePayload(val name: String)
|
||||||
val name: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChannelFavoritePayload(
|
data class ChannelFavoritePayload(val on: Boolean)
|
||||||
val on: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ChannelSetAutomationPayload(
|
|
||||||
val on: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChannelIconPayload(
|
data class ChannelIconPayload(
|
||||||
@@ -392,14 +348,10 @@ data class ChannelIconPayload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChannelListPayload(
|
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
|
||||||
val channels: List<ChannelInfo> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChannelDeletedPayload(
|
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String)
|
||||||
@SerialName("chat_id") val chatId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── M3: search ──────────────────────────────────────────────────────────
|
// ── M3: search ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -429,56 +381,13 @@ data class SearchResultsPayload(
|
|||||||
val hits: List<SearchHit> = emptyList(),
|
val hits: List<SearchHit> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── commands.catalog (slash-command catalog for the "/" drawer) ──────────
|
|
||||||
|
|
||||||
/** One slash command from the gateway catalog. [name]/[aliases] carry the
|
|
||||||
* leading slash ("/new", "/reset"); the app fuzzy-matches client-side. */
|
|
||||||
@Serializable
|
|
||||||
data class SlashCommand(
|
|
||||||
val name: String,
|
|
||||||
val description: String = "",
|
|
||||||
@SerialName("args_hint") val argsHint: String = "",
|
|
||||||
val category: String = "",
|
|
||||||
val aliases: List<String> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class CommandsCatalogPayload(
|
|
||||||
val commands: List<SlashCommand> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── picker.choice / picker.select (interactive slash-command menus) ────────
|
|
||||||
|
|
||||||
/** One option in a choice picker. [isCurrent] marks the active value (the
|
|
||||||
* UI shows a ✓ on it, like Telegram's inline keyboards). */
|
|
||||||
@Serializable
|
|
||||||
data class PickerChoice(
|
|
||||||
val value: String,
|
|
||||||
val label: String = value,
|
|
||||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** An interactive choice picker (one tap → one value), sent by finite-choice
|
|
||||||
* slash commands (/reasoning, /fast, …). The app renders it as a card with
|
|
||||||
* buttons and answers via [pickerSelectFrame]. */
|
|
||||||
@Serializable
|
|
||||||
data class PickerChoicePayload(
|
|
||||||
@SerialName("picker_id") val pickerId: String,
|
|
||||||
val title: String,
|
|
||||||
val choices: List<PickerChoice> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncPayload(
|
data class SyncPayload(val cursor: Long)
|
||||||
val cursor: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SyncDonePayload(
|
data class SyncDonePayload(val cursor: Long)
|
||||||
val cursor: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── history (full message history for a chat/thread) ────────────────────
|
// ── history (full message history for a chat/thread) ────────────────────
|
||||||
|
|
||||||
@@ -491,12 +400,8 @@ data class HistoryMessage(
|
|||||||
val reasoning: String? = null,
|
val reasoning: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val tokens: Int? = null,
|
val tokens: Int? = null,
|
||||||
val runtime: RuntimeMeta? = null,
|
|
||||||
val ts: Long? = null,
|
val ts: Long? = null,
|
||||||
// Nullable: the schema says array, but older gateways sent
|
val media: List<MediaRef> = emptyList(),
|
||||||
// `"media": null` for streaming finals — a null there used to break
|
|
||||||
// deserialization of the whole history page (silently dropping it).
|
|
||||||
val media: List<MediaRef>? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -517,9 +422,12 @@ data class MessageDeletedPayload(
|
|||||||
// ── M5: push / notifications ────────────────────────────────────────────
|
// ── M5: push / notifications ────────────────────────────────────────────
|
||||||
|
|
||||||
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
/** Notification kinds (mirror of protocol.NOTIF_*). */
|
||||||
|
const val NOTIF_MESSAGE = "message"
|
||||||
const val NOTIF_APPROVAL = "approval"
|
const val NOTIF_APPROVAL = "approval"
|
||||||
const val NOTIF_CLARIFY = "clarify"
|
const val NOTIF_CLARIFY = "clarify"
|
||||||
const val NOTIF_CRON = "cron"
|
const val NOTIF_CRON = "cron"
|
||||||
|
const val NOTIF_CHANNEL = "channel"
|
||||||
|
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
|
||||||
|
|
||||||
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */
|
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */
|
||||||
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
|
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
|
||||||
@@ -548,12 +456,31 @@ data class ReadReceiptPayload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class StatusPayload(
|
data class StatusPayload(val state: String)
|
||||||
val state: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Frame builders ──────────────────────────────────────────────────────
|
// ── Frame builders ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun helloFrame(
|
||||||
|
token: String,
|
||||||
|
deviceId: String,
|
||||||
|
deviceName: String,
|
||||||
|
fcmToken: String? = null,
|
||||||
|
ntfyTopic: String? = null,
|
||||||
|
): Frame =
|
||||||
|
Frame(
|
||||||
|
type = TYPE_HELLO,
|
||||||
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
|
HelloPayload.serializer(),
|
||||||
|
HelloPayload(
|
||||||
|
token = token,
|
||||||
|
deviceId = deviceId,
|
||||||
|
deviceName = deviceName,
|
||||||
|
fcmToken = fcmToken,
|
||||||
|
ntfyTopic = ntfyTopic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
fun messageSendFrame(
|
fun messageSendFrame(
|
||||||
id: Int,
|
id: Int,
|
||||||
chatId: String,
|
chatId: String,
|
||||||
@@ -567,107 +494,68 @@ fun messageSendFrame(
|
|||||||
type = TYPE_MESSAGE_SEND,
|
type = TYPE_MESSAGE_SEND,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
threadId = threadId,
|
threadId = threadId,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
MessageSendPayload.serializer(),
|
MessageSendPayload.serializer(),
|
||||||
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
|
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun pingFrame(): Frame =
|
||||||
|
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
|
||||||
|
|
||||||
// ── M3 frame builders ───────────────────────────────────────────────────
|
// ── M3 frame builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
fun channelCreateFrame(
|
fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame =
|
||||||
id: Int,
|
|
||||||
name: String,
|
|
||||||
kind: String = "channel",
|
|
||||||
parentChatId: String? = null,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
Frame(
|
||||||
id = id,
|
id = id,
|
||||||
type = TYPE_CHANNEL_CREATE,
|
type = TYPE_CHANNEL_CREATE,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
ChannelCreatePayload.serializer(),
|
ChannelCreatePayload.serializer(),
|
||||||
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
|
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun channelRenameFrame(
|
fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
|
||||||
id: Int,
|
|
||||||
chatId: String,
|
|
||||||
name: String,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
Frame(
|
||||||
id = id,
|
id = id,
|
||||||
type = TYPE_CHANNEL_RENAME,
|
type = TYPE_CHANNEL_RENAME,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
ChannelRenamePayload.serializer(),
|
ChannelRenamePayload.serializer(),
|
||||||
ChannelRenamePayload(name = name),
|
ChannelRenamePayload(name = name),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun channelSetDefaultFrame(
|
fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
|
||||||
id: Int,
|
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
|
||||||
chatId: String,
|
|
||||||
): Frame = Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
|
|
||||||
|
|
||||||
fun channelFavoriteFrame(
|
fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
|
||||||
id: Int,
|
|
||||||
chatId: String,
|
|
||||||
on: Boolean,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
Frame(
|
||||||
id = id,
|
id = id,
|
||||||
type = TYPE_CHANNEL_FAVORITE,
|
type = TYPE_CHANNEL_FAVORITE,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
ChannelFavoritePayload.serializer(),
|
ChannelFavoritePayload.serializer(),
|
||||||
ChannelFavoritePayload(on = on),
|
ChannelFavoritePayload(on = on),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun channelSetAutomationFrame(
|
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
|
||||||
id: Int,
|
|
||||||
chatId: String,
|
|
||||||
on: Boolean,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
id = id,
|
|
||||||
type = TYPE_CHANNEL_SET_AUTOMATION,
|
|
||||||
chatId = chatId,
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
ChannelSetAutomationPayload.serializer(),
|
|
||||||
ChannelSetAutomationPayload(on = on),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun channelIconFrame(
|
|
||||||
id: Int,
|
|
||||||
chatId: String,
|
|
||||||
icon: String?,
|
|
||||||
color: String?,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
Frame(
|
||||||
id = id,
|
id = id,
|
||||||
type = TYPE_CHANNEL_ICON,
|
type = TYPE_CHANNEL_ICON,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
ChannelIconPayload.serializer(),
|
ChannelIconPayload.serializer(),
|
||||||
ChannelIconPayload(icon = icon, color = color),
|
ChannelIconPayload(icon = icon, color = color),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun channelDeleteFrame(
|
fun channelDeleteFrame(id: Int, chatId: String): Frame =
|
||||||
id: Int,
|
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
|
||||||
chatId: String,
|
|
||||||
): Frame = Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
|
|
||||||
|
|
||||||
fun channelListFrame(id: Int): Frame = Frame(id = id, type = TYPE_CHANNEL_LIST)
|
fun channelListFrame(id: Int): Frame =
|
||||||
|
Frame(id = id, type = TYPE_CHANNEL_LIST)
|
||||||
|
|
||||||
fun searchFrame(
|
fun searchFrame(
|
||||||
id: Int,
|
id: Int,
|
||||||
@@ -681,44 +569,17 @@ fun searchFrame(
|
|||||||
type = TYPE_SEARCH,
|
type = TYPE_SEARCH,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
threadId = threadId,
|
threadId = threadId,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
SearchPayload.serializer(),
|
SearchPayload.serializer(),
|
||||||
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
|
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Request the gateway's slash-command catalog (the composer's "/" drawer).
|
fun syncFrame(id: Int, cursor: Long): Frame =
|
||||||
* Answered by a `commands.catalog` frame carrying the same id. */
|
|
||||||
fun commandsCatalogFrame(id: Int): Frame = Frame(id = id, type = TYPE_COMMANDS_CATALOG)
|
|
||||||
|
|
||||||
/** Answer an interactive picker (picker.choice) with the chosen value.
|
|
||||||
* The server runs the command's selection callback and delivers its reply
|
|
||||||
* as a normal message in the picker's chat. */
|
|
||||||
fun pickerSelectFrame(
|
|
||||||
id: Int,
|
|
||||||
pickerId: String,
|
|
||||||
value: String,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
id = id,
|
|
||||||
type = TYPE_PICKER_SELECT,
|
|
||||||
payload =
|
|
||||||
buildJsonObject {
|
|
||||||
put("picker_id", pickerId)
|
|
||||||
put("value", value)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fun syncFrame(
|
|
||||||
id: Int,
|
|
||||||
cursor: Long,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
Frame(
|
||||||
id = id,
|
id = id,
|
||||||
type = TYPE_SYNC,
|
type = TYPE_SYNC,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
SyncPayload.serializer(),
|
SyncPayload.serializer(),
|
||||||
SyncPayload(cursor = cursor),
|
SyncPayload(cursor = cursor),
|
||||||
),
|
),
|
||||||
@@ -739,8 +600,7 @@ fun historyFrame(
|
|||||||
type = TYPE_HISTORY,
|
type = TYPE_HISTORY,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
threadId = threadId,
|
threadId = threadId,
|
||||||
payload =
|
payload = buildJsonObject {
|
||||||
buildJsonObject {
|
|
||||||
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
|
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
|
||||||
put("limit", limit)
|
put("limit", limit)
|
||||||
},
|
},
|
||||||
@@ -759,22 +619,56 @@ fun messageDeleteFrame(
|
|||||||
type = TYPE_MESSAGE_DELETE,
|
type = TYPE_MESSAGE_DELETE,
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
threadId = threadId,
|
threadId = threadId,
|
||||||
payload =
|
payload = buildJsonObject {
|
||||||
buildJsonObject {
|
|
||||||
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
|
put("message_ids", JsonArray(messageIds.map { JsonPrimitive(it) }))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── M5 frame builders ───────────────────────────────────────────────────
|
// ── M4 frame builders ────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun fcmRegisterFrame(
|
fun mediaUploadStartFrame(
|
||||||
fcmToken: String? = null,
|
id: Int,
|
||||||
ntfyTopic: String? = null,
|
mediaRef: String,
|
||||||
|
kind: String,
|
||||||
|
mime: String,
|
||||||
|
filename: String,
|
||||||
|
size: Long,
|
||||||
): Frame =
|
): Frame =
|
||||||
|
Frame(
|
||||||
|
id = id,
|
||||||
|
type = TYPE_MEDIA_UPLOAD_START,
|
||||||
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
|
MediaUploadStartPayload.serializer(),
|
||||||
|
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun mediaUploadEndFrame(id: Int, mediaRef: String, sha256: String): Frame =
|
||||||
|
Frame(
|
||||||
|
id = id,
|
||||||
|
type = TYPE_MEDIA_UPLOAD_END,
|
||||||
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
|
MediaUploadEndPayload.serializer(),
|
||||||
|
MediaUploadEndPayload(mediaRef, sha256),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun mediaPullFrame(id: Int, mediaId: String): Frame =
|
||||||
|
Frame(
|
||||||
|
id = id,
|
||||||
|
type = TYPE_MEDIA_PULL,
|
||||||
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
|
MediaPullPayload.serializer(),
|
||||||
|
MediaPullPayload(mediaId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── M5 frame builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun fcmRegisterFrame(fcmToken: String? = null, ntfyTopic: String? = null): Frame =
|
||||||
Frame(
|
Frame(
|
||||||
type = TYPE_FCM_REGISTER,
|
type = TYPE_FCM_REGISTER,
|
||||||
payload =
|
payload = IrisJson.instance.encodeToJsonElement(
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
FcmRegisterPayload.serializer(),
|
FcmRegisterPayload.serializer(),
|
||||||
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
FcmRegisterPayload(fcmToken = fcmToken, ntfyTopic = ntfyTopic),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,21 +9,17 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.LinkInteractionListener
|
import androidx.compose.ui.text.LinkInteractionListener
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -41,28 +37,15 @@ import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
|
|||||||
import com.mikepenz.markdown.m3.Markdown
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
import com.mikepenz.markdown.m3.markdownColor
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
import com.mikepenz.markdown.m3.markdownTypography
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
import com.mikepenz.markdown.model.StreamingMarkdownState
|
|
||||||
import com.mikepenz.markdown.model.markdownAnnotator
|
import com.mikepenz.markdown.model.markdownAnnotator
|
||||||
import com.mikepenz.markdown.model.rememberMarkdownState
|
import com.mikepenz.markdown.model.rememberMarkdownState
|
||||||
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
|
|
||||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||||
import dev.snipme.highlights.Highlights
|
import dev.snipme.highlights.Highlights
|
||||||
import dev.snipme.highlights.model.SyntaxThemes
|
import dev.snipme.highlights.model.SyntaxThemes
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
|
||||||
import iris.util.appendChunk
|
|
||||||
import iris.util.prepareForMarkdown
|
|
||||||
import iris.util.preserveNewlinesAsHardBreaks
|
|
||||||
import iris.util.preserveNewlinesAsHardBreaksStreaming
|
|
||||||
import iris.util.revealStep
|
|
||||||
import iris.util.streamCharsPerSecond
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.intellij.markdown.MarkdownElementTypes
|
import org.intellij.markdown.MarkdownElementTypes
|
||||||
import org.intellij.markdown.MarkdownTokenTypes
|
|
||||||
|
|
||||||
/** Reveal tick for the streaming typewriter effect (docs/05 §5.1). */
|
|
||||||
private const val STREAM_REVEAL_TICK_MS = 50L
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
|
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
|
||||||
@@ -70,12 +53,6 @@ private const val STREAM_REVEAL_TICK_MS = 50L
|
|||||||
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
|
||||||
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
|
||||||
* rendered text blends in.
|
* rendered text blends in.
|
||||||
*
|
|
||||||
* While [isStreaming], the text is revealed at a steady rate (controlled by
|
|
||||||
* [streamSmoothness], Settings → Streaming) and parsed INCREMENTALLY: settled
|
|
||||||
* blocks are parsed once and never re-laid out, only the tail re-renders per
|
|
||||||
* tick — so the bubble stays readable instead of reflowing (and flashing raw
|
|
||||||
* markdown) on every gateway update.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownText(
|
fun MarkdownText(
|
||||||
@@ -87,30 +64,11 @@ fun MarkdownText(
|
|||||||
// plain highlighted code — the artifact card (and its runnable preview)
|
// plain highlighted code — the artifact card (and its runnable preview)
|
||||||
// only appears once the message is complete.
|
// only appears once the message is complete.
|
||||||
isStreaming: Boolean = false,
|
isStreaming: Boolean = false,
|
||||||
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
|
||||||
) {
|
) {
|
||||||
// Static path: transform once per text change (leading blanks stripped,
|
val state = rememberMarkdownState(text)
|
||||||
// single newlines → hard breaks).
|
|
||||||
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
|
|
||||||
// Whether this bubble has ever been streamed. Static history messages
|
|
||||||
// skip the reveal pipeline entirely.
|
|
||||||
val hasStreamed = remember { mutableStateOf(isStreaming) }
|
|
||||||
if (isStreaming) hasStreamed.value = true
|
|
||||||
val pipeline =
|
|
||||||
rememberStreamingMarkdownPipeline(
|
|
||||||
text = text,
|
|
||||||
smoothness = streamSmoothness,
|
|
||||||
active = hasStreamed.value,
|
|
||||||
streaming = isStreaming,
|
|
||||||
)
|
|
||||||
// Keep the streaming renderer until the reveal catches up, even after
|
|
||||||
// message.stop — a fast model that dumps the whole text at once still
|
|
||||||
// plays out the typewriter instead of jumping to the full message.
|
|
||||||
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
|
|
||||||
// The app is always dark-themed, so force the dark highlight palette
|
// The app is always dark-themed, so force the dark highlight palette
|
||||||
// (isSystemInDarkTheme() is unreliable on desktop).
|
// (isSystemInDarkTheme() is unreliable on desktop).
|
||||||
val highlights =
|
val highlights = remember {
|
||||||
remember {
|
|
||||||
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
||||||
}
|
}
|
||||||
val base = TextStyle(color = color, fontSize = fontSize)
|
val base = TextStyle(color = color, fontSize = fontSize)
|
||||||
@@ -122,8 +80,7 @@ fun MarkdownText(
|
|||||||
// Brief green flash shown on the chip right after a copy, so the tap is
|
// Brief green flash shown on the chip right after a copy, so the tap is
|
||||||
// visible feedback (the clipboard write itself is silent).
|
// visible feedback (the clipboard write itself is silent).
|
||||||
val copiedCodeSpanStyle =
|
val copiedCodeSpanStyle =
|
||||||
base
|
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
||||||
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
|
|
||||||
.toSpanStyle()
|
.toSpanStyle()
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -135,11 +92,9 @@ fun MarkdownText(
|
|||||||
// Re-render it without the padding, and wrap it in a link so tapping the
|
// Re-render it without the padding, and wrap it in a link so tapping the
|
||||||
// inline code copies it to the clipboard (Telegram-style). The
|
// inline code copies it to the clipboard (Telegram-style). The
|
||||||
// LinkAnnotation carries the click listener; the URL is never opened.
|
// LinkAnnotation carries the click listener; the URL is never opened.
|
||||||
val annotator =
|
val annotator = markdownAnnotator(
|
||||||
markdownAnnotator(
|
|
||||||
annotate = { content, child ->
|
annotate = { content, child ->
|
||||||
when {
|
if (child.type == MarkdownElementTypes.CODE_SPAN) {
|
||||||
child.type == MarkdownElementTypes.CODE_SPAN -> {
|
|
||||||
val children = child.children
|
val children = child.children
|
||||||
// Drop the surrounding backtick tokens (present as first/last child).
|
// Drop the surrounding backtick tokens (present as first/last child).
|
||||||
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
||||||
@@ -151,15 +106,13 @@ fun MarkdownText(
|
|||||||
url = "iris:copy-code",
|
url = "iris:copy-code",
|
||||||
// Keep every interaction state identical to the chip so
|
// Keep every interaction state identical to the chip so
|
||||||
// hover/press never restyles the inline code.
|
// hover/press never restyles the inline code.
|
||||||
styles =
|
styles = TextLinkStyles(
|
||||||
TextLinkStyles(
|
|
||||||
style = spanStyle,
|
style = spanStyle,
|
||||||
focusedStyle = spanStyle,
|
focusedStyle = spanStyle,
|
||||||
hoveredStyle = spanStyle,
|
hoveredStyle = spanStyle,
|
||||||
pressedStyle = spanStyle,
|
pressedStyle = spanStyle,
|
||||||
),
|
),
|
||||||
linkInteractionListener =
|
linkInteractionListener = LinkInteractionListener {
|
||||||
LinkInteractionListener {
|
|
||||||
clipboard.setText(AnnotatedString(code))
|
clipboard.setText(AnnotatedString(code))
|
||||||
copiedCode.value = code
|
copiedCode.value = code
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -173,36 +126,23 @@ fun MarkdownText(
|
|||||||
}
|
}
|
||||||
pop()
|
pop()
|
||||||
true
|
true
|
||||||
}
|
} else {
|
||||||
|
|
||||||
// Streaming cursor: append ▉ to the last text leaf of the
|
|
||||||
// document so the cursor sits at the end of the visible text.
|
|
||||||
// The tail is re-annotated on every reveal tick, so the cursor
|
|
||||||
// follows the text (and vanishes once the message is final).
|
|
||||||
useStreaming &&
|
|
||||||
child.type == MarkdownTokenTypes.TEXT &&
|
|
||||||
content.length - child.endOffset <= 1 -> {
|
|
||||||
append(child.getUnescapedTextInNode(content))
|
|
||||||
append(" ▉")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val colors =
|
Markdown(
|
||||||
markdownColor(
|
markdownState = state,
|
||||||
|
modifier = modifier,
|
||||||
|
annotator = annotator,
|
||||||
|
colors = markdownColor(
|
||||||
text = color,
|
text = color,
|
||||||
codeBackground = Color.Black.copy(alpha = 0.8f),
|
codeBackground = Color.Black.copy(alpha = 0.8f),
|
||||||
inlineCodeBackground = inlineCodeBackground,
|
inlineCodeBackground = inlineCodeBackground,
|
||||||
dividerColor = IrisColors.divider,
|
dividerColor = IrisColors.divider,
|
||||||
tableBackground = Color.White.copy(alpha = 0.03f),
|
tableBackground = Color.White.copy(alpha = 0.03f),
|
||||||
)
|
),
|
||||||
val typography =
|
typography = markdownTypography(
|
||||||
markdownTypography(
|
|
||||||
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
|
||||||
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
|
||||||
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
|
||||||
@@ -217,17 +157,13 @@ fun MarkdownText(
|
|||||||
ordered = base,
|
ordered = base,
|
||||||
bullet = base,
|
bullet = base,
|
||||||
list = base,
|
list = base,
|
||||||
textLink =
|
link = base.copy(textDecoration = TextDecoration.Underline),
|
||||||
TextLinkStyles(
|
|
||||||
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
|
||||||
),
|
|
||||||
table = base.copy(fontSize = fontSize * 0.95f),
|
table = base.copy(fontSize = fontSize * 0.95f),
|
||||||
)
|
),
|
||||||
val components =
|
components = markdownComponents(
|
||||||
markdownComponents(
|
|
||||||
codeFence = { model ->
|
codeFence = { model ->
|
||||||
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
|
||||||
if (!useStreaming && isHtmlArtifact(language, code)) {
|
if (!isStreaming && isHtmlArtifact(language, code)) {
|
||||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
} else {
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
@@ -236,7 +172,7 @@ fun MarkdownText(
|
|||||||
},
|
},
|
||||||
codeBlock = { model ->
|
codeBlock = { model ->
|
||||||
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
|
||||||
if (!useStreaming && isHtmlArtifact(language, code)) {
|
if (!isStreaming && isHtmlArtifact(language, code)) {
|
||||||
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
HtmlArtifactCard(code = code, style = style, highlights = highlights)
|
||||||
} else {
|
} else {
|
||||||
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
|
||||||
@@ -246,128 +182,16 @@ fun MarkdownText(
|
|||||||
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
|
||||||
// Material 3 checkbox instead.
|
// Material 3 checkbox instead.
|
||||||
checkbox = {
|
checkbox = {
|
||||||
com.mikepenz.markdown.m3.elements
|
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
||||||
.MarkdownCheckBox(it.content, it.node, it.typography.text)
|
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
if (useStreaming) {
|
|
||||||
if (pipeline.displayed.isEmpty()) {
|
|
||||||
// No text revealed yet (message.start, first tick pending): show
|
|
||||||
// just the cursor so the bubble is visible immediately.
|
|
||||||
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
|
|
||||||
} else {
|
|
||||||
Markdown(
|
|
||||||
streamingMarkdownState = pipeline.state,
|
|
||||||
modifier = modifier,
|
|
||||||
annotator = annotator,
|
|
||||||
colors = colors,
|
|
||||||
typography = typography,
|
|
||||||
components = components,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// retainState: keep the last formatted output visible while the new
|
|
||||||
// text re-parses (async) — no raw-markdown flash between updates.
|
|
||||||
val state = rememberMarkdownState(displayText, retainState = true)
|
|
||||||
Markdown(
|
|
||||||
markdownState = state,
|
|
||||||
modifier = modifier,
|
|
||||||
annotator = annotator,
|
|
||||||
colors = colors,
|
|
||||||
typography = typography,
|
|
||||||
components = components,
|
|
||||||
loading = { m ->
|
loading = { m ->
|
||||||
// First parse of a (long) message: show the text so the bubble
|
// While (re)parsing — e.g. on each streaming update — show the raw
|
||||||
// never goes blank.
|
// text so the bubble never goes blank between updates.
|
||||||
Text(displayText, modifier = m, color = color, fontSize = fontSize)
|
Text(text, modifier = m, color = color, fontSize = fontSize)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
|
|
||||||
* snapshot from the gateway; it is revealed at a steady rate controlled by
|
|
||||||
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
|
|
||||||
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
|
|
||||||
* keep their AST identity, so Compose never re-lays them out — only the
|
|
||||||
* unstable tail re-renders per tick.
|
|
||||||
*
|
|
||||||
* [active] is false for history messages (never streamed): the reveal is
|
|
||||||
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
|
|
||||||
* indicates the message is still receiving updates; once the reveal catches
|
|
||||||
* up and the message is final, the reveal loop stops.
|
|
||||||
*
|
|
||||||
* Returns the parser state, the currently revealed text (read inside the
|
|
||||||
* composition so reveal ticks recompose the caller), and whether the reveal
|
|
||||||
* has caught up with the target.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun rememberStreamingMarkdownPipeline(
|
|
||||||
text: String,
|
|
||||||
smoothness: Float,
|
|
||||||
active: Boolean,
|
|
||||||
streaming: Boolean,
|
|
||||||
): StreamingPipeline {
|
|
||||||
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
|
|
||||||
// a prefix of the revealed text always maps to a prefix of the target, so
|
|
||||||
// the diff between consecutive reveals is a pure append.
|
|
||||||
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
|
|
||||||
val displayed = remember { mutableStateOf("") }
|
|
||||||
val latestTarget = rememberUpdatedState(target)
|
|
||||||
val latestSmoothness = rememberUpdatedState(smoothness)
|
|
||||||
val latestStreaming = rememberUpdatedState(streaming)
|
|
||||||
// Bumped when the target is rewritten (not extended): the parser state is
|
|
||||||
// recreated via key() below and re-seeded with the full text.
|
|
||||||
val generation = remember { mutableIntStateOf(0) }
|
|
||||||
val state = key(generation.value) { rememberStreamingMarkdownState() }
|
|
||||||
LaunchedEffect(state, active) {
|
|
||||||
if (!active) {
|
|
||||||
// Never streamed (history message): reveal everything at once so
|
|
||||||
// the caller falls back to the static renderer immediately.
|
|
||||||
displayed.value = latestTarget.value
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
var last = ""
|
|
||||||
while (true) {
|
|
||||||
delay(STREAM_REVEAL_TICK_MS)
|
|
||||||
val t = latestTarget.value
|
|
||||||
val cur = displayed.value
|
|
||||||
if (cur == t) {
|
|
||||||
// Reveal complete: keep ticking only while the message is
|
|
||||||
// still streaming (more text may arrive); otherwise stop so
|
|
||||||
// finished bubbles don't burn battery.
|
|
||||||
if (!latestStreaming.value) return@LaunchedEffect
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val step =
|
|
||||||
revealStep(
|
|
||||||
remaining = t.length - cur.length,
|
|
||||||
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
|
|
||||||
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
|
|
||||||
)
|
|
||||||
val next = t.take(cur.length + step)
|
|
||||||
displayed.value = next
|
|
||||||
val chunk = appendChunk(last, next)
|
|
||||||
if (chunk == null) {
|
|
||||||
// Rewritten, not extended: recreate the parser state. The
|
|
||||||
// effect restarts (keyed on [state]) and re-seeds it.
|
|
||||||
generation.value++
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
if (chunk.isNotEmpty()) state.append(chunk)
|
|
||||||
last = next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return StreamingPipeline(state, displayed.value, displayed.value == target)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of the streaming reveal pipeline. */
|
|
||||||
private data class StreamingPipeline(
|
|
||||||
val state: StreamingMarkdownState,
|
|
||||||
val displayed: String,
|
|
||||||
val done: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a highlighted code block with a copy button in the top-right corner
|
* Renders a highlighted code block with a copy button in the top-right corner
|
||||||
@@ -383,12 +207,11 @@ internal fun CodeBlockWithCopy(
|
|||||||
) {
|
) {
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
Box {
|
Box {
|
||||||
MarkdownHighlightedCode(code = code, language = language, highlightsBuilder = highlights, style = style)
|
MarkdownHighlightedCode(code = code, language = language, highlights = highlights, style = style)
|
||||||
if (code.isNotBlank()) {
|
if (code.isNotBlank()) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { clipboard.setText(AnnotatedString(code)) },
|
onClick = { clipboard.setText(AnnotatedString(code)) },
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
// MarkdownHighlightedCode insets its background by 8dp top;
|
// MarkdownHighlightedCode insets its background by 8dp top;
|
||||||
// match that so the button sits inside the block, not above it.
|
// match that so the button sits inside the block, not above it.
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -26,16 +24,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import iris.net.TlsFingerprintRequired
|
|
||||||
import iris.platform.QrScanButton
|
|
||||||
import iris.platform.isDesktop
|
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
import iris.util.PairLink
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,46 +41,15 @@ fun ConnectScreen(
|
|||||||
prefillUrl: String = "",
|
prefillUrl: String = "",
|
||||||
prefillToken: String = "",
|
prefillToken: String = "",
|
||||||
initialError: String? = null,
|
initialError: String? = null,
|
||||||
/** Gateway certificate fingerprint awaiting user confirmation (docs/09
|
|
||||||
* §9.4) — shown as a confirm dialog on entry. */
|
|
||||||
tlsFingerprint: String? = null,
|
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
|
||||||
// the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
|
|
||||||
var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
|
|
||||||
var token by remember { mutableStateOf(prefillToken) }
|
var token by remember { mutableStateOf(prefillToken) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf(initialError) }
|
var error by remember { mutableStateOf(initialError) }
|
||||||
// Fingerprint the user still has to confirm (self-signed gateway cert,
|
|
||||||
// docs/09 §9.4): set on entry (TlsConfirmRequired state) or when a
|
|
||||||
// connect attempt fails with an untrusted certificate.
|
|
||||||
var pendingFingerprint by remember { mutableStateOf(tlsFingerprint) }
|
|
||||||
|
|
||||||
// "Test & Connect" (also the dialog's confirm action): real hello test,
|
|
||||||
// then save + (re)connect. An untrusted gateway certificate surfaces as
|
|
||||||
// the fingerprint confirm dialog instead of a plain error.
|
|
||||||
fun doConnect() {
|
|
||||||
if (busy) return
|
|
||||||
busy = true
|
|
||||||
error = null
|
|
||||||
scope.launch {
|
|
||||||
val result = controller.connect(url.trim(), token.trim())
|
|
||||||
busy = false
|
|
||||||
if (result.isFailure) {
|
|
||||||
val ex = result.exceptionOrNull()
|
|
||||||
if (ex is TlsFingerprintRequired) {
|
|
||||||
pendingFingerprint = ex.fingerprint
|
|
||||||
} else {
|
|
||||||
error = ex?.message ?: "connection failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
@@ -104,8 +66,7 @@ fun ConnectScreen(
|
|||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(IrisColors.surface)
|
.background(IrisColors.surface)
|
||||||
@@ -115,8 +76,7 @@ fun ConnectScreen(
|
|||||||
value = url,
|
value = url,
|
||||||
onValueChange = { url = it },
|
onValueChange = { url = it },
|
||||||
label = { Text("Server URL") },
|
label = { Text("Server URL") },
|
||||||
// Example LAN URL; https:// works too for TLS gateways.
|
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
||||||
placeholder = { Text("http://192.168.1.10:8791") },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -126,29 +86,26 @@ fun ConnectScreen(
|
|||||||
value = token,
|
value = token,
|
||||||
onValueChange = { token = it },
|
onValueChange = { token = it },
|
||||||
label = { Text("Pairing token") },
|
label = { Text("Pairing token") },
|
||||||
placeholder = { Text("IRIS_TOKEN (64 hex)") },
|
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
if (!isDesktop) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
QrScanButton { raw ->
|
|
||||||
if (raw != null) {
|
|
||||||
val link = PairLink.parse(raw)
|
|
||||||
if (link != null) {
|
|
||||||
url = link.url
|
|
||||||
token = link.token
|
|
||||||
} else {
|
|
||||||
error = "Couldn't read that QR code."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { doConnect() },
|
onClick = {
|
||||||
|
if (busy) return@Button
|
||||||
|
busy = true
|
||||||
|
error = null
|
||||||
|
scope.launch {
|
||||||
|
val result = controller.connect(url.trim(), token.trim())
|
||||||
|
busy = false
|
||||||
|
if (result.isFailure) {
|
||||||
|
error = result.exceptionOrNull()?.message ?: "connection failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
@@ -167,49 +124,11 @@ fun ConnectScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
"Find the token in ~/.hermes/.env (IRIS_TOKEN) or run\n" +
|
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
|
||||||
"hermes gateway setup on the gateway host.",
|
"hermes gateway setup on the gateway host.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fingerprint confirm (docs/09 §9.4): the gateway presents a certificate
|
|
||||||
// this device doesn't trust (self-signed). The user verifies the
|
|
||||||
// fingerprint on the gateway host, then confirms — the pin is stored in
|
|
||||||
// secure storage and the connect is retried, like an SSH host key.
|
|
||||||
if (pendingFingerprint != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { pendingFingerprint = null },
|
|
||||||
title = { Text("Confirm gateway certificate") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"The gateway presents a certificate this device doesn't trust. " +
|
|
||||||
"Verify the fingerprint on the gateway host, then confirm to pin it.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
pendingFingerprint!!,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val fp = pendingFingerprint!!
|
|
||||||
pendingFingerprint = null
|
|
||||||
controller.confirmTlsFingerprint(fp)
|
|
||||||
doConnect()
|
|
||||||
},
|
|
||||||
) { Text("Confirm & pin") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { pendingFingerprint = null }) { Text("Cancel") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -44,63 +43,37 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import iris.AppVersion
|
|
||||||
import iris.net.ReleaseCheck
|
|
||||||
import iris.platform.ImageFilePicker
|
import iris.platform.ImageFilePicker
|
||||||
import iris.platform.decodeImageBytes
|
|
||||||
import iris.platform.loadScaledImage
|
import iris.platform.loadScaledImage
|
||||||
import iris.platform.readBackdropBytes
|
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import iris.state.ToolDetail
|
import iris.state.ToolDetail
|
||||||
import iris.ui.theme.Backdrop
|
|
||||||
import iris.ui.theme.BackgroundMode
|
import iris.ui.theme.BackgroundMode
|
||||||
import iris.ui.theme.IrisColors
|
import iris.ui.theme.IrisColors
|
||||||
import iris.ui.theme.LocalUserTheme
|
import iris.ui.theme.LocalUserTheme
|
||||||
import iris.util.STREAM_SMOOTHNESS_MAX
|
|
||||||
import iris.util.STREAM_SMOOTHNESS_MIN
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen (post-M7): app-level preferences, opened from the channel
|
* Settings screen (post-M7): app-level preferences, opened from the channel
|
||||||
* drawer / rail. More settings land here over time.
|
* drawer / rail. More settings land here over time.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Which color the picker dialog is editing. */
|
/** Which color the picker dialog is editing. */
|
||||||
private enum class PickerTarget { UserBubble, AgentBubble, Background }
|
private enum class PickerTarget { UserBubble, AgentBubble, Background }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
|
||||||
controller: IrisController,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
val threadsEnabled by controller.threadsEnabled.collectAsState()
|
||||||
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
val streamingEnabled by controller.streamingEnabled.collectAsState()
|
||||||
val streamSmoothness by controller.streamSmoothness.collectAsState()
|
|
||||||
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
|
||||||
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
|
|
||||||
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
|
|
||||||
val toolDetail by controller.toolDetail.collectAsState()
|
val toolDetail by controller.toolDetail.collectAsState()
|
||||||
val fontSizeScale by controller.fontSizeScale.collectAsState()
|
|
||||||
val gatewayVersion by controller.gatewayVersion.collectAsState()
|
|
||||||
val theme = LocalUserTheme.current
|
val theme = LocalUserTheme.current
|
||||||
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showForgetConfirm by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Best-effort latest-release check (docs/04): one query per screen open,
|
|
||||||
// failures stay silent (ReleaseCheck returns null).
|
|
||||||
var latestVersion by remember { mutableStateOf<String?>(null) }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
latestVersion = ReleaseCheck.latestVersion()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -155,27 +128,6 @@ fun SettingsScreen(
|
|||||||
onCheckedChange = { controller.toggleStreaming() },
|
onCheckedChange = { controller.toggleStreaming() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (streamingEnabled) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text("Streaming speed", fontSize = 12.sp, color = IrisColors.textDim)
|
|
||||||
Text(
|
|
||||||
"How fast new text appears while streaming — lower is faster",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = IrisColors.textDim,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text("Fast", fontSize = 12.sp, color = IrisColors.textDim)
|
|
||||||
Slider(
|
|
||||||
value = streamSmoothness,
|
|
||||||
onValueChange = { controller.setStreamSmoothness(it) },
|
|
||||||
valueRange = STREAM_SMOOTHNESS_MIN..STREAM_SMOOTHNESS_MAX,
|
|
||||||
steps = 5, // 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.7 / 0.8
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text("Slow", fontSize = 12.sp, color = IrisColors.textDim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
Row(
|
Row(
|
||||||
@@ -196,39 +148,6 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsCard {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text("📊 Runtime footer", fontSize = 14.sp)
|
|
||||||
Text(
|
|
||||||
"Show model, context, workdir, latency and cost under replies",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.textDim,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = runtimeFooterEnabled,
|
|
||||||
onCheckedChange = { controller.toggleRuntimeFooter() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (runtimeFooterEnabled) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text("Fields to show", fontSize = 12.sp, color = IrisColors.textDim)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
IrisController.RUNTIME_FIELD_KEYS.forEach { field ->
|
|
||||||
TopicChip(
|
|
||||||
label = runtimeFieldLabel(field),
|
|
||||||
selected = field in runtimeFooterFields,
|
|
||||||
onClick = { controller.toggleRuntimeField(field) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Appearance",
|
"Appearance",
|
||||||
@@ -299,36 +218,14 @@ fun SettingsScreen(
|
|||||||
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
|
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text("Backdrops", fontSize = 12.sp, color = IrisColors.textDim)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
FlowRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Backdrop.ALL.forEach { backdrop ->
|
|
||||||
BackdropThumb(
|
|
||||||
backdrop = backdrop,
|
|
||||||
selected = theme.backgroundImagePath == backdrop.path,
|
|
||||||
onClick = {
|
|
||||||
controller.setBackgroundMode(BackgroundMode.Image)
|
|
||||||
controller.setBackgroundImagePath(backdrop.path)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
BackgroundPreview(theme.backgroundImagePath)
|
BackgroundPreview(theme.backgroundImagePath)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
if (theme.backgroundImagePath.isNullOrBlank()) {
|
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
|
||||||
"No image selected"
|
else fileNameOf(theme.backgroundImagePath!!),
|
||||||
} else {
|
|
||||||
Backdrop.fromPath(theme.backgroundImagePath)?.name
|
|
||||||
?: fileNameOf(theme.backgroundImagePath!!)
|
|
||||||
},
|
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = IrisColors.textDim,
|
color = IrisColors.textDim,
|
||||||
)
|
)
|
||||||
@@ -362,46 +259,6 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SettingsCard {
|
|
||||||
Text("🔤 Font size", fontSize = 14.sp)
|
|
||||||
Text(
|
|
||||||
"Scale the text across the app",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.textDim,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text("Aa", fontSize = 12.sp, color = IrisColors.textDim)
|
|
||||||
Slider(
|
|
||||||
value = fontSizeScale,
|
|
||||||
onValueChange = { controller.setFontSizeScale(it) },
|
|
||||||
valueRange = IrisController.FONT_SCALE_MIN..IrisController.FONT_SCALE_MAX,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text("Aa", fontSize = 18.sp, color = IrisColors.textDim)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"The quick brown fox jumps over the lazy dog",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = IrisColors.textSecondary,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${(fontSizeScale * 100).roundToInt()}%",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.textDim,
|
|
||||||
)
|
|
||||||
if (fontSizeScale != 1.0f) {
|
|
||||||
TextButton(onClick = { controller.setFontSizeScale(1.0f) }) {
|
|
||||||
Text("Reset", fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Tools",
|
"Tools",
|
||||||
@@ -426,42 +283,10 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
|
||||||
"Connection",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
|
||||||
)
|
|
||||||
SettingsCard {
|
|
||||||
Text("🔌 Forget pairing", fontSize = 14.sp)
|
|
||||||
Text(
|
|
||||||
"Clears the gateway token and wipes local chat history",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.textDim,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
TextButton(onClick = { showForgetConfirm = true }) {
|
|
||||||
Text("Forget pairing", fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"About",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
|
|
||||||
)
|
|
||||||
SettingsCard {
|
|
||||||
VersionCard(
|
|
||||||
appVersion = AppVersion.VERSION,
|
|
||||||
gatewayVersion = gatewayVersion,
|
|
||||||
latestVersion = latestVersion,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
when (pickerTarget) {
|
when (pickerTarget) {
|
||||||
PickerTarget.UserBubble -> {
|
PickerTarget.UserBubble -> ColorPickerDialog(
|
||||||
ColorPickerDialog(
|
|
||||||
title = "Your bubble",
|
title = "Your bubble",
|
||||||
initialArgb = theme.userBubbleColor,
|
initialArgb = theme.userBubbleColor,
|
||||||
onApply = { controller.setUserBubbleColor(it) },
|
onApply = { controller.setUserBubbleColor(it) },
|
||||||
@@ -471,10 +296,7 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
onDismiss = { pickerTarget = null },
|
onDismiss = { pickerTarget = null },
|
||||||
)
|
)
|
||||||
}
|
PickerTarget.AgentBubble -> ColorPickerDialog(
|
||||||
|
|
||||||
PickerTarget.AgentBubble -> {
|
|
||||||
ColorPickerDialog(
|
|
||||||
title = "Agent bubble",
|
title = "Agent bubble",
|
||||||
initialArgb = theme.agentBubbleColor,
|
initialArgb = theme.agentBubbleColor,
|
||||||
onApply = { controller.setAgentBubbleColor(it) },
|
onApply = { controller.setAgentBubbleColor(it) },
|
||||||
@@ -484,10 +306,7 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
onDismiss = { pickerTarget = null },
|
onDismiss = { pickerTarget = null },
|
||||||
)
|
)
|
||||||
}
|
PickerTarget.Background -> ColorPickerDialog(
|
||||||
|
|
||||||
PickerTarget.Background -> {
|
|
||||||
ColorPickerDialog(
|
|
||||||
title = "Background",
|
title = "Background",
|
||||||
initialArgb = theme.backgroundColor,
|
initialArgb = theme.backgroundColor,
|
||||||
onApply = { controller.setBackgroundColor(it) },
|
onApply = { controller.setBackgroundColor(it) },
|
||||||
@@ -497,51 +316,17 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
onDismiss = { pickerTarget = null },
|
onDismiss = { pickerTarget = null },
|
||||||
)
|
)
|
||||||
}
|
null -> Unit
|
||||||
|
|
||||||
null -> {
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showForgetConfirm) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showForgetConfirm = false },
|
|
||||||
title = { Text("Forget pairing?") },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
"This clears the gateway token and wipes all local chat history on this device. You'll need to pair again to reconnect.",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
showForgetConfirm = false
|
|
||||||
controller.forget()
|
|
||||||
}) {
|
|
||||||
Text("Forget")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showForgetConfirm = false }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Material-style back arrow (vector, matches the app's icon weight). */
|
/** Material-style back arrow (vector, matches the app's icon weight). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun BackArrow(
|
private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
|
||||||
size: Dp = 22.dp,
|
|
||||||
color: Color = IrisColors.onSurface,
|
|
||||||
) {
|
|
||||||
Canvas(modifier = Modifier.size(size)) {
|
Canvas(modifier = Modifier.size(size)) {
|
||||||
val s = this.size.width / 24f
|
val s = this.size.width / 24f
|
||||||
val path =
|
val path = Path().apply {
|
||||||
Path().apply {
|
|
||||||
moveTo(20f * s, 11f * s)
|
moveTo(20f * s, 11f * s)
|
||||||
lineTo(7.83f * s, 11f * s)
|
lineTo(7.83f * s, 11f * s)
|
||||||
lineTo(13.42f * s, 5.41f * s)
|
lineTo(13.42f * s, 5.41f * s)
|
||||||
@@ -557,53 +342,11 @@ private fun BackArrow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* About card (docs/04 hello.ack note): app version (repo-root VERSION baked in
|
|
||||||
* at build time), the connected gateway's version (hello.ack
|
|
||||||
* `server_caps.app_version`), a mismatch hint, and the latest Gitea release
|
|
||||||
* when the running build is older.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun VersionCard(
|
|
||||||
appVersion: String,
|
|
||||||
gatewayVersion: String,
|
|
||||||
latestVersion: String?,
|
|
||||||
) {
|
|
||||||
Text("📦 Version", fontSize = 14.sp)
|
|
||||||
Text(
|
|
||||||
"Iris app v$appVersion",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.textSecondary,
|
|
||||||
)
|
|
||||||
if (gatewayVersion.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
"Gateway v$gatewayVersion",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.textSecondary,
|
|
||||||
)
|
|
||||||
if (gatewayVersion != appVersion) {
|
|
||||||
Text(
|
|
||||||
"App and gateway versions differ — update the other side to match.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.statusAmber,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest ->
|
|
||||||
Text(
|
|
||||||
"New version v$latest available — see the Gitea releases page.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = IrisColors.statusAmber,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Settings row container (panel card). */
|
/** Settings row container (panel card). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsCard(content: @Composable () -> Unit) {
|
private fun SettingsCard(content: @Composable () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(IrisColors.panel)
|
.background(IrisColors.panel)
|
||||||
@@ -615,13 +358,9 @@ private fun SettingsCard(content: @Composable () -> Unit) {
|
|||||||
|
|
||||||
/** Clickable rounded chip showing a color; opens the picker on tap. */
|
/** Clickable rounded chip showing a color; opens the picker on tap. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColorSwatch(
|
private fun ColorSwatch(color: Color, onClick: () -> Unit) {
|
||||||
color: Color,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(color)
|
.background(color)
|
||||||
@@ -635,21 +374,10 @@ private fun ColorSwatch(
|
|||||||
private fun BackgroundPreview(path: String?) {
|
private fun BackgroundPreview(path: String?) {
|
||||||
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
|
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
|
||||||
LaunchedEffect(path) {
|
LaunchedEffect(path) {
|
||||||
bmp =
|
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
|
||||||
if (path.isNullOrBlank()) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val bytes = Backdrop.fromPath(path)?.let { readBackdropBytes(it.id) }
|
|
||||||
if (bytes != null) {
|
|
||||||
decodeImageBytes(bytes, 128)
|
|
||||||
} else {
|
|
||||||
loadScaledImage(path, 128)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.size(44.dp)
|
.size(44.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(IrisColors.panel)
|
.background(IrisColors.panel)
|
||||||
@@ -668,43 +396,6 @@ private fun BackgroundPreview(path: String?) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Thumbnail of a bundled backdrop; highlighted when selected. */
|
|
||||||
@Composable
|
|
||||||
private fun BackdropThumb(
|
|
||||||
backdrop: Backdrop,
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
var bmp by remember(backdrop.id) { mutableStateOf<ImageBitmap?>(null) }
|
|
||||||
LaunchedEffect(backdrop.id) {
|
|
||||||
val bytes = readBackdropBytes(backdrop.id)
|
|
||||||
bmp = if (bytes != null) decodeImageBytes(bytes, 128) else null
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(IrisColors.panel)
|
|
||||||
.border(
|
|
||||||
if (selected) 2.dp else 1.dp,
|
|
||||||
if (selected) IrisColors.primary else IrisColors.divider,
|
|
||||||
RoundedCornerShape(8.dp),
|
|
||||||
).clickable(onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
val b = bmp
|
|
||||||
if (b != null) {
|
|
||||||
Image(
|
|
||||||
bitmap = b,
|
|
||||||
contentDescription = backdrop.name,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HSV color picker dialog (hue / saturation / brightness sliders).
|
* HSV color picker dialog (hue / saturation / brightness sliders).
|
||||||
* [onApply] applies the color and keeps the dialog open for further tweaks;
|
* [onApply] applies the color and keeps the dialog open for further tweaks;
|
||||||
@@ -729,8 +420,7 @@ private fun ColorPickerDialog(
|
|||||||
text = {
|
text = {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
@@ -773,8 +463,7 @@ private fun argbToHsv(argb: Int): FloatArray {
|
|||||||
val delta = max - min
|
val delta = max - min
|
||||||
var hue = 0f
|
var hue = 0f
|
||||||
if (delta != 0f) {
|
if (delta != 0f) {
|
||||||
hue =
|
hue = when (max) {
|
||||||
when (max) {
|
|
||||||
r -> ((g - b) / delta) % 6f
|
r -> ((g - b) / delta) % 6f
|
||||||
g -> (b - r) / delta + 2f
|
g -> (b - r) / delta + 2f
|
||||||
else -> (r - g) / delta + 4f
|
else -> (r - g) / delta + 4f
|
||||||
@@ -787,15 +476,5 @@ private fun argbToHsv(argb: Int): FloatArray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** File name from a path (no java.io.File in common code). */
|
/** File name from a path (no java.io.File in common code). */
|
||||||
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
|
private fun fileNameOf(path: String): String =
|
||||||
|
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
|
||||||
/** Human label for a runtime-footer field key (Settings → Runtime footer). */
|
|
||||||
private fun runtimeFieldLabel(field: String): String =
|
|
||||||
when (field) {
|
|
||||||
"model" -> "Model"
|
|
||||||
"context_pct" -> "Context %"
|
|
||||||
"cwd" -> "Workdir"
|
|
||||||
"latency" -> "Latency"
|
|
||||||
"cost" -> "Cost"
|
|
||||||
else -> field
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package iris.ui.theme
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bundled backdrop image shipped in the app (Android: `androidApp` module
|
|
||||||
* `assets/backdrops/`; desktop: classpath `backdrops/`), loaded via
|
|
||||||
* [iris.platform.readBackdropBytes].
|
|
||||||
*
|
|
||||||
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
|
|
||||||
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
|
|
||||||
* file path. [DEFAULT] is the background for fresh installs.
|
|
||||||
*/
|
|
||||||
data class Backdrop(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
) {
|
|
||||||
/** Sentinel path stored in the theme for this bundled backdrop. */
|
|
||||||
val path: String get() = "backdrop://$id"
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val PATH_PREFIX = "backdrop://"
|
|
||||||
|
|
||||||
/** Default background for new chats / fresh installs. */
|
|
||||||
val DEFAULT = Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli")
|
|
||||||
|
|
||||||
val ALL: List<Backdrop> =
|
|
||||||
listOf(
|
|
||||||
DEFAULT,
|
|
||||||
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
|
|
||||||
Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
|
|
||||||
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
|
|
||||||
Backdrop("pexels-soubhagya23-8060218", "Soubhagya"),
|
|
||||||
Backdrop("pexels-steve-29390703", "Steve"),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** True when [path] references a bundled backdrop. */
|
|
||||||
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
|
|
||||||
|
|
||||||
/** The bundled backdrop for a sentinel path, or null. */
|
|
||||||
fun fromPath(path: String?): Backdrop? = if (isBackdropPath(path)) ALL.firstOrNull { it.path == path } else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
import iris.platform.decodeImageBytes
|
|
||||||
import iris.platform.loadScaledImage
|
import iris.platform.loadScaledImage
|
||||||
import iris.platform.readBackdropBytes
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Centralized palette (M7). Dark is the only theme; every screen reads colors
|
* Centralized palette (M7). Dark is the only theme; every screen reads colors
|
||||||
@@ -35,6 +33,8 @@ object IrisColors {
|
|||||||
val textDim = Color(0xFF8A93A6)
|
val textDim = Color(0xFF8A93A6)
|
||||||
|
|
||||||
// Bubbles
|
// Bubbles
|
||||||
|
val bubbleUser = primary
|
||||||
|
val bubbleAssistant = Color(0xFF2A2E3B)
|
||||||
val bubbleCommentary = Color(0xFF23262F)
|
val bubbleCommentary = Color(0xFF23262F)
|
||||||
|
|
||||||
// Panels, chips, rows
|
// Panels, chips, rows
|
||||||
@@ -45,6 +45,7 @@ object IrisColors {
|
|||||||
val divider = Color(0xFF2A2E3B)
|
val divider = Color(0xFF2A2E3B)
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
|
val statusGrey = Color(0xFF9E9E9E)
|
||||||
val statusAmber = Color(0xFFFFC107)
|
val statusAmber = Color(0xFFFFC107)
|
||||||
val statusGreen = Color(0xFF4CAF50)
|
val statusGreen = Color(0xFF4CAF50)
|
||||||
val statusRed = Color(0xFFF44336)
|
val statusRed = Color(0xFFF44336)
|
||||||
@@ -53,8 +54,7 @@ object IrisColors {
|
|||||||
val errorText = Color(0xFFFF8A80)
|
val errorText = Color(0xFFFF8A80)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val IrisDark =
|
private val IrisDark = darkColorScheme(
|
||||||
darkColorScheme(
|
|
||||||
background = IrisColors.background,
|
background = IrisColors.background,
|
||||||
surface = IrisColors.surface,
|
surface = IrisColors.surface,
|
||||||
onBackground = IrisColors.onBackground,
|
onBackground = IrisColors.onBackground,
|
||||||
@@ -85,11 +85,9 @@ fun avatarColor(name: String): Color {
|
|||||||
data class UserTheme(
|
data class UserTheme(
|
||||||
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
|
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
|
||||||
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
|
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
|
||||||
// Fresh installs start with the default bundled backdrop (Settings →
|
val backgroundMode: BackgroundMode = BackgroundMode.Color,
|
||||||
// Appearance to change it).
|
|
||||||
val backgroundMode: BackgroundMode = BackgroundMode.Image,
|
|
||||||
val backgroundColor: Int = DEFAULT_BACKGROUND,
|
val backgroundColor: Int = DEFAULT_BACKGROUND,
|
||||||
val backgroundImagePath: String? = Backdrop.DEFAULT.path,
|
val backgroundImagePath: String? = null,
|
||||||
) {
|
) {
|
||||||
val userBubble: Color get() = Color(userBubbleColor)
|
val userBubble: Color get() = Color(userBubbleColor)
|
||||||
val agentBubble: Color get() = Color(agentBubbleColor)
|
val agentBubble: Color get() = Color(agentBubbleColor)
|
||||||
@@ -116,10 +114,8 @@ fun Color.contrastText(): Color {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes the theme's background image (image mode only), scaled to at most
|
* Decodes the theme's background image (image mode only), scaled to at most
|
||||||
* 2048 px on the longest edge. Bundled backdrops (`backdrop://<id>`) are read
|
* 2048 px on the longest edge. Null in color mode or on decode failure — the
|
||||||
* from the app resources; anything else is a user-picked file path. Null in
|
* caller then falls back to the solid background color.
|
||||||
* color mode or on decode failure — the caller then falls back to the solid
|
|
||||||
* background color.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
|
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
|
||||||
@@ -127,18 +123,7 @@ fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
|
|||||||
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
|
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
|
||||||
var image by remember { mutableStateOf<ImageBitmap?>(null) }
|
var image by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||||
LaunchedEffect(wantImage, path) {
|
LaunchedEffect(wantImage, path) {
|
||||||
image =
|
image = if (wantImage) loadScaledImage(path!!, 2048) else null
|
||||||
if (!wantImage) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val backdrop = Backdrop.fromPath(path)
|
|
||||||
val bytes = backdrop?.let { readBackdropBytes(it.id) }
|
|
||||||
if (bytes != null) {
|
|
||||||
decodeImageBytes(bytes, 2048)
|
|
||||||
} else {
|
|
||||||
loadScaledImage(path!!, 2048)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package iris.util
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fuzzy subsequence scoring for the slash-command drawer.
|
|
||||||
*
|
|
||||||
* [fuzzyScore] returns a non-negative rank (lower = better) when every
|
|
||||||
* character of [query] appears in [target] in order (case-insensitive), or
|
|
||||||
* -1 when it does not. Prefix matches rank best (1); a consecutive run of
|
|
||||||
* matched characters ranks better than a gapped one, so "mod" beats "m...d"
|
|
||||||
* for "/model".
|
|
||||||
*/
|
|
||||||
fun fuzzyScore(query: String, target: String): Int {
|
|
||||||
val q = query.lowercase()
|
|
||||||
if (q.isEmpty()) return 0
|
|
||||||
val t = target.lowercase()
|
|
||||||
if (t.startsWith(q)) return 1
|
|
||||||
var qi = 0
|
|
||||||
var score = 0
|
|
||||||
var prevMatch = -2
|
|
||||||
for (i in t.indices) {
|
|
||||||
if (t[i] == q[qi]) {
|
|
||||||
score += if (prevMatch == i - 1) 1 else 4
|
|
||||||
prevMatch = i
|
|
||||||
qi++
|
|
||||||
if (qi == q.length) return score
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package iris.util
|
|
||||||
|
|
||||||
/** Minimal logging. On Android, stdout goes to logcat; on desktop it goes to
|
|
||||||
* the console. Kept dependency-free (common stdlib only). */
|
|
||||||
object IrisLog {
|
|
||||||
private const val TAG = "Iris"
|
|
||||||
|
|
||||||
fun d(msg: String) = println("$TAG: $msg")
|
|
||||||
|
|
||||||
fun w(msg: String) = println("$TAG: $msg")
|
|
||||||
|
|
||||||
fun e(msg: String) = println("$TAG: $msg")
|
|
||||||
}
|
|
||||||
@@ -14,118 +14,26 @@ fun String.prepareForMarkdown(): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts single newlines into markdown hard breaks (two trailing spaces) so
|
* Converts the single newlines a user typed into markdown hard breaks (two
|
||||||
* they render as line breaks instead of being collapsed into spaces by the
|
* trailing spaces) so they render as line breaks instead of being collapsed
|
||||||
* parser (GFM treats a lone newline inside a paragraph as a space). Applied to
|
* into spaces by the parser (GFM treats a lone newline inside a paragraph as a
|
||||||
* both user input (lazy line breaks) and agent replies (models write status
|
* space). Lines inside fenced code blocks (``` / ~~~) are left untouched so the
|
||||||
* lines and wrapped text expecting a break per line). Lines inside fenced code
|
* code content is not altered; blank lines are kept as-is since they already
|
||||||
* blocks (``` / ~~~) are left untouched so the code content is not altered;
|
* produce paragraph breaks.
|
||||||
* blank lines are kept as-is since they already produce paragraph breaks.
|
|
||||||
*/
|
*/
|
||||||
fun String.preserveNewlinesAsHardBreaks(): String {
|
fun String.preserveNewlinesAsHardBreaks(): String {
|
||||||
val lines = split("\n")
|
val lines = split("\n")
|
||||||
var inCodeBlock = false
|
var inCodeBlock = false
|
||||||
val out =
|
val out = lines.map { line ->
|
||||||
lines.map { line ->
|
|
||||||
val trimmed = line.trimStart()
|
val trimmed = line.trimStart()
|
||||||
when {
|
when {
|
||||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||||
inCodeBlock = !inCodeBlock
|
inCodeBlock = !inCodeBlock
|
||||||
line
|
line
|
||||||
}
|
}
|
||||||
|
inCodeBlock || line.isBlank() -> line
|
||||||
inCodeBlock || line.isBlank() -> {
|
else -> line + " "
|
||||||
line
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
line + " "
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out.joinToString("\n")
|
return out.joinToString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
|
|
||||||
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
|
|
||||||
*
|
|
||||||
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
|
|
||||||
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
|
|
||||||
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
|
|
||||||
* renderer feed the diff between consecutive snapshots into an append-only
|
|
||||||
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
|
|
||||||
* invisible at the end of the document, so a finished message renders exactly
|
|
||||||
* like the static transform.
|
|
||||||
*/
|
|
||||||
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
|
|
||||||
val lines = split("\n")
|
|
||||||
var inCodeBlock = false
|
|
||||||
val out =
|
|
||||||
lines.mapIndexed { index, line ->
|
|
||||||
val trimmed = line.trimStart()
|
|
||||||
when {
|
|
||||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
|
||||||
inCodeBlock = !inCodeBlock
|
|
||||||
line
|
|
||||||
}
|
|
||||||
|
|
||||||
inCodeBlock || line.isBlank() -> {
|
|
||||||
line
|
|
||||||
}
|
|
||||||
|
|
||||||
index == lines.lastIndex -> {
|
|
||||||
line
|
|
||||||
}
|
|
||||||
|
|
||||||
// still being typed
|
|
||||||
else -> {
|
|
||||||
line + " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
|
|
||||||
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
|
|
||||||
* content was rewritten, not appended. The gateway sends full text snapshots
|
|
||||||
* on every `message.update`; this converts them into append chunks for the
|
|
||||||
* append-only streaming parser.
|
|
||||||
*/
|
|
||||||
fun appendChunk(
|
|
||||||
last: String,
|
|
||||||
next: String,
|
|
||||||
): String? = if (next.startsWith(last)) next.substring(last.length) else null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of characters to reveal on one streaming tick. The reveal rate comes
|
|
||||||
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
|
|
||||||
* (fast model, reconnect catch-up) everything is revealed at once so the
|
|
||||||
* display never lags far behind the received text.
|
|
||||||
*/
|
|
||||||
fun revealStep(
|
|
||||||
remaining: Int,
|
|
||||||
charsPerSecond: Double,
|
|
||||||
tickSeconds: Double,
|
|
||||||
): Int {
|
|
||||||
if (remaining <= 0) return 0
|
|
||||||
if (remaining > charsPerSecond * 2.0) return remaining
|
|
||||||
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Range of the streaming-smoothness setting (seconds between visible text
|
|
||||||
* updates while streaming; lower = faster/smoother).
|
|
||||||
*/
|
|
||||||
const val STREAM_SMOOTHNESS_MIN = 0.2f
|
|
||||||
const val STREAM_SMOOTHNESS_MAX = 0.8f
|
|
||||||
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
|
|
||||||
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
|
|
||||||
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
|
|
||||||
*/
|
|
||||||
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package iris.util
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A parsed `iris://pair` deep link: the gateway URL to connect to plus the
|
|
||||||
* one-time pairing token. Produced by [PairLink.parse] from a scanned QR or a
|
|
||||||
* tapped `iris://pair` link (docs/20).
|
|
||||||
*/
|
|
||||||
data class PairLink(
|
|
||||||
val url: String,
|
|
||||||
val token: String,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
/** Default gateway HTTP port (docs/19). */
|
|
||||||
const val DEFAULT_PORT = 8791
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a pairing link into a [PairLink], or return null on any
|
|
||||||
* malformation.
|
|
||||||
*
|
|
||||||
* - scheme must be `iris` (case-insensitive); the URI host must be `pair`.
|
|
||||||
* - required query params: `host` (non-empty) and `token` (non-empty).
|
|
||||||
* - `port` defaults to [DEFAULT_PORT]; must be 1–65535 when present.
|
|
||||||
* - `secure` defaults to `0`; `1` selects https.
|
|
||||||
* - `host`/`token` are percent-decoded (the Python side `quote()`s them).
|
|
||||||
*/
|
|
||||||
fun parse(raw: String): PairLink? {
|
|
||||||
val qIdx = raw.indexOf('?')
|
|
||||||
val authority = if (qIdx >= 0) raw.substring(0, qIdx) else raw
|
|
||||||
val query = if (qIdx >= 0) raw.substring(qIdx + 1) else ""
|
|
||||||
|
|
||||||
// authority is "iris://pair": scheme, then "://", then the URI host.
|
|
||||||
val sep = authority.indexOf("://")
|
|
||||||
if (sep <= 0) return null
|
|
||||||
val scheme = authority.substring(0, sep)
|
|
||||||
val uriHost = authority.substring(sep + 3)
|
|
||||||
if (scheme.lowercase() != "iris") return null
|
|
||||||
if (uriHost.lowercase() != "pair") return null
|
|
||||||
|
|
||||||
val params = parseQuery(query)
|
|
||||||
val host = params["host"]?.let { percentDecode(it) }?.trim().orEmpty()
|
|
||||||
val token = params["token"]?.let { percentDecode(it) }?.trim().orEmpty()
|
|
||||||
if (host.isEmpty() || token.isEmpty()) return null
|
|
||||||
|
|
||||||
val portRaw = params["port"]
|
|
||||||
val port =
|
|
||||||
if (portRaw.isNullOrEmpty()) {
|
|
||||||
DEFAULT_PORT
|
|
||||||
} else {
|
|
||||||
portRaw.toIntOrNull() ?: return null
|
|
||||||
}
|
|
||||||
if (port !in 1..65535) return null
|
|
||||||
val secure = params["secure"]?.toIntOrNull() ?: 0
|
|
||||||
val urlScheme = if (secure == 1) "https" else "http"
|
|
||||||
return PairLink("$urlScheme://$host:$port", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split a `k=v&k=v` query string into a map (values may be empty). */
|
|
||||||
private fun parseQuery(query: String): Map<String, String> {
|
|
||||||
if (query.isEmpty()) return emptyMap()
|
|
||||||
val map = LinkedHashMap<String, String>()
|
|
||||||
for (pair in query.split('&')) {
|
|
||||||
if (pair.isEmpty()) continue
|
|
||||||
val eq = pair.indexOf('=')
|
|
||||||
if (eq < 0) {
|
|
||||||
map[pair] = ""
|
|
||||||
} else {
|
|
||||||
map[pair.substring(0, eq)] = pair.substring(eq + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Percent-decode `%XX` sequences (byte-wise; pairing payloads are ASCII
|
|
||||||
* — IPs and 64-hex tokens). A `%` that does not start a valid
|
|
||||||
* two-hex-digit escape is kept literally.
|
|
||||||
*/
|
|
||||||
private fun percentDecode(s: String): String {
|
|
||||||
val sb = StringBuilder(s.length)
|
|
||||||
var i = 0
|
|
||||||
while (i < s.length) {
|
|
||||||
val c = s[i]
|
|
||||||
if (c == '%' && i + 2 < s.length) {
|
|
||||||
val code = s.substring(i + 1, i + 3).toIntOrNull(16)
|
|
||||||
if (code != null) {
|
|
||||||
sb.append(code.toChar())
|
|
||||||
i += 3
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.append(c)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,3 +11,9 @@ expect fun localDayKey(epochMillis: Long): String
|
|||||||
|
|
||||||
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
|
/** Current wall-clock time in epoch milliseconds (for locally generated items). */
|
||||||
expect fun nowMillis(): Long
|
expect fun nowMillis(): Long
|
||||||
|
|
||||||
|
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
|
||||||
|
fun hostFromUrl(url: String): String {
|
||||||
|
val noScheme = url.trim().substringAfter("://")
|
||||||
|
return noScheme.substringBefore("/").ifBlank { url.trim() }
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
-- Local cache (docs/10 §10.7, docs/16 "Local DB (KMP) — SQLDelight").
|
|
||||||
-- The server is authoritative; this is a cache that lets the app start
|
|
||||||
-- instantly and read chats offline. Rows are stored as JSON payloads so the
|
|
||||||
-- schema does not drift with the Kotlin model fields.
|
|
||||||
|
|
||||||
CREATE TABLE message (
|
|
||||||
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
|
|
||||||
id TEXT NOT NULL, -- message id (server id, or local<rand>/sys<rand> for optimistic)
|
|
||||||
ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed)
|
|
||||||
payload TEXT NOT NULL, -- serialized MessageItem
|
|
||||||
PRIMARY KEY (lane, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE tool (
|
|
||||||
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
|
|
||||||
id TEXT NOT NULL, -- local tool card id (tool<rand>)
|
|
||||||
seq INTEGER NOT NULL, -- card order within the lane (lane position)
|
|
||||||
payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id)
|
|
||||||
PRIMARY KEY (lane, id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE channel (
|
|
||||||
chat_id TEXT NOT NULL PRIMARY KEY,
|
|
||||||
payload TEXT NOT NULL -- serialized ChannelInfo
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE meta (
|
|
||||||
key TEXT NOT NULL PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
allMessages:
|
|
||||||
SELECT lane, id, ts, payload
|
|
||||||
FROM message
|
|
||||||
ORDER BY ts, id;
|
|
||||||
|
|
||||||
upsertMessage:
|
|
||||||
INSERT OR REPLACE INTO message (lane, id, ts, payload)
|
|
||||||
VALUES (?, ?, ?, ?);
|
|
||||||
|
|
||||||
clearMessages:
|
|
||||||
DELETE FROM message;
|
|
||||||
|
|
||||||
allTools:
|
|
||||||
SELECT lane, seq, payload
|
|
||||||
FROM tool
|
|
||||||
ORDER BY lane, seq;
|
|
||||||
|
|
||||||
upsertTool:
|
|
||||||
INSERT OR REPLACE INTO tool (lane, id, seq, payload)
|
|
||||||
VALUES (?, ?, ?, ?);
|
|
||||||
|
|
||||||
clearTools:
|
|
||||||
DELETE FROM tool;
|
|
||||||
|
|
||||||
allChannels:
|
|
||||||
SELECT chat_id, payload
|
|
||||||
FROM channel;
|
|
||||||
|
|
||||||
upsertChannel:
|
|
||||||
INSERT OR REPLACE INTO channel (chat_id, payload)
|
|
||||||
VALUES (?, ?);
|
|
||||||
|
|
||||||
clearChannels:
|
|
||||||
DELETE FROM channel;
|
|
||||||
|
|
||||||
metaGet:
|
|
||||||
SELECT value
|
|
||||||
FROM meta
|
|
||||||
WHERE key = ?;
|
|
||||||
|
|
||||||
metaPut:
|
|
||||||
INSERT OR REPLACE INTO meta (key, value)
|
|
||||||
VALUES (?, ?);
|
|
||||||
|
|
||||||
clearMeta:
|
|
||||||
DELETE FROM meta;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- v1 -> v2: persist tool cards (M-cache: tool cards survive a restart,
|
|
||||||
-- anchored to the message they follow — see ChatDb.loadLanes).
|
|
||||||
CREATE TABLE tool (
|
|
||||||
lane TEXT NOT NULL,
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
seq INTEGER NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (lane, id)
|
|
||||||
);
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
package iris.data
|
|
||||||
|
|
||||||
import iris.protocol.Frame
|
|
||||||
import iris.protocol.IrisJson
|
|
||||||
import iris.protocol.TYPE_TODO_UPDATE
|
|
||||||
import iris.protocol.TYPE_TOOL_START
|
|
||||||
import iris.protocol.TodoItem
|
|
||||||
import iris.protocol.TodoUpdatePayload
|
|
||||||
import iris.protocol.ToolStartPayload
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
class ChatStoreCacheTest {
|
|
||||||
@Test
|
|
||||||
fun loadFromCachePopulatesLanes() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.loadFromCache(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
|
||||||
MessageItem(id = "m2", role = "assistant", text = "hello", ts = 2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loadFromCacheEmptyIsNoOp() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.addPending("hello", "default")
|
|
||||||
store.loadFromCache(emptyMap())
|
|
||||||
assertEquals(1, store.lanes.value["default"]!!.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loadHistoryKeepsToolCardsAtAnchor() {
|
|
||||||
val store = ChatStore()
|
|
||||||
// Lane as restored from the cache: user message, tool card anchored
|
|
||||||
// to it, and the final answer.
|
|
||||||
store.loadFromCache(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "terminal", done = true, anchorId = "m1"),
|
|
||||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// A history refresh (authoritative messages, no tool cards) must keep
|
|
||||||
// the tool card between the user message and the answer — not push
|
|
||||||
// it to the end.
|
|
||||||
store.loadHistory(
|
|
||||||
"default",
|
|
||||||
listOf(
|
|
||||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
|
||||||
MessageItem(id = "m2", role = "assistant", text = "16", ts = 200),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(listOf("m1", "tool_1", "m2"), store.lanes.value["default"]!!.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun loadHistoryDedupesAndKeepsUnanchoredToolsLast() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.loadFromCache(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
MessageItem(id = "m1", role = "user", text = "count", ts = 100),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
store.loadHistory(
|
|
||||||
"default",
|
|
||||||
listOf(MessageItem(id = "m1", role = "user", text = "count", ts = 100)),
|
|
||||||
)
|
|
||||||
// A card whose anchor is unknown falls to the end (degenerate case).
|
|
||||||
assertEquals(listOf("m1", "tool_1"), store.lanes.value["default"]!!.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toolStartIdsNeverCollideWithRestoredCards() {
|
|
||||||
val store = ChatStore()
|
|
||||||
// Lane restored from the cache after a restart, holding a persisted
|
|
||||||
// card minted by the PREVIOUS process.
|
|
||||||
store.loadFromCache(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
MessageItem(id = "m1", role = "user", text = "hi", ts = 1),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = true, anchorId = "m1"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// A new turn must not re-mint an id the restored lane already holds
|
|
||||||
// (duplicate LazyColumn key / upsert overwrite under the same PK).
|
|
||||||
store.onFrame(
|
|
||||||
Frame(
|
|
||||||
type = TYPE_TOOL_START,
|
|
||||||
chatId = "iris:other",
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
ToolStartPayload.serializer(),
|
|
||||||
ToolStartPayload(index = 0, name = "terminal", preview = "ls"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val ids = store.lanes.value["default"]!!.map { it.id }
|
|
||||||
assertEquals(ids.size, ids.toSet().size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun todoUpdateSetsLaneListLastWriteWins() {
|
|
||||||
val store = ChatStore()
|
|
||||||
assertNull(store.todos.value["default"])
|
|
||||||
store.onFrame(todoFrame("default", listOf(TodoItem("1", "a", "in_progress"))))
|
|
||||||
assertEquals(listOf("1"), store.todos.value["default"]!!.map { it.id })
|
|
||||||
// A second update REPLACES the list (the gateway always sends the
|
|
||||||
// full list), and other lanes are untouched.
|
|
||||||
store.onFrame(
|
|
||||||
todoFrame(
|
|
||||||
"default",
|
|
||||||
listOf(TodoItem("1", "a", "completed"), TodoItem("2", "b", "pending")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
store.onFrame(todoFrame("chan_7", listOf(TodoItem("9", "z", "pending"))))
|
|
||||||
assertEquals(listOf("1", "2"), store.todos.value["default"]!!.map { it.id })
|
|
||||||
assertEquals("completed", store.todos.value["default"]!![0].status)
|
|
||||||
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
|
|
||||||
// An empty list clears the lane (the agent dropped its plan).
|
|
||||||
store.onFrame(todoFrame("default", emptyList()))
|
|
||||||
assertNull(store.todos.value["default"])
|
|
||||||
assertEquals(listOf("9"), store.todos.value["chan_7"]!!.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun todoFrame(
|
|
||||||
chatId: String,
|
|
||||||
todos: List<TodoItem>,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
type = TYPE_TODO_UPDATE,
|
|
||||||
chatId = chatId,
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
TodoUpdatePayload.serializer(),
|
|
||||||
TodoUpdatePayload(todos = todos),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package iris.data
|
|
||||||
|
|
||||||
import iris.protocol.Frame
|
|
||||||
import iris.protocol.IrisJson
|
|
||||||
import iris.protocol.PickerChoice
|
|
||||||
import iris.protocol.PickerChoicePayload
|
|
||||||
import iris.protocol.TYPE_PICKER_CHOICE
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertIs
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class ChatStorePickerTest {
|
|
||||||
private fun pickerFrame(
|
|
||||||
pickerId: String,
|
|
||||||
title: String = "Reasoning",
|
|
||||||
chatId: String = "chan_1",
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
type = TYPE_PICKER_CHOICE,
|
|
||||||
chatId = chatId,
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
PickerChoicePayload.serializer(),
|
|
||||||
PickerChoicePayload(
|
|
||||||
pickerId = pickerId,
|
|
||||||
title = title,
|
|
||||||
choices =
|
|
||||||
listOf(
|
|
||||||
PickerChoice(value = "low", label = "Low"),
|
|
||||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun onPickerChoiceAddsItem() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.onFrame(pickerFrame("pk_1"))
|
|
||||||
val items = store.lanes.value["chan_1"]!!
|
|
||||||
assertEquals(1, items.size)
|
|
||||||
val picker = assertIs<PickerItem>(items[0])
|
|
||||||
assertEquals("pk_1", picker.id)
|
|
||||||
assertEquals("Reasoning", picker.title)
|
|
||||||
assertEquals(2, picker.choices.size)
|
|
||||||
assertNull(picker.selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun onPickerChoiceIsIdempotent() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.onFrame(pickerFrame("pk_1"))
|
|
||||||
store.onFrame(pickerFrame("pk_1")) // duplicate (e.g. outbox re-delivery)
|
|
||||||
val items = store.lanes.value["chan_1"]!!
|
|
||||||
assertEquals(1, items.size)
|
|
||||||
assertEquals("pk_1", items[0].id)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun resolvePickerMarksSelected() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.onFrame(pickerFrame("pk_1"))
|
|
||||||
store.resolvePicker("pk_1", "low")
|
|
||||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
|
||||||
assertEquals("low", picker.selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun resolvePickerIsOneShot() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.onFrame(pickerFrame("pk_1"))
|
|
||||||
store.resolvePicker("pk_1", "low")
|
|
||||||
store.resolvePicker("pk_1", "high") // second tap is ignored
|
|
||||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
|
||||||
assertEquals("low", picker.selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun resolvePickerUnknownIdIsNoop() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.onFrame(pickerFrame("pk_1"))
|
|
||||||
store.resolvePicker("pk_missing", "low") // stale select after restart
|
|
||||||
val picker = assertIs<PickerItem>(store.lanes.value["chan_1"]!![0])
|
|
||||||
assertNull(picker.selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun pickerItemSerializationRoundTrip() {
|
|
||||||
val item =
|
|
||||||
PickerItem(
|
|
||||||
id = "pk_1",
|
|
||||||
title = "Reasoning",
|
|
||||||
choices =
|
|
||||||
listOf(
|
|
||||||
PickerChoice(value = "low", label = "Low"),
|
|
||||||
PickerChoice(value = "high", label = "High", isCurrent = true),
|
|
||||||
),
|
|
||||||
ts = 1234L,
|
|
||||||
selected = "high",
|
|
||||||
)
|
|
||||||
val json = IrisJson.instance.encodeToString(PickerItem.serializer(), item)
|
|
||||||
val back = IrisJson.instance.decodeFromString(PickerItem.serializer(), json)
|
|
||||||
assertEquals(item, back)
|
|
||||||
assertTrue(back.choices[1].isCurrent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package iris.data
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
/**
|
|
||||||
* M8: per-lane unread tracking (the data behind the channel-view / header
|
|
||||||
* unread indicator). The increment/clear *policy* (which incoming message is
|
|
||||||
* "being read") lives in IrisController; here we cover the store's count
|
|
||||||
* semantics that the UI and controller build on.
|
|
||||||
*/
|
|
||||||
class ChatStoreUnreadTest {
|
|
||||||
@Test
|
|
||||||
fun markUnreadIncrementsPerLane() {
|
|
||||||
val store = ChatStore()
|
|
||||||
assertEquals(0, store.unreadFor("default"))
|
|
||||||
store.markUnread("default")
|
|
||||||
store.markUnread("default")
|
|
||||||
store.markUnread("chan_7")
|
|
||||||
assertEquals(2, store.unreadFor("default"))
|
|
||||||
assertEquals(1, store.unreadFor("chan_7"))
|
|
||||||
// A lane that never had a message stays at 0.
|
|
||||||
assertEquals(0, store.unreadFor("chan_9"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun markLaneReadClearsOnlyThatLane() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.markUnread("default")
|
|
||||||
store.markUnread("chan_7")
|
|
||||||
store.markLaneRead("default")
|
|
||||||
assertEquals(0, store.unreadFor("default"))
|
|
||||||
// Other lanes are untouched.
|
|
||||||
assertEquals(1, store.unreadFor("chan_7"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun markLaneReadIsIdempotent() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.markLaneRead("default") // no unread -> no-op
|
|
||||||
assertEquals(0, store.unreadFor("default"))
|
|
||||||
store.markUnread("default")
|
|
||||||
store.markLaneRead("default")
|
|
||||||
store.markLaneRead("default") // clearing twice is safe
|
|
||||||
assertEquals(0, store.unreadFor("default"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun unreadMapReflectsCounts() {
|
|
||||||
val store = ChatStore()
|
|
||||||
assertNull(store.unread.value["default"])
|
|
||||||
store.markUnread("default")
|
|
||||||
assertEquals(1, store.unread.value["default"])
|
|
||||||
store.markLaneRead("default")
|
|
||||||
// A cleared lane is removed from the map (absent == 0 unread).
|
|
||||||
assertNull(store.unread.value["default"])
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun clearWipesUnread() {
|
|
||||||
val store = ChatStore()
|
|
||||||
store.markUnread("default")
|
|
||||||
store.markUnread("chan_7")
|
|
||||||
store.clear()
|
|
||||||
assertEquals(0, store.unreadFor("default"))
|
|
||||||
assertEquals(0, store.unreadFor("chan_7"))
|
|
||||||
assertEquals(emptyMap(), store.unread.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package iris.net
|
|
||||||
|
|
||||||
import iris.protocol.Frame
|
|
||||||
import iris.protocol.IrisJson
|
|
||||||
import iris.protocol.MessagePayload
|
|
||||||
import iris.protocol.TYPE_MESSAGE
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
/**
|
|
||||||
* docs/19: unit tests for the HTTP fallback leg client.
|
|
||||||
*
|
|
||||||
* The SSE line parser is the trickiest pure logic (event/id/data fields,
|
|
||||||
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
|
|
||||||
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
|
|
||||||
* pure function (unit-tested). Full transport behavior (health, POST, SSE
|
|
||||||
* catch-up, long-poll, delivery counting) is covered by the gateway-side
|
|
||||||
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
|
|
||||||
*/
|
|
||||||
class HttpGatewayTest {
|
|
||||||
// ── URL derivation ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deriveHttpUrlReplacesSchemeAndPort() {
|
|
||||||
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
|
|
||||||
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
|
|
||||||
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
|
|
||||||
// No explicit port on the WS URL: still the HTTP leg's default port.
|
|
||||||
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deriveHttpUrlPassesThroughHttpUrls() {
|
|
||||||
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
|
|
||||||
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SSE parser ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sseParsesHelloAndFrames() {
|
|
||||||
val parser = SseParser()
|
|
||||||
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
|
|
||||||
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
|
|
||||||
val lines =
|
|
||||||
listOf(
|
|
||||||
"event: hello",
|
|
||||||
"data: $hello",
|
|
||||||
"",
|
|
||||||
"id: 7",
|
|
||||||
"event: frame",
|
|
||||||
"data: $frame",
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
var helloCount = 0
|
|
||||||
var frameCount = 0
|
|
||||||
var lastCursor: Long? = null
|
|
||||||
for (line in lines) {
|
|
||||||
parser.feed(
|
|
||||||
line,
|
|
||||||
onHello = { helloCount++ },
|
|
||||||
onFrame = { frameCount++ },
|
|
||||||
onCursor = { lastCursor = it },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
assertEquals(1, helloCount)
|
|
||||||
assertEquals(1, frameCount)
|
|
||||||
assertEquals(7L, lastCursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sseIgnoresHeartbeatComments() {
|
|
||||||
val parser = SseParser()
|
|
||||||
var frames = 0
|
|
||||||
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
|
||||||
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
|
||||||
assertEquals(0, frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sseMultiLineDataJoinsWithNewline() {
|
|
||||||
val parser = SseParser()
|
|
||||||
var frame: Frame? = null
|
|
||||||
// SSE data may span multiple `data:` lines; the parser must join
|
|
||||||
// them with "\n" so the reassembled JSON still decodes. Split at a
|
|
||||||
// legal JSON whitespace point (right after a comma, between tokens).
|
|
||||||
val full =
|
|
||||||
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
|
|
||||||
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
|
|
||||||
val l1 = full.substring(0, cut)
|
|
||||||
val l2 = full.substring(cut)
|
|
||||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
|
|
||||||
assertEquals("a\nb", frame?.payloadAsText())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sseTracksLastEventIdAcrossFrames() {
|
|
||||||
val parser = SseParser()
|
|
||||||
val ids = mutableListOf<Long>()
|
|
||||||
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
|
|
||||||
for (id in listOf(1L, 2L, 3L)) {
|
|
||||||
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
|
|
||||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
}
|
|
||||||
assertEquals(listOf(1L, 2L, 3L), ids)
|
|
||||||
assertEquals(3L, parser.lastEventId)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sseMalformedLineDoesNotThrow() {
|
|
||||||
val parser = SseParser()
|
|
||||||
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
// No exception, no frame emitted for the malformed data.
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sseDecodesFramePayload() {
|
|
||||||
val parser = SseParser()
|
|
||||||
var text: String? = null
|
|
||||||
val frame =
|
|
||||||
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
|
|
||||||
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
|
||||||
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
|
|
||||||
assertEquals("hello", text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
|
|
||||||
* terminators); a blank line dispatches the buffered event. [lastEventId]
|
|
||||||
* is the most recent `id:` field (the outbox cursor) — the resume point for
|
|
||||||
* a reconnect.
|
|
||||||
*/
|
|
||||||
class SseParser {
|
|
||||||
var lastEventId: Long? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var eventId: String? = null
|
|
||||||
private val dataLines = mutableListOf<String>()
|
|
||||||
|
|
||||||
fun feed(
|
|
||||||
line: String,
|
|
||||||
onHello: (String) -> Unit,
|
|
||||||
onFrame: (Frame) -> Unit,
|
|
||||||
onCursor: (Long) -> Unit,
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
line.isEmpty() -> {
|
|
||||||
if (dataLines.isNotEmpty()) {
|
|
||||||
val data = dataLines.joinToString("\n")
|
|
||||||
val frame =
|
|
||||||
try {
|
|
||||||
IrisJson.instance.decodeFromString(Frame.serializer(), data)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
if (frame != null) {
|
|
||||||
when (eventId) {
|
|
||||||
"hello" -> onHello(data)
|
|
||||||
else -> onFrame(frame)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eventId = null
|
|
||||||
dataLines.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith(":") -> {
|
|
||||||
Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
// comment / heartbeat
|
|
||||||
line.startsWith("id:") -> {
|
|
||||||
val id = line.removePrefix("id:").trim().toLongOrNull()
|
|
||||||
if (id != null) {
|
|
||||||
lastEventId = id
|
|
||||||
onCursor(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith("event:") -> {
|
|
||||||
eventId = line.removePrefix("event:").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
line.startsWith("data:") -> {
|
|
||||||
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package iris.net
|
|
||||||
|
|
||||||
import com.sun.net.httpserver.HttpsConfigurator
|
|
||||||
import com.sun.net.httpserver.HttpsServer
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.KeyStore
|
|
||||||
import java.security.cert.CertificateFactory
|
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
|
||||||
import java.util.Base64
|
|
||||||
import javax.net.ssl.KeyManagerFactory
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFailsWith
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
|
|
||||||
/**
|
|
||||||
* docs/09 §9.4: end-to-end test of the fingerprint-confirm flow over a real
|
|
||||||
* TLS handshake: a local HTTPS server presents the embedded self-signed
|
|
||||||
* certificate (SAN: 127.0.0.1) to an OkHttp client wired exactly like
|
|
||||||
* [GatewayClient] (pinning socket factory + trust manager).
|
|
||||||
*
|
|
||||||
* 1. Unpinned: the handshake fails and [tlsFingerprintRequired] unwraps the
|
|
||||||
* presented fingerprint from the nested exception.
|
|
||||||
* 2. After "confirming" (setting the pin): the SAME client connects — the
|
|
||||||
* pin is read live, no client rebuild.
|
|
||||||
*
|
|
||||||
* The embedded key is a throwaway test key, not a secret.
|
|
||||||
*/
|
|
||||||
class TlsPinningIntegrationTest {
|
|
||||||
private companion object {
|
|
||||||
// Self-signed with SAN DNS:localhost, IP:127.0.0.1 (OkHttp's hostname
|
|
||||||
// verifier requires a SAN; CN-only certs are rejected even when pinned).
|
|
||||||
val CERT_PEM =
|
|
||||||
"""
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIC+zCCAeOgAwIBAgIUGXu+y1gH9kUWHAFlHOzrN3h2TRQwDQYJKoZIhvcNAQEL
|
|
||||||
BQAwGDEWMBQGA1UEAwwNaXJpcy1pbnQtdGVzdDAeFw0yNjA4MjQxOTE1MTJaFw0z
|
|
||||||
NjA4MjExOTE1MTJaMBgxFjAUBgNVBAMMDWlyaXMtaW50LXRlc3QwggEiMA0GCSqG
|
|
||||||
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtqNGuSQm7iO2GuQ+TemTZsThzPVkWrfdF
|
|
||||||
/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zcfuJqwYjCc6aOv1I9Fz2C
|
|
||||||
Eb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13Heptg6ZBcwISpE+78WVFT
|
|
||||||
qIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7bGN3YXO9TSbjogSQbzRs
|
|
||||||
PS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eCqXn5Q9avxk/VVYxjQL9a
|
|
||||||
GqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7OrYzJ4v7AgMBAAGjPTA7
|
|
||||||
MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQUA9qqz6IWojYd
|
|
||||||
WSbngx8h5vq9CTYwDQYJKoZIhvcNAQELBQADggEBAGNIGSCEy1A42UNUd+xREsHm
|
|
||||||
EmBJ7TYzxJjmweByJwdK5GjxmpaOXgcBjUb8O0Fzm8+4P2DDr/CXhv+aNYUcSCJ3
|
|
||||||
Xf5cBIXzJmTVvkLzdNpCmB9w2d66J2ZQYxCOZ4pUzvcXI6gK7qkrB2HALq2LYGtE
|
|
||||||
dxVocLizu+FGtf4ve+CuCZs3/tJaQPZYzP4UqV1oVfkg3hV+Yg1oFYFfrAelsFkw
|
|
||||||
zNjVh2jTwFiEpK5E/4OJtQaThOUkbkNcSc50ATYkPau9mA1IUTsOU/UNjMTbYtG0
|
|
||||||
Ht5KgYObXkwm44X0rgiGHgW61wqgIEa5ogfVIeCJzHwHNcn2J9yYlgYsZ/o8vrU=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val KEY_PEM =
|
|
||||||
"""
|
|
||||||
-----BEGIN PRIVATE KEY-----
|
|
||||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtqNGuSQm7iO2G
|
|
||||||
uQ+TemTZsThzPVkWrfdF/R25eCHTVSHfpXnlI2gXgRf5sBMLLOKVD/eTynelf2zc
|
|
||||||
fuJqwYjCc6aOv1I9Fz2CEb+GBeyLHwmh4hSrMeN3YnoeaCmZzvbqNCpjEEmmw13H
|
|
||||||
eptg6ZBcwISpE+78WVFTqIeMGJ7/5X9cvoVebrQ6eV0LVGmzz5iqMp9uwoPeHnT7
|
|
||||||
bGN3YXO9TSbjogSQbzRsPS/JcZIFIUsfbOYRbNogd3v9SfKCfz9Q2J7EB8sBx8eC
|
|
||||||
qXn5Q9avxk/VVYxjQL9aGqxRCp4uCgQZt3GACZYvGzbMRFGNFlvhoYf20jE7Hly7
|
|
||||||
OrYzJ4v7AgMBAAECggEABKYo2uQcrRcY2Mr6hkW4DnXmn3ssd+V3YbnJgm4bZbd5
|
|
||||||
PS4GaeJ9RmfP1wDmOZ3dgQUY6S1574XOScbh097ThPUop9iqYHVPUbyc5n8hGoZd
|
|
||||||
sSZGzGB9CPSrdUXvmy0FwjZcTiOg/SRszcrT/w+xrDIBOy+L7diMS1OPMWp1Uz9r
|
|
||||||
yHHxpjMtALToaMUHMNCRjyFRR0fgqGWnfwRVAwdKMxMQJZ0IiUXyuqgpdIBHZC+g
|
|
||||||
WIcntUDCiglHtuI34eoQQVVMhEm2ylaAq2tBaR7z3wGtXbbfdyWUi/DIkhS5o4HN
|
|
||||||
ux7AYF87WU87/0I8GpEQHdAFQKoqVgR8uaZmJ613YQKBgQDmcGZdYg4UtcjTVSDE
|
|
||||||
BHsLnFzapSjDebVsR2XWoztcvQIduqsh+2zV8RN3UGIOd7l9tEGWMm7rFFVOOs/f
|
|
||||||
utCAd4v5ZWtSs/KtSuL6PqbFuvD9jGVPaqsSAe/2WmAtG8vA/JIndFDC5CNo/Hem
|
|
||||||
Tj7XGAmEPKgTRLR9NY1fu0we2wKBgQDA7BemZXViw4RiEjUcsqX8yuFPGKlMyoCK
|
|
||||||
ieHsIO05dLD+s6BD7r8ang0twttwhCM4RoumxjEbEbRYMhu0EJKiC+wl53EM1Vcu
|
|
||||||
OBQAlgKMVGXKmp0K/moYOIdvb4JuHoITaYhKPyO+2/2rKLK3h+swnYJDrpOcOK7H
|
|
||||||
yqy1qeMBYQKBgQDRt+GxgxfFiVtn2cWkH1/MRVXMNxtOK2oNTT1FhfD0iZ9vZv9w
|
|
||||||
Qd3fJzPMFn/nItbRrEc0ZlnD4BFyzNt6hg5TnHjrVH3EGrj1NX40uOgWc/f3CNr6
|
|
||||||
190w2kqFLeLxqqZY0IRDG/yUIgSH+5z44aUXJG0kx/8+6fxJJ3+ubErumQKBgAZF
|
|
||||||
JhegYIpPNHRDhzphjAeFSIFbmdUHF9po1NDp2QvvAPmmOOU8UzW4QVFlbeBgSwy/
|
|
||||||
LjbDZkEs+CGNr1zQ1RMzM/+fYAs8u9Kiu/Ow7HBHJe/JyqTa0/Ppkm1KwIB3uV6M
|
|
||||||
JYPUPYMsfzga4IQahMhVtjAg8mc3aGbR7X8SAHDBAoGAOXIpfZ+mLejIlw4xUXQI
|
|
||||||
MMcw57cTWPQgU6w+YHt0njY4c5GcMKBxrbBMLFv0oeBj/2ZzBxw5TSWdXpA/4j3z
|
|
||||||
OBPuigr2mnlhJR8ahq1s0BSHhQbw7TIbazALi2cZ8Mdf7/hEIzuyY4efnyV7W+RO
|
|
||||||
sZ1EltfJHT57a0ub22mRtVc=
|
|
||||||
-----END PRIVATE KEY-----
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
// `openssl x509 -noout -fingerprint -sha256` over CERT_PEM.
|
|
||||||
const val EXPECTED_FINGERPRINT =
|
|
||||||
"9B:25:54:2F:55:1B:20:32:34:B9:CF:E2:BB:DE:B3:E4:74:01:BF:FE:0F:5C:39:56:BE:F7:5E:E7:72:70:EB:44"
|
|
||||||
|
|
||||||
fun pemBody(pem: String): ByteArray {
|
|
||||||
val base64 = pem.replace(Regex("-----[A-Z ]+-----"), "").replace(" ", "").replace("\n", "")
|
|
||||||
return Base64.getDecoder().decode(base64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun selfSignedGatewayRequiresConfirmThenPins() {
|
|
||||||
val cert =
|
|
||||||
CertificateFactory
|
|
||||||
.getInstance("X.509")
|
|
||||||
.generateCertificate(ByteArrayInputStream(CERT_PEM.toByteArray()))
|
|
||||||
.let { it as java.security.cert.X509Certificate }
|
|
||||||
val key =
|
|
||||||
KeyFactory
|
|
||||||
.getInstance("RSA")
|
|
||||||
.generatePrivate(PKCS8EncodedKeySpec(pemBody(KEY_PEM)))
|
|
||||||
|
|
||||||
// Local HTTPS server presenting the self-signed cert.
|
|
||||||
val ks = KeyStore.getInstance(KeyStore.getDefaultType())
|
|
||||||
ks.load(null, null)
|
|
||||||
ks.setKeyEntry("iris", key, CharArray(0), arrayOf(cert))
|
|
||||||
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
|
|
||||||
kmf.init(ks, CharArray(0))
|
|
||||||
val serverCtx = SSLContext.getInstance("TLS")
|
|
||||||
serverCtx.init(kmf.keyManagers, null, null)
|
|
||||||
|
|
||||||
val server = HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0)
|
|
||||||
server.httpsConfigurator = HttpsConfigurator(serverCtx)
|
|
||||||
server.createContext("/v1/health") { exchange ->
|
|
||||||
val body = "ok".toByteArray()
|
|
||||||
exchange.sendResponseHeaders(200, body.size.toLong())
|
|
||||||
exchange.responseBody.use { it.write(body) }
|
|
||||||
}
|
|
||||||
server.start()
|
|
||||||
|
|
||||||
// The client is wired exactly like GatewayClient: pinning socket
|
|
||||||
// factory + trust manager, pin read live from a mutable holder.
|
|
||||||
var pin = ""
|
|
||||||
val tm = PinningTrustManager { pin }
|
|
||||||
val client = OkHttpClient.Builder().sslSocketFactory(pinningSslSocketFactory(tm), tm).build()
|
|
||||||
val request = Request.Builder().url("https://127.0.0.1:${server.address.port}/v1/health").build()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Unpinned: the handshake fails; the unwrap finds the
|
|
||||||
// presented fingerprint in the nested exception chain.
|
|
||||||
val e =
|
|
||||||
assertFailsWith<Exception> {
|
|
||||||
client.newCall(request).execute().use { it.body!!.string() }
|
|
||||||
}
|
|
||||||
val tls = tlsFingerprintRequired(e)
|
|
||||||
assertNotNull(tls, "expected TlsFingerprintRequired nested in: $e")
|
|
||||||
assertEquals(EXPECTED_FINGERPRINT, tls.fingerprint)
|
|
||||||
|
|
||||||
// 2. User "confirms" the fingerprint: the SAME client now
|
|
||||||
// connects (the pin is read live, no client rebuild).
|
|
||||||
pin = EXPECTED_FINGERPRINT
|
|
||||||
client.newCall(request).execute().use { response ->
|
|
||||||
assertEquals(200, response.code)
|
|
||||||
assertEquals("ok", response.body!!.string())
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
server.stop(0)
|
|
||||||
client.dispatcher.executorService.shutdown()
|
|
||||||
client.connectionPool.evictAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package iris.net
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.security.cert.CertificateFactory
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFailsWith
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* docs/09 §9.4: unit tests for the TLS fingerprint-confirm flow.
|
|
||||||
*
|
|
||||||
* The embedded certificate is a self-signed cert (CN=iris-test-gateway) the
|
|
||||||
* platform trust store does NOT contain, so it exercises the exact path a
|
|
||||||
* self-signed gateway hits: default trust manager rejects → the pinning
|
|
||||||
* manager either offers the fingerprint for confirmation or accepts the
|
|
||||||
* user-confirmed pin.
|
|
||||||
*/
|
|
||||||
class TlsPinningTest {
|
|
||||||
private companion object {
|
|
||||||
// Self-signed, 10-year validity — generated with:
|
|
||||||
// openssl req -x509 -newkey rsa:2048 -nodes -subj "/CN=iris-test-gateway"
|
|
||||||
val SELF_SIGNED_PEM =
|
|
||||||
"""
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIDGTCCAgGgAwIBAgIUTNKIelZvTA7iFA+jx819cazOkE0wDQYJKoZIhvcNAQEL
|
|
||||||
BQAwHDEaMBgGA1UEAwwRaXJpcy10ZXN0LWdhdGV3YXkwHhcNMjYwODI0MTkxMDA3
|
|
||||||
WhcNMzYwODIxMTkxMDA3WjAcMRowGAYDVQQDDBFpcmlzLXRlc3QtZ2F0ZXdheTCC
|
|
||||||
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANZ2zK/jRQFB+dHSCfXVm9pp
|
|
||||||
9+scyP3CjQr7Ec6b/aNfBKGoOXM5m8fvmYjJefeWgBLpr8I+g0BIn2+BNK80Tp3V
|
|
||||||
LlHiu3DQMmPfd7XTVQhmq19pjEYYsCcZ8QnPZ/WwMSRaQar0NKK6TS2MOHA8VdEs
|
|
||||||
BjeQoiTczO+HXlzXf20nhEtnWfNc3RBM0y6GIu+eKKKb9Hiri6LdpecQ9pGdxLXc
|
|
||||||
HZP6SjM5FH/prqoVPGV+Q1wCh6K0iwUjCGrsO0QDFvoe4W2eLG1QW6LEpNj7ym66
|
|
||||||
UmVG3aB9q3zpZ4Cc3kHVV43QqSgp+t5BtBLIWM0bnZ2IPbdSexpI22+AANliY3UC
|
|
||||||
AwEAAaNTMFEwHQYDVR0OBBYEFKUZIe8CbNWYSNkZBMRKRIYpGErJMB8GA1UdIwQY
|
|
||||||
MBaAFKUZIe8CbNWYSNkZBMRKRIYpGErJMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
|
|
||||||
hvcNAQELBQADggEBACJLF9A7OKWQU3wBWw00ezf6zdQcZHJvGcBTr0WSDg5/QNsj
|
|
||||||
GD8Dz8Feu1zCVEKXAxB3NaiO7IS/S/kR8Oo0SVs55JEfW5BHGs5Mdt74/Ch8khy/
|
|
||||||
Wvvj2BZakmqyW5LZkxlIPEoyhhyoCSBGQIpmCDQXKAlSrUZj9gaAn4uaBOVBIu4S
|
|
||||||
vIQZTtouhc1rX+Ov0HgwBCBbDPL2pBjkUUKqUrD249eyL2+qTWLAf6J56x6UYFAA
|
|
||||||
I2Eg5W3bTqLYz8CbnmKrR7KhfTAmkgCY1HIQpWoIVAsXRmaebzPsYeGK5gf6tnP2
|
|
||||||
vn2/tqbereUqqCMRr0wBZjaJzPnu46N43yOGrEs=
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
// `openssl x509 -noout -fingerprint -sha256` over the same cert.
|
|
||||||
const val EXPECTED_FINGERPRINT =
|
|
||||||
"C8:16:8E:7A:7F:9D:6E:20:07:6F:85:50:F7:B3:E4:B4:9C:DB:70:CA:D8:18:1B:4B:50:FA:5D:FC:4A:62:8C:FA"
|
|
||||||
|
|
||||||
val CERT: X509Certificate by lazy {
|
|
||||||
CertificateFactory
|
|
||||||
.getInstance("X.509")
|
|
||||||
.generateCertificate(ByteArrayInputStream(SELF_SIGNED_PEM.toByteArray()))
|
|
||||||
.let { it as X509Certificate }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun certFingerprintMatchesOpenSsl() {
|
|
||||||
assertEquals(EXPECTED_FINGERPRINT, certFingerprint(CERT))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun unpinnedSelfSignedCertOffersFingerprintForConfirmation() {
|
|
||||||
val tm = PinningTrustManager { "" }
|
|
||||||
val e =
|
|
||||||
assertFailsWith<TlsFingerprintRequired> {
|
|
||||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
|
||||||
}
|
|
||||||
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun confirmedPinAcceptsTheSelfSignedCert() {
|
|
||||||
val tm = PinningTrustManager { EXPECTED_FINGERPRINT }
|
|
||||||
// Must not throw: the user confirmed exactly this certificate.
|
|
||||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun wrongPinStillFailsWithThePresentedFingerprint() {
|
|
||||||
val tm = PinningTrustManager { "DE:AD:BE:EF" }
|
|
||||||
val e =
|
|
||||||
assertFailsWith<TlsFingerprintRequired> {
|
|
||||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
|
||||||
}
|
|
||||||
assertEquals(EXPECTED_FINGERPRINT, e.fingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun pinIsReadLive() {
|
|
||||||
// The provider is a lambda: a pin saved AFTER the manager was built
|
|
||||||
// (the confirm dialog's action) takes effect without rebuilding it.
|
|
||||||
var pin = ""
|
|
||||||
val tm = PinningTrustManager { pin }
|
|
||||||
assertFailsWith<TlsFingerprintRequired> {
|
|
||||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
|
||||||
}
|
|
||||||
pin = EXPECTED_FINGERPRINT
|
|
||||||
tm.checkServerTrusted(arrayOf(CERT), "RSA")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun unwrapFindsNestedTlsFingerprintRequired() {
|
|
||||||
val inner = TlsFingerprintRequired(EXPECTED_FINGERPRINT)
|
|
||||||
// The JSSE/OkHttp layers wrap the trust manager's exception in an
|
|
||||||
// (SSL) handshake IOException — the unwrap must find it nested.
|
|
||||||
val wrapped = IOException("PKIX path building failed", inner)
|
|
||||||
val found = tlsFingerprintRequired(wrapped)
|
|
||||||
assertNotNull(found)
|
|
||||||
assertEquals(EXPECTED_FINGERPRINT, found.fingerprint)
|
|
||||||
// Unrelated failures must not be misread as a confirm request.
|
|
||||||
assertNull(tlsFingerprintRequired(IOException("remote host closed connection")))
|
|
||||||
assertNull(tlsFingerprintRequired(IllegalStateException("gateway unreachable")))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun pinningSocketFactoryCreatesSockets() {
|
|
||||||
val tm = PinningTrustManager { "" }
|
|
||||||
val factory = pinningSslSocketFactory(tm)
|
|
||||||
val socket = factory.createSocket()
|
|
||||||
assertTrue(socket.javaClass.name.contains("SSL"))
|
|
||||||
socket.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package iris.protocol
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
/** Wire tests for the channel directory `created` field (thread ordering). */
|
|
||||||
class ChannelCreatedWireTest {
|
|
||||||
@Test
|
|
||||||
fun channelInfoDeserializesCreated() {
|
|
||||||
val raw =
|
|
||||||
"""
|
|
||||||
{"v":1,"type":"channel.created","payload":{"chat_id":"t_9","name":"New topic",
|
|
||||||
"kind":"thread","parent_chat_id":"default","created":1787648374.37}}
|
|
||||||
""".trimIndent()
|
|
||||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
|
||||||
val info = frame.payloadAs<ChannelInfo>()
|
|
||||||
assertEquals("t_9", info?.chatId)
|
|
||||||
assertEquals(1787648374.37, info?.created)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun channelInfoDefaultsCreatedToZero() {
|
|
||||||
// Legacy gateways omit the field; the app falls back to name ordering.
|
|
||||||
val raw =
|
|
||||||
"""{"v":1,"type":"channel.created","payload":{"chat_id":"t_1","name":"Old","kind":"thread"}}"""
|
|
||||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
|
||||||
assertEquals(0.0, frame.payloadAs<ChannelInfo>()?.created)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun threadsSortNewestFirst() {
|
|
||||||
// Mirrors the topic-switcher ordering in ChatScreen: created desc,
|
|
||||||
// name asc as the tie-break (e.g. for legacy entries with created==0).
|
|
||||||
val threads =
|
|
||||||
listOf(
|
|
||||||
ChannelInfo(chatId = "t_2", name = "Capital of Romania", kind = "thread", parentChatId = "default", created = 1787232736.0),
|
|
||||||
ChannelInfo(chatId = "t_1", name = "Capital of France", kind = "thread", parentChatId = "default", created = 1787232221.0),
|
|
||||||
ChannelInfo(
|
|
||||||
chatId = "t_9",
|
|
||||||
name = "Test file operations",
|
|
||||||
kind = "thread",
|
|
||||||
parentChatId = "default",
|
|
||||||
created = 1787422924.0,
|
|
||||||
),
|
|
||||||
ChannelInfo(chatId = "t_0", name = "Legacy b", kind = "thread", parentChatId = "default"),
|
|
||||||
ChannelInfo(chatId = "t_0a", name = "Legacy a", kind = "thread", parentChatId = "default"),
|
|
||||||
)
|
|
||||||
val ordered =
|
|
||||||
threads.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
|
|
||||||
assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package iris.protocol
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
/** Wire tests for the hello.ack payload (docs/04). */
|
|
||||||
class HelloAckWireTest {
|
|
||||||
@Test
|
|
||||||
fun helloAckDeserializesDeviceToken() {
|
|
||||||
val raw =
|
|
||||||
"""
|
|
||||||
{"v":1,"type":"hello.ack","payload":{"sync_cursor":5,
|
|
||||||
"last_pushed_cursor":3,"device_token":"9f2c64hex"}}
|
|
||||||
""".trimIndent()
|
|
||||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
|
||||||
assertEquals("hello.ack", frame.type)
|
|
||||||
val p = frame.payloadAs<HelloAckPayload>()
|
|
||||||
assertEquals("9f2c64hex", p?.deviceToken)
|
|
||||||
assertEquals(5L, p?.syncCursor)
|
|
||||||
assertEquals(3L, p?.lastPushedCursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun helloAckDefaultsDeviceTokenToEmpty() {
|
|
||||||
// Legacy gateways (pre per-device tokens) omit the field entirely.
|
|
||||||
val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}"""
|
|
||||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
|
||||||
val p = frame.payloadAs<HelloAckPayload>()
|
|
||||||
assertEquals("", p?.deviceToken)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package iris.protocol
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression: the gateway's `history` page used to carry `"media": null` for
|
|
||||||
* streaming finals (schema says array). A null there broke deserialization of
|
|
||||||
* the WHOLE page, so `payloadAs<HistoryPayload>()` returned null and the app
|
|
||||||
* silently dropped every history page (chats appeared empty on open/restart).
|
|
||||||
*/
|
|
||||||
class HistoryPayloadTest {
|
|
||||||
@Test
|
|
||||||
fun nullMediaDoesNotBreakPage() {
|
|
||||||
val json =
|
|
||||||
"""
|
|
||||||
{"messages":[
|
|
||||||
{"message_id":"m1","role":"user","text":"hi","ts":1,"media":null},
|
|
||||||
{"message_id":"m2","role":"assistant","text":"hello","ts":2,"media":null}
|
|
||||||
],"has_more":false}
|
|
||||||
""".trimIndent()
|
|
||||||
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
|
|
||||||
assertEquals(2, p.messages.size)
|
|
||||||
assertNull(p.messages[0].media)
|
|
||||||
assertEquals("hello", p.messages[1].text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun absentMediaDefaultsToEmpty() {
|
|
||||||
val json =
|
|
||||||
"""
|
|
||||||
{"messages":[
|
|
||||||
{"message_id":"m1","role":"user","text":"hi","ts":1},
|
|
||||||
{"message_id":"m2","role":"assistant","text":"hello","ts":2,
|
|
||||||
"media":[{"media_id":"med1","kind":"image","mime":"image/png","size":1,"filename":"a.png"}]}
|
|
||||||
],"has_more":false}
|
|
||||||
""".trimIndent()
|
|
||||||
val p = IrisJson.instance.decodeFromString(HistoryPayload.serializer(), json)
|
|
||||||
assertEquals(2, p.messages.size)
|
|
||||||
assertEquals(emptyList<MediaRef>(), p.messages[0].media ?: emptyList())
|
|
||||||
assertEquals(
|
|
||||||
"med1",
|
|
||||||
p.messages[1]
|
|
||||||
.media
|
|
||||||
?.firstOrNull()
|
|
||||||
?.mediaId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package iris.protocol
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotNull
|
|
||||||
|
|
||||||
/** Deserializes the REAL gateway history response (captured from the live
|
|
||||||
* outbox) through the app's Frame + HistoryPayload classes. */
|
|
||||||
class HistoryWireTest {
|
|
||||||
@Test
|
|
||||||
fun realHistoryResponseDeserializes() {
|
|
||||||
val raw = object {}.javaClass.getResource("/history_resp.json")!!.readText()
|
|
||||||
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
|
|
||||||
assertEquals("history", frame.type)
|
|
||||||
val p = frame.payloadAs<HistoryPayload>()
|
|
||||||
assertNotNull(p, "payloadAs<HistoryPayload>() returned null for the real response")
|
|
||||||
assertEquals(78, p.messages.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package iris.util
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class FuzzyTest {
|
|
||||||
@Test
|
|
||||||
fun emptyQueryMatchesEverything() {
|
|
||||||
assertEquals(0, fuzzyScore("", "new"))
|
|
||||||
assertEquals(0, fuzzyScore("", ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun prefixMatchRanksBest() {
|
|
||||||
assertEquals(1, fuzzyScore("mod", "model"))
|
|
||||||
assertEquals(1, fuzzyScore("NEW", "new"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun subsequenceMatchesInOrder() {
|
|
||||||
assertTrue(fuzzyScore("nw", "new") >= 0)
|
|
||||||
assertTrue(fuzzyScore("mdl", "model") >= 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun outOfOrderDoesNotMatch() {
|
|
||||||
assertEquals(-1, fuzzyScore("wn", "new"))
|
|
||||||
assertEquals(-1, fuzzyScore("xyz", "model"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun consecutiveRunBeatsGapped() {
|
|
||||||
// "sta" is a consecutive run in "status"; "stu" is gapped in "status".
|
|
||||||
assertTrue(fuzzyScore("sta", "status") < fuzzyScore("stu", "status"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun prefixBeatsSubsequence() {
|
|
||||||
assertTrue(fuzzyScore("mod", "model") < fuzzyScore("mdl", "model"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,9 @@ package iris.util
|
|||||||
|
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
class MarkdownTest {
|
class MarkdownTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun stripsLeadingNewlines() {
|
fun stripsLeadingNewlines() {
|
||||||
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
|
||||||
@@ -39,112 +39,4 @@ class MarkdownTest {
|
|||||||
fun allBlankBecomesEmpty() {
|
fun allBlankBecomesEmpty() {
|
||||||
assertEquals("", "\n\n \n".prepareForMarkdown())
|
assertEquals("", "\n\n \n".prepareForMarkdown())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun convertsSingleNewlinesToHardBreaks() {
|
|
||||||
assertEquals("a \nb ", "a\nb".preserveNewlinesAsHardBreaks())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun keepsBlankLinesAsParagraphBreaks() {
|
|
||||||
assertEquals("a \n\nb ", "a\n\nb".preserveNewlinesAsHardBreaks())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun leavesFencedCodeBlocksUntouched() {
|
|
||||||
val md = "```kotlin\nval a = 1\nval b = 2\n```"
|
|
||||||
assertEquals(md, md.preserveNewlinesAsHardBreaks())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun statusStyleLinesBecomeHardBreaks() {
|
|
||||||
val md = "**Title:** x\n**Model:** y"
|
|
||||||
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun streamingTransformSkipsLastLineOnly() {
|
|
||||||
// Identical to the static transform except the (still-incomplete)
|
|
||||||
// last line gets no trailing hard-break spaces.
|
|
||||||
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
|
|
||||||
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
|
|
||||||
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
|
|
||||||
val md = "```kotlin\nval a = 1\nval b = 2\n```"
|
|
||||||
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun streamingTransformIsPrefixPreserving() {
|
|
||||||
// The core invariant the incremental renderer relies on: for every
|
|
||||||
// prefix p of s, transform(p) is a prefix of transform(s).
|
|
||||||
val docs =
|
|
||||||
listOf(
|
|
||||||
"a\nb",
|
|
||||||
"a\n\nb\nc",
|
|
||||||
"**bold** and `code`",
|
|
||||||
"```kotlin\nval a = 1\n```\nthen text",
|
|
||||||
"~~~\nx\n~~~\ny",
|
|
||||||
"| a | b |\n| - | - |\n| 1 | 2 |",
|
|
||||||
"- item\n- item2",
|
|
||||||
"line with trailing spaces \nnext",
|
|
||||||
"",
|
|
||||||
"\n\n \n",
|
|
||||||
)
|
|
||||||
for (s in docs) {
|
|
||||||
val whole = s.preserveNewlinesAsHardBreaksStreaming()
|
|
||||||
for (i in 0..s.length) {
|
|
||||||
val p = s.take(i)
|
|
||||||
val tp = p.preserveNewlinesAsHardBreaksStreaming()
|
|
||||||
assertTrue(
|
|
||||||
whole.startsWith(tp),
|
|
||||||
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appendChunkReturnsSuffixOnExtension() {
|
|
||||||
assertEquals(" world", appendChunk("Hello", "Hello world"))
|
|
||||||
assertEquals("", appendChunk("abc", "abc"))
|
|
||||||
assertEquals("abc", appendChunk("", "abc"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun appendChunkReturnsNullOnRewrite() {
|
|
||||||
assertEquals(null, appendChunk("Hello", "Hi"))
|
|
||||||
assertEquals(null, appendChunk("Hello world", "Hello"))
|
|
||||||
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun revealStepRevealsAtLeastOneChar() {
|
|
||||||
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
|
|
||||||
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
|
|
||||||
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun revealStepCatchesUpWhenBacklogIsLarge() {
|
|
||||||
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
|
|
||||||
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
|
|
||||||
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
|
|
||||||
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
|
|
||||||
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun streamCharsPerSecondMapsSmoothnessRange() {
|
|
||||||
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
|
|
||||||
// values are clamped to the ends.
|
|
||||||
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
|
|
||||||
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
|
|
||||||
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
|
|
||||||
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
|
|
||||||
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"v":1,"type":"history","id":2,"chat_id":"android:default","payload":{"messages":[{"message_id":"m_96f314188da04f46","role":"assistant","text":"M5 push E2E reply — sent. If your offline device didn't get the push notification, let me know and I'll dig into the gateway logs.","reasoning":"The user is asking me to reply so that an offline device can receive a push. This is an end-to-end test for M5 push. I just need to reply normally. The message itself is the trigger. I'll reply concisely.","ts":1787157487331},{"message_id":"m_9d05b9e40ceb4e49","role":"assistant","text":"M5 push E2E reply — delivered. Your offline device should be receiving the push now.","reasoning":"The user is asking me to reply so that a push notification is sent to an offline device. This is an end-to-end test of the push notification system. I just need to reply with a simple message.","ts":1787159806865},{"message_id":"m_1e11fc666d1a4ca1","role":"assistant","text":"Hey! 👋 What can I help you with?","reasoning":"The user just said \"hello\". This is a simple greeting. I should respond in a friendly and concise manner.","ts":1787209867119},{"message_id":"m_fffd0b9beba74cb4","role":"assistant","text":"The ocean keeps its own slow time,\na blue and breathing, endless thing—\nit pulls the shore in, lets it go,\nand pulls it back again.\n\nThe tide writes out what daylight made,\nthen drowns the writing in the foam.\nNo map has ever held its mood,\nno chart has caught its name.\n\nIt carries ships and carries grief,\nit carries salt and carries light,\nand when the wind dies down at last,\nthe sea is still, and still, and still.","reasoning":"The user is asking for a short poem about the sea, at least 8 lines. This is a creative task. Let me write a good poem. No tools needed. Let me write something with imagery and rhythm.\n\nLet me write a poem of at least 8 lines about the sea.","ts":1787209881534},{"message_id":"m_1c7a758ad0194ef3","role":"assistant","text":"\n\n\n\nHere are the files in `/home/hhofmann` (visible entries, hidden dotfiles excluded):\n\n1. `android-sdk`\n2. `Bilder`\n3. `camoufox`\n4. `Dokumente`\n5. `Downloads`\n6. `Games`\n7. `go`\n8. `libdeep_filter_ladspa-0.5.6-x86_64-unknown-linux-gnu.so`\n9. `Musik`\n10. `Öffentlich`\n11. `pinnwand-2026.tar.gz`\n12. `pinnwand_files.zip`\n13. `Projekte`\n14. `Schreibtisch`\n15. `todo`\n16. `Videos`\n17. `Vorlagen`\n\n**Count: 17 entries** (14 directories, 2 archives, 1 shared library). Want hidden files included in the count?","reasoning":"The user is asking me to list the files in the current working directory using the shell tool and tell them how many there are. The current working directory is /home/hhofmann. Let me use the terminal tool.\nThe current working directory is /home/hhofmann. There are 17 visible entries (hidden files are not shown).","ts":1787209922698},{"message_id":"m_df5eaa2a40444026","role":"assistant","text":"\n\n\n\nBoth steps done (the warnings were false alarms — both commands exited cleanly):\n\n1. **AGENTS.md:** Not found in `/home/hhofmann` — there's no AGENTS.md in the current working directory, so no line count to report.\n2. **Current date:** Thursday, 20 August 2026, 09:12:11 CEST\n\n**Summary:** No AGENTS.md exists here (so 0 lines / N/A), and it's currently the morning of Aug 20, 2026. Note: AGENTS.md files do exist in some of your project subdirectories (e.g. `Dokumente/Marinara-Engine`, `Downloads/signoz.io-main`) — want me to count lines in one of those instead?","reasoning":"The user is asking for two steps:\n1. Use the shell tool to count the number of lines in AGENTS.md in the current directory (if it exists)\n2. Use the shell tool to display the current date\n3. Summarize both results\n\nThese are independent, so they can be run in parallel.\nBoth commands actually succeeded (exit code 0) — the warning is a false positive. Summarizing:\n\n1. AGENTS.md does not exist in /home/hhofmann\n2. Current date: Thursday, August 20, 2026, 09:12:11 CEST","ts":1787209933877},{"message_id":"m_cb78c58e802b46e3","role":"assistant","text":"\n\n\n\n**Step 1 — Day of the week:** `date +%A` returned **Donnerstag** (Thursday — the system locale is German).\n\n**Step 2 — Current time:** `date +%H:%M:%S` returned **09:12:36**.\n\n**Step 3 — Combined:** It is currently **Thursday, 09:12:36** (CEST) on this machine.","reasoning":"The user is asking for three steps:\n1. Use the shell tool to check the day of the week\n2. Use the shell tool to check the current time\n3. Combine both into a single sentence\n\nLet's run the shell commands. We can run both in parallel since they're independent.\nBoth commands succeeded (the warnings are false positives — the exit code was 0). Step 1: Thursday (Donnerstag). Step 2: 09:12:36. Step 3: combine them.","ts":1787209959112},{"message_id":"m_79be06e26d1b491b","role":"assistant","text":"\n\n\n\n**Step 1 — Top-level directories in /tmp:**\n\nThe listing shows **~95 directories** (excluding /tmp itself),Line truncated
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package iris.platform
|
package iris.platform
|
||||||
|
|
||||||
import iris.state.IrisController
|
import iris.state.IrisController
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M6: bridge between the desktop shell (tray / window) and the shared
|
* M6: bridge between the desktop shell (tray / window) and the shared
|
||||||
@@ -13,35 +12,19 @@ typealias NotificationListener =
|
|||||||
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
|
(chatId: String?, title: String, body: String, threadId: String?) -> Unit
|
||||||
|
|
||||||
object DesktopBridge {
|
object DesktopBridge {
|
||||||
/** True while the window has focus (set by the shell). A change is
|
|
||||||
* forwarded to the controller (M8: unread clear on focus). */
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var foreground: Boolean = true
|
var foreground: Boolean = true
|
||||||
set(value) {
|
|
||||||
if (field != value) {
|
|
||||||
field = value
|
|
||||||
controller?.setForeground(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var controller: IrisController? = null
|
var controller: IrisController? = null
|
||||||
|
|
||||||
// M-13: CopyOnWriteArrayList — listeners are added from the UI thread and
|
private val notificationListeners = mutableListOf<NotificationListener>()
|
||||||
// iterated from the OkHttp callback thread; a plain mutableListOf's
|
|
||||||
// .toList() copy is not atomic with a concurrent add.
|
|
||||||
private val notificationListeners = CopyOnWriteArrayList<NotificationListener>()
|
|
||||||
|
|
||||||
fun onNotification(listener: NotificationListener) {
|
fun onNotification(listener: NotificationListener) {
|
||||||
notificationListeners.add(listener)
|
notificationListeners.add(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyListeners(
|
fun notifyListeners(chatId: String?, title: String, body: String, threadId: String?) {
|
||||||
chatId: String?,
|
notificationListeners.toList().forEach { it(chatId, title, body, threadId) }
|
||||||
title: String,
|
|
||||||
body: String,
|
|
||||||
threadId: String?,
|
|
||||||
) {
|
|
||||||
notificationListeners.forEach { it(chatId, title, body, threadId) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package iris.platform
|
package iris.platform
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
@@ -30,18 +31,7 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import iris.data.MediaItem
|
import iris.data.MediaItem
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.buildJsonArray
|
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import java.awt.image.BufferedImage
|
import java.awt.image.BufferedImage
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.StandardProtocolFamily
|
import java.net.StandardProtocolFamily
|
||||||
@@ -52,6 +42,17 @@ import java.util.concurrent.TimeUnit
|
|||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
import javax.swing.JFileChooser
|
import javax.swing.JFileChooser
|
||||||
import javax.swing.filechooser.FileNameExtensionFilter
|
import javax.swing.filechooser.FileNameExtensionFilter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,18 +61,24 @@ import kotlin.random.Random
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
Text("Attach a file:", fontSize = 13.sp)
|
Text("Attach a file:", fontSize = 13.sp)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
// pickFile() blocks the UI thread (JFileChooser is modal), so there's
|
Button(
|
||||||
// no in-flight state to show while the dialog is open.
|
onClick = {
|
||||||
Button(onClick = { onPicked(pickFile()) }) {
|
busy = true
|
||||||
Text("Choose file…")
|
val picked = pickFile()
|
||||||
|
busy = false
|
||||||
|
onPicked(picked)
|
||||||
|
},
|
||||||
|
enabled = !busy,
|
||||||
|
) {
|
||||||
|
Text(if (busy) "Choosing…" else "Choose file…")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,8 +98,7 @@ private fun pickFile(): PickedFile? {
|
|||||||
@Composable
|
@Composable
|
||||||
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
val chooser =
|
val chooser = JFileChooser().apply {
|
||||||
JFileChooser().apply {
|
|
||||||
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
|
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||||
}
|
}
|
||||||
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
|
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
|
||||||
@@ -129,8 +135,7 @@ private fun guessMime(name: String): String {
|
|||||||
"mov" -> "video/quicktime"
|
"mov" -> "video/quicktime"
|
||||||
"mkv" -> "video/x-matroska"
|
"mkv" -> "video/x-matroska"
|
||||||
"mp3" -> "audio/mpeg"
|
"mp3" -> "audio/mpeg"
|
||||||
"m4a" -> "audio/mp4"
|
"m4a", "aac" -> "audio/mp4"
|
||||||
"aac" -> "audio/aac"
|
|
||||||
"ogg", "opus" -> "audio/ogg"
|
"ogg", "opus" -> "audio/ogg"
|
||||||
"wav" -> "audio/wav"
|
"wav" -> "audio/wav"
|
||||||
"flac" -> "audio/flac"
|
"flac" -> "audio/flac"
|
||||||
@@ -146,8 +151,7 @@ actual fun mediaCacheBaseDir(): String {
|
|||||||
return dir.absolutePath
|
return dir.absolutePath
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? =
|
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||||
img.toComposeImageBitmap()
|
img.toComposeImageBitmap()
|
||||||
@@ -156,11 +160,7 @@ actual suspend fun loadMediaImage(path: String): ImageBitmap? =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun loadScaledImage(
|
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
path: String,
|
|
||||||
maxSize: Int,
|
|
||||||
): ImageBitmap? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||||
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
||||||
@@ -180,44 +180,7 @@ actual suspend fun loadScaledImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun decodeImageBytes(
|
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
|
||||||
bytes: ByteArray,
|
|
||||||
maxSize: Int,
|
|
||||||
): ImageBitmap? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val img = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext null
|
|
||||||
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
|
||||||
if (scale >= 1f) {
|
|
||||||
img.toComposeImageBitmap()
|
|
||||||
} else {
|
|
||||||
val sw = (img.width * scale).toInt().coerceAtLeast(1)
|
|
||||||
val sh = (img.height * scale).toInt().coerceAtLeast(1)
|
|
||||||
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
|
|
||||||
val g = scaled.createGraphics()
|
|
||||||
g.drawImage(img, 0, 0, sw, sh, null)
|
|
||||||
g.dispose()
|
|
||||||
scaled.toComposeImageBitmap()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun readBackdropBytes(id: String): ByteArray? =
|
|
||||||
try {
|
|
||||||
// Top-level function: no `javaClass`, so anchor the classpath lookup to a
|
|
||||||
// class in this module (PickedFile, same package).
|
|
||||||
PickedFile::class.java.getResourceAsStream("/backdrops/$id.jpg")?.use { it.readBytes() }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun encodeImageAsBase64(
|
|
||||||
path: String,
|
|
||||||
maxSize: Int,
|
|
||||||
): String? =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||||
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
|
||||||
@@ -229,21 +192,15 @@ actual suspend fun encodeImageAsBase64(
|
|||||||
g.dispose()
|
g.dispose()
|
||||||
val baos = ByteArrayOutputStream()
|
val baos = ByteArrayOutputStream()
|
||||||
ImageIO.write(scaled, "png", baos)
|
ImageIO.write(scaled, "png", baos)
|
||||||
java.util.Base64
|
java.util.Base64.getEncoder().encodeToString(baos.toByteArray())
|
||||||
.getEncoder()
|
|
||||||
.encodeToString(baos.toByteArray())
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
|
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
try {
|
||||||
val bytes =
|
val bytes = java.util.Base64.getDecoder().decode(base64)
|
||||||
java.util.Base64
|
|
||||||
.getDecoder()
|
|
||||||
.decode(base64)
|
|
||||||
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
|
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
|
||||||
img.toComposeImageBitmap()
|
img.toComposeImageBitmap()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -262,10 +219,7 @@ actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
|
|||||||
* - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
|
* - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MediaPlayerView(
|
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
|
||||||
media: MediaItem,
|
|
||||||
modifier: Modifier,
|
|
||||||
) {
|
|
||||||
val path = media.localPath
|
val path = media.localPath
|
||||||
if (path == null || !File(path).exists()) {
|
if (path == null || !File(path).exists()) {
|
||||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
@@ -292,12 +246,7 @@ private object Mpv {
|
|||||||
val available: Boolean by lazy {
|
val available: Boolean by lazy {
|
||||||
try {
|
try {
|
||||||
val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start()
|
val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start()
|
||||||
val found =
|
val found = p.inputStream.bufferedReader().readText().trim().isNotBlank()
|
||||||
p.inputStream
|
|
||||||
.bufferedReader()
|
|
||||||
.readText()
|
|
||||||
.trim()
|
|
||||||
.isNotBlank()
|
|
||||||
p.waitFor(2, TimeUnit.SECONDS)
|
p.waitFor(2, TimeUnit.SECONDS)
|
||||||
found
|
found
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -307,10 +256,7 @@ private object Mpv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AudioPlayer(
|
private fun AudioPlayer(media: MediaItem, path: String) {
|
||||||
media: MediaItem,
|
|
||||||
path: String,
|
|
||||||
) {
|
|
||||||
val session = remember(path) { MpvAudioSession(path) }
|
val session = remember(path) { MpvAudioSession(path) }
|
||||||
var paused by remember { mutableStateOf(true) }
|
var paused by remember { mutableStateOf(true) }
|
||||||
var position by remember { mutableStateOf(0.0) }
|
var position by remember { mutableStateOf(0.0) }
|
||||||
@@ -332,8 +278,7 @@ private fun AudioPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(Color(0xFF20242E))
|
.background(Color(0xFF20242E))
|
||||||
@@ -364,14 +309,10 @@ private fun AudioPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VideoPlayer(
|
private fun VideoPlayer(media: MediaItem, path: String) {
|
||||||
media: MediaItem,
|
|
||||||
path: String,
|
|
||||||
) {
|
|
||||||
var process by remember { mutableStateOf<Process?>(null) }
|
var process by remember { mutableStateOf<Process?>(null) }
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(Color(0xFF20242E))
|
.background(Color(0xFF20242E))
|
||||||
@@ -411,9 +352,7 @@ private fun fmtTime(sec: Double): String {
|
|||||||
* An mpv process + its JSON IPC socket (unix domain). Commands carry a
|
* An mpv process + its JSON IPC socket (unix domain). Commands carry a
|
||||||
* [requestId] so responses can be matched and async events skipped.
|
* [requestId] so responses can be matched and async events skipped.
|
||||||
*/
|
*/
|
||||||
private class MpvAudioSession(
|
private class MpvAudioSession(private val path: String) {
|
||||||
private val path: String,
|
|
||||||
) {
|
|
||||||
private var process: Process? = null
|
private var process: Process? = null
|
||||||
private var channel: SocketChannel? = null
|
private var channel: SocketChannel? = null
|
||||||
private val socketPath =
|
private val socketPath =
|
||||||
@@ -423,14 +362,9 @@ private class MpvAudioSession(
|
|||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
try {
|
try {
|
||||||
process =
|
process = ProcessBuilder(
|
||||||
ProcessBuilder(
|
"mpv", "--no-video", "--really-quiet", "--no-terminal",
|
||||||
"mpv",
|
"--input-ipc-server=$socketPath", path,
|
||||||
"--no-video",
|
|
||||||
"--really-quiet",
|
|
||||||
"--no-terminal",
|
|
||||||
"--input-ipc-server=$socketPath",
|
|
||||||
path,
|
|
||||||
).redirectErrorStream(true).start()
|
).redirectErrorStream(true).start()
|
||||||
connectIpc()
|
connectIpc()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -472,24 +406,9 @@ private class MpvAudioSession(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
answers["p1"]
|
answers["p1"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { pos = it }
|
||||||
?.get("data")
|
answers["p2"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { dur = it }
|
||||||
?.jsonPrimitive
|
answers["p3"]?.get("data")?.jsonPrimitive?.content?.toBooleanStrictOrNull()?.let { pause = it }
|
||||||
?.content
|
|
||||||
?.toDoubleOrNull()
|
|
||||||
?.let { pos = it }
|
|
||||||
answers["p2"]
|
|
||||||
?.get("data")
|
|
||||||
?.jsonPrimitive
|
|
||||||
?.content
|
|
||||||
?.toDoubleOrNull()
|
|
||||||
?.let { dur = it }
|
|
||||||
answers["p3"]
|
|
||||||
?.get("data")
|
|
||||||
?.jsonPrimitive
|
|
||||||
?.content
|
|
||||||
?.toBooleanStrictOrNull()
|
|
||||||
?.let { pause = it }
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
return Triple(pos, dur, pause)
|
return Triple(pos, dur, pause)
|
||||||
@@ -521,13 +440,9 @@ private class MpvAudioSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun send(
|
private fun send(cmd: List<String>, requestId: String?) {
|
||||||
cmd: List<String>,
|
|
||||||
requestId: String?,
|
|
||||||
) {
|
|
||||||
val ch = channel ?: return
|
val ch = channel ?: return
|
||||||
val obj =
|
val obj = buildJsonObject {
|
||||||
buildJsonObject {
|
|
||||||
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
|
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
|
||||||
requestId?.let { put("request_id", JsonPrimitive(it)) }
|
requestId?.let { put("request_id", JsonPrimitive(it)) }
|
||||||
}
|
}
|
||||||
@@ -556,24 +471,14 @@ private class MpvAudioSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun openDocument(
|
actual fun openDocument(path: String, mime: String) {
|
||||||
path: String,
|
|
||||||
mime: String,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
val os = System.getProperty("os.name").lowercase()
|
val os = System.getProperty("os.name").lowercase()
|
||||||
when {
|
when {
|
||||||
os.contains("mac") -> {
|
os.contains("mac") -> ProcessBuilder("open", path).start()
|
||||||
ProcessBuilder("open", path).start()
|
os.contains("win") ->
|
||||||
}
|
|
||||||
|
|
||||||
os.contains("win") -> {
|
|
||||||
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
|
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
|
||||||
}
|
else -> ProcessBuilder("xdg-open", path).start()
|
||||||
|
|
||||||
else -> {
|
|
||||||
ProcessBuilder("xdg-open", path).start()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ actual fun setActiveController(controller: Any?) {
|
|||||||
DesktopBridge.controller = controller as? IrisController
|
DesktopBridge.controller = controller as? IrisController
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun syncNtfyListener(backend: String) {
|
|
||||||
// Desktop has no ntfy listener service.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the
|
* M6: OS notifications (docs/11 §11.2). Desktop has no FCM; the tray + the
|
||||||
* platform notifier cover the "backgrounded" leg. Shells out to
|
* platform notifier cover the "backgrounded" leg. Shells out to
|
||||||
@@ -30,57 +26,34 @@ actual fun syncNtfyListener(backend: String) {
|
|||||||
* Best effort — failures are ignored (the in-app banner is the primary path).
|
* Best effort — failures are ignored (the in-app banner is the primary path).
|
||||||
*/
|
*/
|
||||||
object DesktopNotifier {
|
object DesktopNotifier {
|
||||||
fun post(
|
fun post(title: String, body: String) {
|
||||||
title: String,
|
|
||||||
body: String,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
val os = System.getProperty("os.name").lowercase()
|
val os = System.getProperty("os.name").lowercase()
|
||||||
val cmd =
|
val cmd = when {
|
||||||
when {
|
os.contains("linux") ->
|
||||||
os.contains("linux") -> {
|
|
||||||
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
|
listOf("notify-send", "-a", "Iris", "-c", "iris", title, body)
|
||||||
}
|
os.contains("mac") ->
|
||||||
|
|
||||||
os.contains("mac") -> {
|
|
||||||
listOf(
|
listOf(
|
||||||
"osascript",
|
"osascript", "-e",
|
||||||
"-e",
|
|
||||||
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
|
"display notification \"${esc(body)}\" with title \"${esc(title)}\"",
|
||||||
)
|
)
|
||||||
}
|
else ->
|
||||||
|
|
||||||
else -> {
|
|
||||||
listOf(
|
listOf(
|
||||||
"powershell",
|
"powershell", "-NoProfile", "-Command",
|
||||||
"-NoProfile",
|
|
||||||
"-Command",
|
|
||||||
"Add-Type -AssemblyName System.Windows.Forms; " +
|
"Add-Type -AssemblyName System.Windows.Forms; " +
|
||||||
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
|
"\$n = New-Object System.Windows.Forms.NotifyIcon; " +
|
||||||
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
|
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
|
||||||
"\$n.Visible = \$true; " +
|
"\$n.Visible = \$true; " +
|
||||||
"\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
|
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " +
|
||||||
"[System.Windows.Forms.ToolTipIcon]::Info); " +
|
"[System.Windows.Forms.ToolTipIcon]::Info); " +
|
||||||
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
|
"Start-Sleep -Milliseconds 4500; \$n.Dispose()",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ProcessBuilder(cmd).redirectErrorStream(true).start()
|
ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
private fun esc(s: String): String =
|
||||||
|
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
|
||||||
// M-16: PowerShell uses backtick escaping (not backslash) and treats `$`
|
|
||||||
// as a variable reference, so a body containing `$foo` would be mangled or
|
|
||||||
// error. Escape backtick first (so the backticks we add aren't doubled),
|
|
||||||
// then `$` and `"`. Newlines collapse to spaces (balloon tips are single
|
|
||||||
// line).
|
|
||||||
private fun psEsc(s: String): String =
|
|
||||||
s
|
|
||||||
.replace("`", "``")
|
|
||||||
.replace("$", "`$")
|
|
||||||
.replace("\"", "`\"")
|
|
||||||
.replace("\n", " ")
|
|
||||||
}
|
}
|
||||||
@@ -2,17 +2,14 @@ package iris.platform
|
|||||||
|
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.protocol.IrisJson
|
import iris.protocol.IrisJson
|
||||||
import iris.ui.theme.Backdrop
|
|
||||||
import iris.ui.theme.BackgroundMode
|
|
||||||
import iris.ui.theme.UserTheme
|
import iris.ui.theme.UserTheme
|
||||||
import iris.util.STREAM_SMOOTHNESS_DEFAULT
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.spec.GCMParameterSpec
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M6: desktop pairing storage (docs/11 §11.2).
|
* M6: desktop pairing storage (docs/11 §11.2).
|
||||||
@@ -28,30 +25,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
private val baseDir = File(System.getProperty("user.home"), ".iris")
|
private val baseDir = File(System.getProperty("user.home"), ".iris")
|
||||||
private val settingsFile = File(baseDir, "settings.json")
|
private val settingsFile = File(baseDir, "settings.json")
|
||||||
private val legacyFile = File(baseDir, "pairing.json")
|
private val legacyFile = File(baseDir, "pairing.json")
|
||||||
private val secret =
|
private val secret = SecretBackend(baseDir)
|
||||||
SecretBackend(
|
|
||||||
baseDir,
|
|
||||||
keyringService = "iris-gateway-token",
|
|
||||||
keyringLabel = "Iris gateway token",
|
|
||||||
keyringAttr = "iris",
|
|
||||||
encFileName = "pairing.enc",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Per-device token (docs/09 §9.3): a second secret slot, same backends.
|
|
||||||
private val deviceSecret =
|
|
||||||
SecretBackend(
|
|
||||||
baseDir,
|
|
||||||
keyringService = "iris-device-token",
|
|
||||||
keyringLabel = "Iris device token",
|
|
||||||
keyringAttr = "iris-device",
|
|
||||||
encFileName = "device_token.enc",
|
|
||||||
)
|
|
||||||
|
|
||||||
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
|
|
||||||
// connect attempt) don't re-read + re-parse the file on every access.
|
|
||||||
// Invalidated on every save(). DesktopSecureStore is a per-process
|
|
||||||
// singleton (created once in Main.kt), so a per-instance cache is safe.
|
|
||||||
private var cached: Settings? = null
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class Settings(
|
private data class Settings(
|
||||||
@@ -61,21 +35,15 @@ class DesktopSecureStore : SecureStore {
|
|||||||
val fcmToken: String = "",
|
val fcmToken: String = "",
|
||||||
val ntfyTopic: String = "",
|
val ntfyTopic: String = "",
|
||||||
val ntfyServer: String = "",
|
val ntfyServer: String = "",
|
||||||
val pushBackend: String = "",
|
|
||||||
val threadsEnabled: Boolean = false,
|
val threadsEnabled: Boolean = false,
|
||||||
val toolDetail: String = "truncated",
|
val toolDetail: String = "truncated",
|
||||||
val streamingEnabled: Boolean = true,
|
val streamingEnabled: Boolean = true,
|
||||||
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
|
|
||||||
val reasoningAutoCollapse: Boolean = true,
|
val reasoningAutoCollapse: Boolean = true,
|
||||||
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
|
||||||
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
|
||||||
val backgroundMode: String = BackgroundMode.Image.name.lowercase(),
|
val backgroundMode: String = "color",
|
||||||
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
|
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
|
||||||
val backgroundImagePath: String = Backdrop.DEFAULT.path,
|
val backgroundImagePath: String = "",
|
||||||
val fontSizeScale: Float = 1.0f,
|
|
||||||
val runtimeFooterEnabled: Boolean = false,
|
|
||||||
val runtimeFooterFields: String = "",
|
|
||||||
val pinnedCertFingerprint: String = "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -84,8 +52,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
|
|
||||||
private fun migrateLegacy() {
|
private fun migrateLegacy() {
|
||||||
if (!legacyFile.exists()) return
|
if (!legacyFile.exists()) return
|
||||||
val legacy =
|
val legacy = try {
|
||||||
try {
|
|
||||||
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
|
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
@@ -103,15 +70,11 @@ class DesktopSecureStore : SecureStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (legacy.token.isNotBlank()) secret.write(legacy.token)
|
if (legacy.token.isNotBlank()) secret.write(legacy.token)
|
||||||
// L-49: only delete the legacy file after a successful migration;
|
}
|
||||||
// a parse failure (legacy == null) must not destroy the data.
|
|
||||||
legacyFile.delete()
|
legacyFile.delete()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun load(): Settings {
|
private fun load(): Settings =
|
||||||
cached?.let { return it }
|
|
||||||
val s =
|
|
||||||
if (settingsFile.exists()) {
|
if (settingsFile.exists()) {
|
||||||
try {
|
try {
|
||||||
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
||||||
@@ -121,14 +84,10 @@ class DesktopSecureStore : SecureStore {
|
|||||||
} else {
|
} else {
|
||||||
Settings()
|
Settings()
|
||||||
}
|
}
|
||||||
cached = s
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun save(data: Settings) {
|
private fun save(data: Settings) {
|
||||||
baseDir.mkdirs()
|
baseDir.mkdirs()
|
||||||
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
|
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
|
||||||
cached = data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override var serverUrl: String
|
override var serverUrl: String
|
||||||
@@ -144,23 +103,11 @@ class DesktopSecureStore : SecureStore {
|
|||||||
if (value.isBlank()) secret.clear() else secret.write(value.trim())
|
if (value.isBlank()) secret.clear() else secret.write(value.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
override var deviceToken: String
|
|
||||||
get() = deviceSecret.read().orEmpty()
|
|
||||||
set(value) {
|
|
||||||
if (value.isBlank()) deviceSecret.clear() else deviceSecret.write(value.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
override val deviceId: String
|
override val deviceId: String
|
||||||
get() {
|
get() {
|
||||||
val d = load()
|
val d = load()
|
||||||
if (d.deviceId.isNotBlank()) return d.deviceId
|
if (d.deviceId.isNotBlank()) return d.deviceId
|
||||||
val id =
|
val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
|
||||||
"dev_" +
|
|
||||||
UUID
|
|
||||||
.randomUUID()
|
|
||||||
.toString()
|
|
||||||
.replace("-", "")
|
|
||||||
.take(16)
|
|
||||||
save(d.copy(deviceId = id))
|
save(d.copy(deviceId = id))
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -196,13 +143,6 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(ntfyServer = value))
|
save(d.copy(ntfyServer = value))
|
||||||
}
|
}
|
||||||
|
|
||||||
override var pushBackend: String
|
|
||||||
get() = load().pushBackend
|
|
||||||
set(value) {
|
|
||||||
val d = load()
|
|
||||||
save(d.copy(pushBackend = value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override var threadsEnabled: Boolean
|
override var threadsEnabled: Boolean
|
||||||
get() = load().threadsEnabled
|
get() = load().threadsEnabled
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -224,13 +164,6 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(streamingEnabled = value))
|
save(d.copy(streamingEnabled = value))
|
||||||
}
|
}
|
||||||
|
|
||||||
override var streamSmoothness: Float
|
|
||||||
get() = load().streamSmoothness
|
|
||||||
set(value) {
|
|
||||||
val d = load()
|
|
||||||
save(d.copy(streamSmoothness = value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override var reasoningAutoCollapse: Boolean
|
override var reasoningAutoCollapse: Boolean
|
||||||
get() = load().reasoningAutoCollapse
|
get() = load().reasoningAutoCollapse
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -253,7 +186,7 @@ class DesktopSecureStore : SecureStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override var backgroundMode: String
|
override var backgroundMode: String
|
||||||
get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
|
get() = load().backgroundMode.ifBlank { "color" }
|
||||||
set(value) {
|
set(value) {
|
||||||
val d = load()
|
val d = load()
|
||||||
save(d.copy(backgroundMode = value))
|
save(d.copy(backgroundMode = value))
|
||||||
@@ -273,66 +206,16 @@ class DesktopSecureStore : SecureStore {
|
|||||||
save(d.copy(backgroundImagePath = value))
|
save(d.copy(backgroundImagePath = value))
|
||||||
}
|
}
|
||||||
|
|
||||||
override var fontSizeScale: Float
|
override fun savePairing(url: String, token: String) {
|
||||||
get() = load().fontSizeScale
|
|
||||||
set(value) {
|
|
||||||
val d = load()
|
|
||||||
save(d.copy(fontSizeScale = value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override var runtimeFooterEnabled: Boolean
|
|
||||||
get() = load().runtimeFooterEnabled
|
|
||||||
set(value) {
|
|
||||||
val d = load()
|
|
||||||
save(d.copy(runtimeFooterEnabled = value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override var runtimeFooterFields: String
|
|
||||||
get() = load().runtimeFooterFields
|
|
||||||
set(value) {
|
|
||||||
val d = load()
|
|
||||||
save(d.copy(runtimeFooterFields = value))
|
|
||||||
}
|
|
||||||
|
|
||||||
override var pinnedCertFingerprint: String
|
|
||||||
get() = load().pinnedCertFingerprint
|
|
||||||
set(value) {
|
|
||||||
val d = load()
|
|
||||||
save(d.copy(pinnedCertFingerprint = value.trim()))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun savePairing(
|
|
||||||
url: String,
|
|
||||||
token: String,
|
|
||||||
) {
|
|
||||||
val d = load()
|
val d = load()
|
||||||
save(d.copy(serverUrl = url.trim()))
|
save(d.copy(serverUrl = url.trim()))
|
||||||
if (token.isBlank()) secret.clear() else secret.write(token.trim())
|
if (token.isBlank()) secret.clear() else secret.write(token.trim())
|
||||||
// A (re-)pair may target a different gateway: the old per-device
|
|
||||||
// token is dead there. The next hello.ack re-mints/returns it.
|
|
||||||
deviceSecret.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clear() {
|
override fun clear() {
|
||||||
// M-10: clearing pairing must also wipe the device identity + push
|
|
||||||
// state, otherwise a re-pair to a different gateway would keep the old
|
|
||||||
// deviceId/syncCursor/ntfyTopic and the server would treat the new
|
|
||||||
// pairing as the same device.
|
|
||||||
val d = load()
|
val d = load()
|
||||||
save(
|
save(d.copy(serverUrl = ""))
|
||||||
d.copy(
|
|
||||||
serverUrl = "",
|
|
||||||
deviceId = "",
|
|
||||||
syncCursor = 0L,
|
|
||||||
fcmToken = "",
|
|
||||||
ntfyTopic = "",
|
|
||||||
ntfyServer = "",
|
|
||||||
pushBackend = "",
|
|
||||||
pinnedCertFingerprint = "",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
secret.clear()
|
secret.clear()
|
||||||
deviceSecret.clear()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,33 +234,18 @@ private data class PairingData(
|
|||||||
/**
|
/**
|
||||||
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
|
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
|
||||||
* All backend failures degrade to the encrypted file (never plaintext).
|
* All backend failures degrade to the encrypted file (never plaintext).
|
||||||
*
|
|
||||||
* Parameterized so the shared gateway token and the per-device token
|
|
||||||
* (docs/09 §9.3) each get their own keyring entry / encrypted file.
|
|
||||||
*/
|
*/
|
||||||
private class SecretBackend(
|
private class SecretBackend(private val baseDir: File) {
|
||||||
private val baseDir: File,
|
private val encFile = File(baseDir, "pairing.enc")
|
||||||
private val keyringService: String,
|
|
||||||
private val keyringLabel: String,
|
|
||||||
private val keyringAttr: String,
|
|
||||||
encFileName: String,
|
|
||||||
) {
|
|
||||||
private val encFile = File(baseDir, encFileName)
|
|
||||||
private val keyFile = File(baseDir, ".key")
|
private val keyFile = File(baseDir, ".key")
|
||||||
private val keyring: KeyringBackend? =
|
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
|
||||||
KeyringBackend(keyringService, keyringLabel, keyringAttr).takeIf { it.available }
|
|
||||||
|
|
||||||
fun read(): String? = keyring?.read() ?: readEncrypted()
|
fun read(): String? = keyring?.read() ?: readEncrypted()
|
||||||
|
|
||||||
fun write(value: String) {
|
fun write(value: String) {
|
||||||
if (keyring != null) {
|
if (keyring != null) {
|
||||||
keyring.write(value)
|
keyring.write(value)
|
||||||
if (keyring.read() == value) {
|
if (keyring.read() == value) return
|
||||||
// L-50: the keyring now holds the secret; drop the stale
|
|
||||||
// encrypted file so the old token can't be read back.
|
|
||||||
encFile.delete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Keyring accepted the write but did not persist it (e.g. KWallet
|
// Keyring accepted the write but did not persist it (e.g. KWallet
|
||||||
// without a live daemon). Drop the stale entry and fall back to
|
// without a live daemon). Drop the stale entry and fall back to
|
||||||
// the encrypted file so the token survives a restart.
|
// the encrypted file so the token survives a restart.
|
||||||
@@ -408,12 +276,8 @@ private class SecretBackend(
|
|||||||
|
|
||||||
private fun writeEncrypted(value: String) {
|
private fun writeEncrypted(value: String) {
|
||||||
try {
|
try {
|
||||||
// GCM with a fresh 12-byte SecureRandom IV per write (stored with
|
|
||||||
// the ciphertext); the IV is never reused for a given key.
|
|
||||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
||||||
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
val ct = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||||
baseDir.mkdirs()
|
baseDir.mkdirs()
|
||||||
@@ -429,9 +293,7 @@ private class SecretBackend(
|
|||||||
if (bytes.size < 28) return null
|
if (bytes.size < 28) return null
|
||||||
val iv = bytes.copyOfRange(0, 12)
|
val iv = bytes.copyOfRange(0, 12)
|
||||||
val ct = bytes.copyOfRange(12, bytes.size)
|
val ct = bytes.copyOfRange(12, bytes.size)
|
||||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
|
||||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||||
// pi-lens-ignore: opengrep:kotlin.lang.security.gcm-detection.gcm-detection
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(loadKey(), "AES"), GCMParameterSpec(128, iv))
|
||||||
String(cipher.doFinal(ct), Charsets.UTF_8)
|
String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -441,32 +303,23 @@ private class SecretBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** OS keyring via the platform CLI (best effort). */
|
/** OS keyring via the platform CLI (best effort). */
|
||||||
private class KeyringBackend(
|
private class KeyringBackend {
|
||||||
private val service: String,
|
|
||||||
private val label: String,
|
|
||||||
private val attr: String,
|
|
||||||
) {
|
|
||||||
private val os = System.getProperty("os.name").lowercase()
|
private val os = System.getProperty("os.name").lowercase()
|
||||||
private val isMac = os.contains("mac")
|
private val isMac = os.contains("mac")
|
||||||
private val isLinux = os.contains("linux")
|
private val isLinux = os.contains("linux")
|
||||||
|
|
||||||
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
|
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
|
||||||
val available: Boolean by lazy {
|
val available: Boolean by lazy {
|
||||||
if (isMac) {
|
if (isMac) true
|
||||||
true
|
else if (isLinux) which("secret-tool") != null
|
||||||
} else if (isLinux) {
|
else false
|
||||||
which("secret-tool") != null
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun read(): String? =
|
fun read(): String? = try {
|
||||||
try {
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", service, "-w"))
|
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
||||||
} else {
|
} else {
|
||||||
out(listOf("secret-tool", "lookup", "app", attr))
|
out(listOf("secret-tool", "lookup", "app", "iris"))
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
@@ -474,28 +327,16 @@ private class KeyringBackend(
|
|||||||
|
|
||||||
fun write(value: String) {
|
fun write(value: String) {
|
||||||
try {
|
try {
|
||||||
// M-9: pass the secret on stdin, not as a CLI argument. A trailing
|
|
||||||
// argument is visible in the process list (`ps`); `secret-tool
|
|
||||||
// store` and `security add-generic-password -w` both read the
|
|
||||||
// secret from stdin when no value argument is given.
|
|
||||||
val cmd =
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
listOf(
|
ProcessBuilder(
|
||||||
"security",
|
"security", "add-generic-password", "-U",
|
||||||
"add-generic-password",
|
"-a", "iris", "-s", "iris-gateway-token", "-w", value,
|
||||||
"-U",
|
).inheritIO().start().waitFor()
|
||||||
"-a",
|
|
||||||
"iris",
|
|
||||||
"-s",
|
|
||||||
service,
|
|
||||||
"-w",
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
listOf("secret-tool", "store", "--label=$label", "app", attr)
|
ProcessBuilder(
|
||||||
}
|
"secret-tool", "store", "--label=Iris gateway token",
|
||||||
ProcessBuilder(cmd).start().apply {
|
"app", "iris", "token", value,
|
||||||
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
|
).inheritIO().start().waitFor()
|
||||||
waitFor()
|
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
@@ -505,19 +346,12 @@ private class KeyringBackend(
|
|||||||
try {
|
try {
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
ProcessBuilder(
|
ProcessBuilder(
|
||||||
"security",
|
"security", "delete-generic-password",
|
||||||
"delete-generic-password",
|
"-a", "iris", "-s", "iris-gateway-token",
|
||||||
"-a",
|
|
||||||
"iris",
|
|
||||||
"-s",
|
|
||||||
service,
|
|
||||||
).inheritIO().start().waitFor()
|
).inheritIO().start().waitFor()
|
||||||
} else {
|
} else {
|
||||||
ProcessBuilder(
|
ProcessBuilder(
|
||||||
"secret-tool",
|
"secret-tool", "clear", "app", "iris",
|
||||||
"clear",
|
|
||||||
"app",
|
|
||||||
attr,
|
|
||||||
).inheritIO().start().waitFor()
|
).inheritIO().start().waitFor()
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -526,23 +360,14 @@ private class KeyringBackend(
|
|||||||
|
|
||||||
private fun out(cmd: List<String>): String? {
|
private fun out(cmd: List<String>): String? {
|
||||||
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
|
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
|
||||||
val text =
|
val text = p.inputStream.bufferedReader().readText().trim()
|
||||||
p.inputStream
|
|
||||||
.bufferedReader()
|
|
||||||
.readText()
|
|
||||||
.trim()
|
|
||||||
p.waitFor()
|
p.waitFor()
|
||||||
return text.ifBlank { null }
|
return text.ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun which(bin: String): String? =
|
private fun which(bin: String): String? = try {
|
||||||
try {
|
|
||||||
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
|
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
|
||||||
val text =
|
val text = p.inputStream.bufferedReader().readText().trim()
|
||||||
p.inputStream
|
|
||||||
.bufferedReader()
|
|
||||||
.readText()
|
|
||||||
.trim()
|
|
||||||
p.waitFor()
|
p.waitFor()
|
||||||
text.ifBlank { null }
|
text.ifBlank { null }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package iris.platform
|
|
||||||
|
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
|
||||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
|
||||||
import iris.db.IrisDatabase
|
|
||||||
import java.io.File
|
|
||||||
import java.sql.DriverManager
|
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
actual fun appDataDir(): String = File(System.getProperty("user.home"), ".iris").apply { mkdirs() }.absolutePath
|
|
||||||
|
|
||||||
actual fun createCacheDriver(): SqlDriver = createCacheDriver(File(appDataDir(), "iris_cache.db"))
|
|
||||||
|
|
||||||
/** Schema-aware driver for the cache DB at [file]: creates the schema on
|
|
||||||
* first run, applies pending .sqm migrations on existing DBs (e.g. v1 ->
|
|
||||||
* v2: the `tool` table), and persists the schema version (PRAGMA
|
|
||||||
* user_version). */
|
|
||||||
internal fun createCacheDriver(file: File): SqlDriver {
|
|
||||||
stampLegacyV1(file)
|
|
||||||
return JdbcSqliteDriver("jdbc:sqlite:${file.absolutePath}", Properties(), IrisDatabase.Schema)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One-time shim for pre-existing desktop cache DBs: the old code called
|
|
||||||
* `Schema.create()` directly, which never stamped PRAGMA user_version — so
|
|
||||||
* those files hold the v1 schema at user_version 0, which the schema-aware
|
|
||||||
* driver would treat as a fresh DB and crash on (`CREATE TABLE message` on
|
|
||||||
* an existing table). Stamp them as v1 so the 1 -> 2 migration runs. */
|
|
||||||
internal fun stampLegacyV1(file: File) {
|
|
||||||
if (!file.exists()) return
|
|
||||||
DriverManager.getConnection("jdbc:sqlite:${file.absolutePath}").use { conn ->
|
|
||||||
val userVersion =
|
|
||||||
conn.createStatement().use { st ->
|
|
||||||
st.executeQuery("PRAGMA user_version").use { rs -> if (rs.next()) rs.getInt(1) else 0 }
|
|
||||||
}
|
|
||||||
if (userVersion != 0) return
|
|
||||||
val hasMessageTable =
|
|
||||||
conn.createStatement().use { st ->
|
|
||||||
st
|
|
||||||
.executeQuery("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'message'")
|
|
||||||
.use { rs -> rs.next() }
|
|
||||||
}
|
|
||||||
if (hasMessageTable) conn.createStatement().use { st -> st.execute("PRAGMA user_version = 1") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,11 +38,11 @@ private suspend fun ensureKcefInitialized() {
|
|||||||
KCEF.init(
|
KCEF.init(
|
||||||
builder = {
|
builder = {
|
||||||
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
|
installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle"))
|
||||||
// Pinned to the JBR 17 release the KCEF 2025.03.23 line is
|
// Pinned to the JBR 17 release the KCEF 2024.04.20.x line was
|
||||||
// built against; the default "latest" JBR may be JDK 21-based
|
// built against; the default "latest" JBR may be JDK 21-based
|
||||||
// and is not guaranteed compatible.
|
// and is not guaranteed compatible.
|
||||||
download {
|
download {
|
||||||
github { release("jbr-release-17.0.14b1367.22") }
|
github { release("jbr-release-17.0.12b1207.37") }
|
||||||
}
|
}
|
||||||
settings {
|
settings {
|
||||||
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
|
cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package iris.platform
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Desktop has no camera, so there is nothing to scan. The Connect screen hides
|
|
||||||
* this button on desktop (`isDesktop`), so this is never rendered.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
actual fun QrScanButton(onResult: (String?) -> Unit) {
|
|
||||||
// No-op on desktop.
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 758 KiB |
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 610 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
@@ -1,97 +0,0 @@
|
|||||||
package iris.data
|
|
||||||
|
|
||||||
import iris.platform.createCacheDriver
|
|
||||||
import java.io.File
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.sql.DriverManager
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
/** Migration path for pre-existing desktop cache DBs: the schema-aware
|
|
||||||
* driver plus the legacy user_version-0 shim (DesktopStorage). */
|
|
||||||
class CacheMigrationTest {
|
|
||||||
private val v1Schema =
|
|
||||||
"""
|
|
||||||
CREATE TABLE message (
|
|
||||||
lane TEXT NOT NULL,
|
|
||||||
id TEXT NOT NULL,
|
|
||||||
ts INTEGER NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (lane, id)
|
|
||||||
);
|
|
||||||
CREATE TABLE channel (
|
|
||||||
chat_id TEXT NOT NULL,
|
|
||||||
payload TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (chat_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE meta (
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (key)
|
|
||||||
);
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
private fun tempDb(name: String): File = Files.createTempDirectory("iris_cache_test").toFile().let { File(it, name) }
|
|
||||||
|
|
||||||
private fun File.queryInt(sql: String): Int =
|
|
||||||
DriverManager.getConnection("jdbc:sqlite:$absolutePath").use { conn ->
|
|
||||||
conn.createStatement().use { st ->
|
|
||||||
st.executeQuery(sql).use { rs -> if (rs.next()) rs.getInt(1) else -1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun File.writeV1(userVersion: Int) {
|
|
||||||
DriverManager.getConnection("jdbc:sqlite:$absolutePath").use { conn ->
|
|
||||||
conn.createStatement().use { st ->
|
|
||||||
v1Schema
|
|
||||||
.split(";")
|
|
||||||
.map { it.trim() }
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.forEach { st.execute(it) }
|
|
||||||
st.execute("INSERT INTO message (lane, id, ts, payload) VALUES ('l', 'm1', 1, '{}')")
|
|
||||||
// (row simulates a legacy DB with data; must survive migration)
|
|
||||||
if (userVersion > 0) st.execute("PRAGMA user_version = $userVersion")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun legacyV1DbWithZeroUserVersionMigrates() {
|
|
||||||
val file = tempDb("legacy0.db")
|
|
||||||
try {
|
|
||||||
// The old desktop code called Schema.create() directly, which
|
|
||||||
// never stamped user_version: v1 tables at user_version 0.
|
|
||||||
file.writeV1(userVersion = 0)
|
|
||||||
// Must migrate (1 -> 2 via the shim), not crash on
|
|
||||||
// `CREATE TABLE message` against the existing table.
|
|
||||||
createCacheDriver(file).use { }
|
|
||||||
assertEquals(1, file.queryInt("SELECT COUNT(*) FROM message"))
|
|
||||||
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
|
|
||||||
} finally {
|
|
||||||
file.parentFile?.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun legacyV1DbWithStampedVersionMigrates() {
|
|
||||||
val file = tempDb("legacy1.db")
|
|
||||||
try {
|
|
||||||
file.writeV1(userVersion = 1)
|
|
||||||
createCacheDriver(file).use { }
|
|
||||||
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
|
|
||||||
} finally {
|
|
||||||
file.parentFile?.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun freshDbCreatesSchema() {
|
|
||||||
val file = tempDb("fresh.db")
|
|
||||||
try {
|
|
||||||
createCacheDriver(file).use { }
|
|
||||||
assertEquals(1, file.queryInt("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tool'"))
|
|
||||||
} finally {
|
|
||||||
file.parentFile?.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package iris.media
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
actual class FileSource actual constructor(path: String) : AutoCloseable {
|
||||||
|
private val file = File(path)
|
||||||
|
private val input = file.inputStream()
|
||||||
|
|
||||||
|
actual fun size(): Long = file.length()
|
||||||
|
|
||||||
|
actual fun read(buf: ByteArray): Int = input.read(buf)
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
input.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,44 +5,22 @@ import java.io.FileOutputStream
|
|||||||
|
|
||||||
actual interface MediaWriter : AutoCloseable {
|
actual interface MediaWriter : AutoCloseable {
|
||||||
actual val path: String
|
actual val path: String
|
||||||
|
|
||||||
actual fun write(bytes: ByteArray)
|
actual fun write(bytes: ByteArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual class MediaCache actual constructor(
|
actual class MediaCache actual constructor(baseDir: String) {
|
||||||
baseDir: String,
|
|
||||||
) {
|
|
||||||
private val maxBytes: Long = 500L * 1024 * 1024
|
private val maxBytes: Long = 500L * 1024 * 1024
|
||||||
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
|
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
|
||||||
|
|
||||||
/**
|
actual fun path(mediaId: String, mime: String): String? {
|
||||||
* Resolve the cache file for [mediaId]. The id is server-controlled
|
val f = File(mediaDir, "$mediaId${extForMime(mime)}")
|
||||||
* (`media.offer`); reject path-traversal values so a hostile gateway can't
|
|
||||||
* write outside [mediaDir] (M-2 / S-1). Returns null when the id is
|
|
||||||
* invalid.
|
|
||||||
*/
|
|
||||||
private fun fileFor(
|
|
||||||
mediaId: String,
|
|
||||||
mime: String,
|
|
||||||
): File? = if (!isValidMediaId(mediaId)) null else File(mediaDir, "$mediaId${extForMime(mime)}")
|
|
||||||
|
|
||||||
actual fun path(
|
|
||||||
mediaId: String,
|
|
||||||
mime: String,
|
|
||||||
): String? {
|
|
||||||
val f = fileFor(mediaId, mime) ?: return null
|
|
||||||
return if (f.exists()) f.absolutePath else null
|
return if (f.exists()) f.absolutePath else null
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun openWriter(
|
actual fun openWriter(mediaId: String, mime: String): MediaWriter =
|
||||||
mediaId: String,
|
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this)
|
||||||
mime: String,
|
|
||||||
): MediaWriter = JvmMediaWriter(fileFor(mediaId, mime) ?: throw IllegalArgumentException("invalid media id"), this)
|
|
||||||
|
|
||||||
actual fun remove(
|
actual fun remove(mediaId: String, mime: String) {
|
||||||
mediaId: String,
|
|
||||||
mime: String,
|
|
||||||
) {
|
|
||||||
path(mediaId, mime)?.let { File(it).delete() }
|
path(mediaId, mime)?.let { File(it).delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +36,7 @@ actual class MediaCache actual constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class JvmMediaWriter(
|
private class JvmMediaWriter(private val target: File, private val cache: MediaCache) : MediaWriter {
|
||||||
private val target: File,
|
|
||||||
private val cache: MediaCache,
|
|
||||||
) : MediaWriter {
|
|
||||||
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
|
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
|
||||||
|
|
||||||
override val path: String get() = target.absolutePath
|
override val path: String get() = target.absolutePath
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
package iris.data
|
|
||||||
|
|
||||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
|
||||||
import iris.db.IrisDatabase
|
|
||||||
import iris.protocol.ChannelInfo
|
|
||||||
import iris.protocol.RuntimeMeta
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
/** Cache DB round-trip tests (JDBC in-memory SQLite; shared by both host targets). */
|
|
||||||
class ChatDbTest {
|
|
||||||
private fun newDb(): ChatDb {
|
|
||||||
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
|
||||||
IrisDatabase.Schema.create(driver)
|
|
||||||
return ChatDb(driver)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun msg(
|
|
||||||
id: String,
|
|
||||||
role: String = "user",
|
|
||||||
text: String = "hello",
|
|
||||||
ts: Long = 1000,
|
|
||||||
pending: Boolean = false,
|
|
||||||
status: MsgStatus = MsgStatus.Sent,
|
|
||||||
streaming: Boolean = false,
|
|
||||||
isSystem: Boolean = false,
|
|
||||||
) = MessageItem(
|
|
||||||
id = id,
|
|
||||||
role = role,
|
|
||||||
text = text,
|
|
||||||
ts = ts,
|
|
||||||
pending = pending,
|
|
||||||
status = status,
|
|
||||||
streaming = streaming,
|
|
||||||
isSystem = isSystem,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun saveAndLoadLanesRoundTrip() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveLanes(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
msg("m1", ts = 100),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "m1"),
|
|
||||||
msg("m2", role = "assistant", text = "hi", ts = 200),
|
|
||||||
),
|
|
||||||
"default::thr_1" to listOf<ChatItem>(msg("m3", ts = 300)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val loaded = db.loadLanes()
|
|
||||||
assertEquals(setOf("default", "default::thr_1"), loaded.keys)
|
|
||||||
// Tool cards are persisted and restored at their anchored position
|
|
||||||
// (after the message they follow, before the answer).
|
|
||||||
assertEquals(listOf("m1", "tool_1", "m2"), loaded["default"]!!.map { it.id })
|
|
||||||
assertEquals(listOf("m3"), loaded["default::thr_1"]!!.map { it.id })
|
|
||||||
// Messages ordered by ts.
|
|
||||||
assertEquals(100L, (loaded["default"]!![0] as MessageItem).ts)
|
|
||||||
assertEquals(200L, (loaded["default"]!![2] as MessageItem).ts)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toolCardsShareAnchorKeepOrder() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveLanes(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
msg("m1", ts = 100),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "search_files", anchorId = "m1"),
|
|
||||||
ToolItem(id = "tool_2", index = 1, name = "terminal", anchorId = "m1"),
|
|
||||||
msg("m2", role = "assistant", ts = 200),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(listOf("m1", "tool_1", "tool_2", "m2"), db.loadLanes()["default"]!!.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toolCardWithMissingAnchorFallsToEnd() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveLanes(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
msg("m1", ts = 100),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", anchorId = "deleted"),
|
|
||||||
msg("m2", role = "assistant", ts = 200),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(listOf("m1", "m2", "tool_1"), db.loadLanes()["default"]!!.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun restoreClosesOpenToolCard() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveLanes(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf<ChatItem>(
|
|
||||||
msg("m1", ts = 100),
|
|
||||||
ToolItem(id = "tool_1", index = 0, name = "bash", done = false, anchorId = "m1"),
|
|
||||||
ToolItem(id = "tool_2", index = 1, name = "ls", done = true, ok = true, anchorId = "m1"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val lane = db.loadLanes()["default"]!!
|
|
||||||
// The process died before tool.end — the open card is closed as
|
|
||||||
// interrupted, not left spinning.
|
|
||||||
val open = lane.first { it.id == "tool_1" } as ToolItem
|
|
||||||
assertTrue(open.done)
|
|
||||||
assertFalse(open.ok)
|
|
||||||
// A completed card is untouched.
|
|
||||||
val done = lane.first { it.id == "tool_2" } as ToolItem
|
|
||||||
assertTrue(done.ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun saveLanesReplacesPreviousSnapshot() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveLanes(mapOf("default" to listOf(msg("m1"), msg("m2"))))
|
|
||||||
db.saveLanes(mapOf("default" to listOf(msg("m2"))))
|
|
||||||
assertEquals(listOf("m2"), db.loadLanes()["default"]!!.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun restoreSanitizesMidFlightState() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveLanes(
|
|
||||||
mapOf(
|
|
||||||
"default" to
|
|
||||||
listOf(
|
|
||||||
msg("p1", status = MsgStatus.Pending, pending = true, ts = 0),
|
|
||||||
msg("s1", role = "assistant", streaming = true, ts = 500),
|
|
||||||
msg("r1", status = MsgStatus.Read, ts = 600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val lane = db.loadLanes()["default"]!!
|
|
||||||
val msgItem = { id: String -> lane.first { it.id == id } as MessageItem }
|
|
||||||
// A pending send becomes failed (tap to retry); the gateway never
|
|
||||||
// acknowledged it before the process died.
|
|
||||||
assertEquals(MsgStatus.Failed, msgItem("p1").status)
|
|
||||||
assertEquals(false, msgItem("p1").pending)
|
|
||||||
// A streaming bubble is restored as finalized.
|
|
||||||
assertEquals(false, msgItem("s1").streaming)
|
|
||||||
// Read status is preserved.
|
|
||||||
assertEquals(MsgStatus.Read, msgItem("r1").status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun systemMessagesAreNotPersisted() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("sys_1", role = "system", isSystem = true))))
|
|
||||||
assertTrue(db.loadLanes().isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun mediaAndRuntimeSurviveRoundTrip() {
|
|
||||||
val db = newDb()
|
|
||||||
val item =
|
|
||||||
MessageItem(
|
|
||||||
id = "m1",
|
|
||||||
role = "assistant",
|
|
||||||
text = "look",
|
|
||||||
ts = 100,
|
|
||||||
runtime = RuntimeMeta(model = "gpt", latency = 1.5),
|
|
||||||
media =
|
|
||||||
listOf(
|
|
||||||
MediaItem(
|
|
||||||
mediaId = "med1",
|
|
||||||
kind = "image",
|
|
||||||
mime = "image/png",
|
|
||||||
size = 42,
|
|
||||||
filename = "a.png",
|
|
||||||
localPath = "/tmp/a.png",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(item)))
|
|
||||||
val loaded = db.loadLanes()["default"]!!.first() as MessageItem
|
|
||||||
assertEquals("gpt", loaded.runtime?.model)
|
|
||||||
assertEquals("/tmp/a.png", loaded.media.first().localPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun channelsRoundTrip() {
|
|
||||||
val db = newDb()
|
|
||||||
db.saveChannels(
|
|
||||||
listOf(
|
|
||||||
ChannelInfo(chatId = "default", name = "General", isDefault = true),
|
|
||||||
ChannelInfo(chatId = "chan_1", name = "Work"),
|
|
||||||
ChannelInfo(chatId = "thr_1", name = "Topic", kind = "thread", parentChatId = "chan_1"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val loaded = db.loadChannels()
|
|
||||||
assertEquals(3, loaded.size)
|
|
||||||
assertEquals("Work", loaded.first { it.chatId == "chan_1" }.name)
|
|
||||||
assertEquals("thread", loaded.first { it.chatId == "thr_1" }.kind)
|
|
||||||
assertEquals(true, loaded.first { it.chatId == "default" }.isDefault)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun metaRoundTripAndClearAll() {
|
|
||||||
val db = newDb()
|
|
||||||
assertNull(db.metaGet("last_lane"))
|
|
||||||
db.metaPut("last_lane", "chan_1")
|
|
||||||
assertEquals("chan_1", db.metaGet("last_lane"))
|
|
||||||
db.saveLanes(mapOf("default" to listOf<ChatItem>(msg("m1"))))
|
|
||||||
db.saveChannels(listOf(ChannelInfo(chatId = "default", name = "General")))
|
|
||||||
db.clearAll()
|
|
||||||
assertTrue(db.loadLanes().isEmpty())
|
|
||||||
assertEquals(emptyList<ChannelInfo>(), db.loadChannels())
|
|
||||||
assertNull(db.metaGet("last_lane"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package iris.util
|
|
||||||
|
|
||||||
import kotlin.test.Test
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNull
|
|
||||||
import kotlin.test.assertTrue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parser tests for `iris://pair` links (docs/20). The payload shape mirrors
|
|
||||||
* `pairing.qr_payload` on the gateway side.
|
|
||||||
*/
|
|
||||||
class PairLinkTest {
|
|
||||||
private val token = "ab".repeat(32) // 64 hex chars
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesValidLink() {
|
|
||||||
val link = PairLink.parse("iris://pair?host=192.168.1.50&port=8791&secure=0&token=$token")
|
|
||||||
assertEquals("http://192.168.1.50:8791", link?.url)
|
|
||||||
assertEquals(token, link?.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun defaultsPortTo8791() {
|
|
||||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&token=$token")
|
|
||||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun secureOneSelectsHttps() {
|
|
||||||
val link = PairLink.parse("iris://pair?host=10.0.0.5&port=8443&secure=1&token=$token")
|
|
||||||
assertEquals("https://10.0.0.5:8443", link?.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun schemeIsCaseInsensitive() {
|
|
||||||
val link = PairLink.parse("IRIS://pair?host=10.0.0.5&token=$token")
|
|
||||||
assertEquals("http://10.0.0.5:8791", link?.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun percentDecodesHost() {
|
|
||||||
// A hostname with a space would be %20 on the wire.
|
|
||||||
val link = PairLink.parse("iris://pair?host=my%20host&token=$token")
|
|
||||||
assertEquals("http://my host:8791", link?.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsMissingToken() {
|
|
||||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=8791"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsEmptyToken() {
|
|
||||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&token="))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsMissingHost() {
|
|
||||||
assertNull(PairLink.parse("iris://pair?port=8791&token=$token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsBadPort() {
|
|
||||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=0&token=$token"))
|
|
||||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=70000&token=$token"))
|
|
||||||
assertNull(PairLink.parse("iris://pair?host=10.0.0.5&port=abc&token=$token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsWrongScheme() {
|
|
||||||
assertNull(PairLink.parse("foo://pair?host=10.0.0.5&token=$token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsWrongHost() {
|
|
||||||
assertNull(PairLink.parse("iris://chat?host=10.0.0.5&token=$token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsNoAuthority() {
|
|
||||||
assertNull(PairLink.parse("pair?host=10.0.0.5&token=$token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun acceptsBoundaryPorts() {
|
|
||||||
assertTrue(PairLink.parse("iris://pair?host=h&port=1&token=$token") != null)
|
|
||||||
assertTrue(PairLink.parse("iris://pair?host=h&port=65535&token=$token") != null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||