release: promote dev to main for v2.0.0 (#1426)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Lx-204 <15377891490@163.com> Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
315 files changed
+65173
-7909
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: hy4ri
|
||||
ko_fi: m_5_7
|
||||
@@ -27,6 +27,7 @@ Fixes #
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] PR is focused on one feature or one fix (no unrelated changes)
|
||||
- [ ] Branch rebased onto `dev` (`git rebase origin/dev`)
|
||||
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first)
|
||||
- [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job)
|
||||
|
||||
@@ -97,11 +97,8 @@ jobs:
|
||||
- name: Add local bin to PATH
|
||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Run ktlintCheck
|
||||
run: ./gradlew ktlintCheck
|
||||
|
||||
- name: Run checkColorLiterals (hardcoded Color guard, issue #622)
|
||||
run: ./gradlew checkColorLiterals
|
||||
- name: Run ktlintCheck + checkColorLiterals (hardcoded Color guard, issue #622)
|
||||
run: ./gradlew ktlintCheck checkColorLiterals --continue
|
||||
|
||||
- name: Upload ktlint reports
|
||||
if: always()
|
||||
@@ -185,6 +182,25 @@ jobs:
|
||||
- name: Run test
|
||||
run: ./gradlew test
|
||||
|
||||
# Telemetry only, never a gate: surfaces the slowest classes so perf work
|
||||
# targets real offenders.
|
||||
- name: Slowest unit-test classes
|
||||
if: always()
|
||||
run: |
|
||||
python3 - <<'EOF' >> "$GITHUB_STEP_SUMMARY"
|
||||
import glob, xml.etree.ElementTree as ET
|
||||
rows = []
|
||||
for path in glob.glob("app/build/test-results/testDebugUnitTest/*.xml"):
|
||||
root = ET.parse(path).getroot()
|
||||
rows.append((float(root.get("time", 0)), int(root.get("tests", 0)), root.get("name", path)))
|
||||
rows.sort(reverse=True)
|
||||
print("### Slowest unit-test classes\n")
|
||||
print(f"{len(rows)} classes, {sum(r[0] for r in rows):.0f}s summed across forks\n")
|
||||
print("| Time | Tests | Class |\n|---:|---:|---|")
|
||||
for secs, count, name in rows[:20]:
|
||||
print(f"| {secs:.1f}s | {count} | `{name.rsplit('.', 1)[-1]}` |")
|
||||
EOF
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -194,11 +210,11 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Job 4: Build Debug APK — depends on all three above passing.
|
||||
# Job 4: Build Debug APK — runs in parallel; CI Summary gates the result.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build Debug APK
|
||||
needs: [changes, ktlint, android-lint, unit-tests]
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -241,7 +257,7 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
release-compile:
|
||||
name: Release Compile Check
|
||||
needs: [changes, ktlint, android-lint, unit-tests]
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -339,7 +355,7 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
ci-summary:
|
||||
name: CI Summary
|
||||
needs: [changes, ktlint, android-lint, unit-tests, build, release-compile]
|
||||
needs: [changes, ktlint, android-lint, unit-tests, build, release-compile, instrumented-tests]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
if: always()
|
||||
@@ -355,11 +371,12 @@ jobs:
|
||||
TEST_RESULT: ${{ needs.unit-tests.result }}
|
||||
BUILD_RESULT: ${{ needs.build.result }}
|
||||
RELEASE_COMPILE_RESULT: ${{ needs.release-compile.result }}
|
||||
INSTRUMENTED_RESULT: ${{ needs.instrumented-tests.result }}
|
||||
run: |
|
||||
LABEL="ci-red"
|
||||
FAILED=0
|
||||
FAILED_JOBS=""
|
||||
for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT"; do
|
||||
for entry in "ktlint:$KTLINT_RESULT" "android-lint:$LINT_RESULT" "unit-tests:$TEST_RESULT" "build:$BUILD_RESULT" "release-compile:$RELEASE_COMPILE_RESULT" "instrumented-tests:$INSTRUMENTED_RESULT"; do
|
||||
name="${entry%%:*}"
|
||||
result="${entry#*:}"
|
||||
echo "::notice::$name = $result"
|
||||
|
||||
@@ -54,10 +54,13 @@ jobs:
|
||||
# Strip leading 'v' for versionName
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
# versionCode: strip pre-release suffixes (e.g. 1.25.0-rc1 -> 1.25.0) and dots
|
||||
# versionCode: SemVer MAJOR * 10000 + MINOR * 100 + PATCH (e.g. 2.0.0 -> 20000, > 1.31.0's 1310)
|
||||
BASE_VERSION="${VERSION%%-*}"
|
||||
CODE=$(echo "$BASE_VERSION" | tr -d '.' | sed 's/^0*//')
|
||||
[ -z "$CODE" ] && CODE=0
|
||||
IFS='.' read -r MAJOR MINOR PATCH _ <<< "$BASE_VERSION"
|
||||
MAJOR="${MAJOR:-0}"
|
||||
MINOR="${MINOR:-0}"
|
||||
PATCH="${PATCH:-0}"
|
||||
CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "code=$CODE" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
PRERELEASE="true"
|
||||
|
||||
@@ -137,6 +137,21 @@ explicitly disconnected on logout and reconnected after login:
|
||||
The singleton's `connect()` has a guard (`if connected → skip`) so it's safe to
|
||||
call unconditionally.
|
||||
|
||||
### Typed Gateway RPC
|
||||
|
||||
Every gateway method in the contract (`app/src/test/resources/gateway-contract/openrpc.json`) is called through a
|
||||
typed descriptor: `HermesWsClient.call(RpcMethods.X, params)` (suspending), `requestTyped(...)`, or the typed
|
||||
`send(RpcMethods.X, params, onSent)`. Params are `@Serializable` classes under `data/ws/contract/`; results are
|
||||
`JsonElement` passthrough or typed. To add a method: add the params class, register it in `RpcMethods` (and
|
||||
`RpcMethods.all`), and `GatewayContractTest` checks it against the contract.
|
||||
|
||||
- The untyped `request(String, Map)` / `send(String, Map)` are `internal`. Use them only for `gateway.ping` and the
|
||||
legacy `*.respond` fallbacks (`clarify`, `sudo`, `secret`, `vault.*`) that have no contract entry.
|
||||
- Optional params are `T? = null`: the client Json has `encodeDefaults = false`, so unset fields are omitted and an
|
||||
explicit `false`/`""` is still sent. Do not add a `profile` field unless callers pass an explicit override;
|
||||
`WsProfileParams.decorate` injects it for profile-scoped methods.
|
||||
- `RawRpcParamsRatchetTest.BASELINE` must stay empty.
|
||||
|
||||
### Shared Components
|
||||
|
||||
- **`HermesScaffold`** — drawer-aware Scaffold + TopAppBar with refresh slot,
|
||||
@@ -197,8 +212,8 @@ for the current inset implementation.
|
||||
|
||||
### Theme
|
||||
|
||||
`Theme.kt` uses a preset-based theme system with 6 built-in presets: Default,
|
||||
Monochrome, Gruvbox, Catppuccin, AMOLED, and Nord. Each preset provides light
|
||||
`Theme.kt` uses a preset-based theme system with 7 built-in presets: Default,
|
||||
Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, and Garnet. Each preset provides light
|
||||
and dark color schemes plus matching semantic status colors (AMOLED is
|
||||
dark-only — light mode falls back to the default theme).
|
||||
Every preset is a `ThemePalette` template fill (`PaletteTemplate.kt`): raw
|
||||
@@ -259,7 +274,7 @@ gh pr create --base dev --title "fix(#N): description" --body "Closes #N"
|
||||
(`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local
|
||||
`val` at the composable scope first.
|
||||
- Don't add new screens without checking if an existing one already covers the
|
||||
functionality (28+ screens exist). Extend rather than duplicate.
|
||||
functionality (30+ screens exist). Extend rather than duplicate.
|
||||
- **⚠ Never scope a dependency to `debugImplementation` if its import is used in
|
||||
`main/` source code.** The CI `release-compile` job catches this, but save the
|
||||
cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's
|
||||
@@ -286,22 +301,25 @@ com.m57.hermescontrol/
|
||||
│ ├── local/ AuthManager, Room (ChatMessageEntity/Dao, HermesDatabase), AnalyticsCacheStore
|
||||
│ ├── model/ 40+ data classes for API responses + requests
|
||||
│ ├── remote/ ApiClient, Retrofit service, OkHttp provider, cookie management
|
||||
│ ├── repository/ KanbanRepository, KanbanSyncCoordinator, VoiceNoteRepository
|
||||
│ ├── session/ ActiveSessionHolder
|
||||
│ ├── update/ AppUpdateChecker, AppUpdateCache, AppUpdateState
|
||||
│ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository
|
||||
├── notification/ ChatNotificationService, NotificationReplyReceiver
|
||||
├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors
|
||||
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord
|
||||
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet
|
||||
├── ui/
|
||||
│ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows
|
||||
│ └── 28 feature packages (achievements, analytics, authlogin, billing, channels,
|
||||
│ chat, config, connect, cron, gateway, kanban, keys, landing, logs, mcp,
|
||||
│ model, pairing, plugins, process, profiles, providers, sessions, settings,
|
||||
│ skills, system, toolsets, webhooks)
|
||||
│ └── 31 feature packages (achievements, analytics, authlogin, billing, bots,
|
||||
│ channels, chat, config, connect, connectors, cron, files, gateway, kanban,
|
||||
│ keys, landing, logs, mcp, memory, model, pairing, plugins, process,
|
||||
│ profiles, providers, sessions, settings, skills, system, toolsets,
|
||||
│ webhooks)
|
||||
├── util/ CronExpressionFormatter, LocaleContextWrapper
|
||||
├── HermesControlApp.kt Application class
|
||||
├── Navigation.kt Drawer + NavDisplay + entry wiring
|
||||
├── NavigationController.kt Central navigation guard (dedup)
|
||||
├── NavigationKeys.kt @Serializable NavKey data objects (28 screens + 6 settings sub-pages)
|
||||
├── NavigationKeys.kt @Serializable NavKey data objects (30 screens + 4 detail keys + 7 settings sub-pages)
|
||||
├── ScreenRegistry.kt entry<T> registrations for all NavKeys
|
||||
└── MainActivity.kt
|
||||
```
|
||||
@@ -311,6 +329,7 @@ com.m57.hermescontrol/
|
||||
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
|
||||
- [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
|
||||
- [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation
|
||||
- [Gateway contract test](CONTRIBUTING.md#gateway-contract-snapshot) — backend drift check; refresh with `scripts/sync-gateway-contract.sh`
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style
|
||||
- [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth
|
||||
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs
|
||||
@@ -22,6 +22,8 @@ If an open PR already covers it, review or improve that one instead of opening a
|
||||
|
||||
Every change must go through a pull request (PR) targeting the `dev` staging branch. Do not push directly to `main` or `dev`.
|
||||
|
||||
**Keep PRs focused: one feature or one fix per PR.** Unrelated changes belong in separate PRs so each one stays easy to review, test, and revert.
|
||||
|
||||
1. **Pick or open an issue** to discuss the changes you want to make.
|
||||
2. **Create a branch** off `dev` using the following naming convention:
|
||||
- Features: `feat/short-description` (optionally `feat/issue-N-description`)
|
||||
@@ -110,6 +112,15 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
|
||||
- Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`), restore `Dispatchers.Main` in teardown, and cancel test-owned scopes. Follow neighboring tests for the affected component.
|
||||
- For UI changes, exercise the changed flow on a device or emulator and record the behavior observed. A built or downloaded CI APK alone is not behavioral verification. If device verification is unavailable, state that gate explicitly rather than marking it passed.
|
||||
|
||||
|
||||
### Gateway contract snapshot
|
||||
|
||||
`GatewayContractTest` checks `WsMethods`, handled server requests and literal param keys against a vendored copy of
|
||||
hermes-agent's `gateway-contract.openrpc.json` (`app/src/test/resources/gateway-contract/`, provenance in `SOURCE`).
|
||||
It is test-only. Refresh it with `scripts/sync-gateway-contract.sh [ref]`, then run
|
||||
`./gradlew testDebugUnitTest --tests '*GatewayContract*'` and review the diff. Allowlist exceptions need a reason in
|
||||
`GatewayContractAllowlist`.
|
||||
|
||||
---
|
||||
|
||||
## Dependency & Build Hygiene
|
||||
@@ -124,6 +135,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
|
||||
Before submitting your PR, please verify:
|
||||
|
||||
- [ ] I searched open/closed PRs + issues for duplicates (see *Before You Start*).
|
||||
- [ ] PR is focused on one feature or one fix (no unrelated changes).
|
||||
- [ ] Branch is rebased onto current `dev` (`git rebase origin/dev`).
|
||||
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first — no hand edits).
|
||||
- [ ] `checkColorLiterals` passes (no hardcoded Color literals outside theme/).
|
||||
|
||||
@@ -23,7 +23,7 @@ Android layout dimensions use `dp`; text sizes, line heights, and tracking use `
|
||||
|
||||
| Concern | Authoritative source |
|
||||
| --- | --- |
|
||||
| Presets and mode selection | [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) |
|
||||
| Presets and mode selection | [ThemeRegistry.kt](app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt), [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) |
|
||||
| Material slot mapping | [PaletteTemplate.kt](app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt) |
|
||||
| Palette values | [presets/](app/src/main/java/com/m57/hermescontrol/theme/presets/) |
|
||||
| Semantic status model | [HermesStatusColors.kt](app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt) |
|
||||
@@ -41,7 +41,7 @@ The visual language follows Material 3 guidelines strictly adapted for developer
|
||||
- **Core Principle**: Zero fluff. Fast render cycles, explicit states, flat surfaces, and zero visual ambiguity.
|
||||
|
||||
## Colors
|
||||
The app supports 6 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default.
|
||||
The app supports 7 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`, `Garnet`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default.
|
||||
|
||||
1. **Surfaces**:
|
||||
- `MaterialTheme.colorScheme.background`: Main canvas and full-bleed chat background.
|
||||
@@ -144,8 +144,11 @@ passed an accessibility audit.
|
||||
- **Narrow layouts**: Check long localized labels, keyboard-open states, and scrolling at small widths; density must not hide essential actions.
|
||||
|
||||
[ThemePaletteTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt)
|
||||
currently guards error slot pairs, full-bleed prose/header contrast, and theme-mode
|
||||
invariants. Those tests are useful but do not establish contrast or accessibility
|
||||
guards normal-text slot pairs, full-bleed prose/header contrast, complete Material
|
||||
role mapping, registry coverage, fixed-role mode invariance, and theme-mode invariants.
|
||||
[ThemeComponentContrastTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt)
|
||||
and the instrumented component gallery also guard enabled Material control states.
|
||||
Those tests are useful but do not establish contrast or accessibility
|
||||
compliance for every rendered component, alpha blend, or dynamic palette.
|
||||
Record device/emulator steps and observed results for changed UI; explicitly name
|
||||
any gate that could not be verified.
|
||||
@@ -30,13 +30,9 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Hermes Mobile** is the native Android client for [Hermes Agent](https://hermes-agent.nousresearch.com). It connects to your Hermes gateway (REST API and WebSocket TUI Gateway), giving you pocket control over your AI assistant. Plain HTTP/WS connections are intended for trusted networks only; authentication does not encrypt the transport.
|
||||
**Hermes Mobile** is a native Android app for controlling your [Hermes Agent](https://hermes-agent.nousresearch.com) from your phone. Chat with your agent, manage cron jobs, skills, models and more.
|
||||
|
||||
---
|
||||
|
||||
## Support the project
|
||||
|
||||
If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7).
|
||||
It connects to the Hermes dashboard (REST API and WebSocket gateway), so you need a running dashboard that your phone can reach.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,79 +57,41 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-Time Chat:** Message your agent with Room-backed local database history and inline reply notifications.
|
||||
- **System Config:** Manage active profiles, installed skills, plugins, toolsets, and LLM model/provider selections.
|
||||
- **Chat:** Talk to your agent. History is stored locally in Room, and notifications support inline replies.
|
||||
- **Configuration:** Manage profiles, skills, plugins, toolsets, and model/provider selections.
|
||||
- **Operations:** Stream and filter live logs, manage cron jobs, edit environment keys, test webhooks, and monitor processes.
|
||||
- **Gateway Status:** Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers.
|
||||
- **Productivity:** View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history.
|
||||
- **Analytics & Billing:** Usage analytics dashboard and billing/subscription management.
|
||||
- **Theming:** 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord) plus Material You dynamic colors on supported devices.
|
||||
- **Modern UX:** Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation.
|
||||
- **Gateway status:** Check the WebSocket connection, MCP servers, messaging channels, and OAuth providers.
|
||||
- **Productivity:** Manage tasks on Kanban boards, track agent milestones, and browse session history.
|
||||
- **Analytics and billing:** See usage analytics and manage billing or subscriptions.
|
||||
- **Theming:** Seven built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet), plus Material You dynamic colors on supported devices.
|
||||
- **Material 3 design:** Pull-to-refresh, a scroll-aware top bar, and customizable bottom navigation.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install Hermes Mobile from [F-Droid](https://f-droid.org/packages/com.m57.hermescontrol/) or download the APK from the [latest GitHub release](https://github.com/Hy4ri/hermes-mobile/releases/latest).
|
||||
2. Start your Hermes dashboard on a host reachable from your phone.
|
||||
3. Follow [Authentication](#authentication) below to connect. Use a trusted network for plain HTTP/WS connections.
|
||||
|
||||
## Build from source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **JDK 21+** (required for Kotlin compilation and the Gradle toolchain)
|
||||
- An **Android SDK** matching the compile SDK in [`app/build.gradle.kts`](app/build.gradle.kts), available through Android Studio or the **Nix** development environment. Set `ANDROID_HOME` or configure `sdk.dir` in your local `local.properties`.
|
||||
|
||||
### Build & Deploy
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/Hy4ri/hermes-mobile.git
|
||||
cd hermes-mobile
|
||||
```
|
||||
2. **Build the debug APK:**
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
3. **Install on your emulator/device:**
|
||||
```bash
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
_Note: For release builds, ensure keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`) are configured, or let the GitHub Actions release workflow handle it on tag push (`v*`)._
|
||||
|
||||
### Nix emulator
|
||||
|
||||
The Nix development shell enables physical keyboard input in an existing
|
||||
`hermes_dev` AVD configuration. It respects `ANDROID_AVD_HOME` and
|
||||
`ANDROID_USER_HOME`, defaulting to `~/.android/avd`.
|
||||
|
||||
Close any running emulator, then cold boot once to apply the setting:
|
||||
|
||||
```bash
|
||||
nix develop --command emulator -avd hermes_dev -no-snapshot-load
|
||||
```
|
||||
|
||||
Subsequent launches can omit `-no-snapshot-load`. Create the `hermes_dev` AVD
|
||||
first if it does not exist; the shell does not create one.
|
||||
1. Install Hermes Mobile from [F-Droid](https://f-droid.org/packages/com.m57.hermescontrol/), or download the APK from the [latest GitHub release](https://github.com/Hy4ri/hermes-mobile/releases/latest).
|
||||
2. Start your Hermes dashboard on a host your phone can reach.
|
||||
3. Open the app, tap **Sign in**, and follow [Authentication](#authentication).
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
Once the app is installed, you need to point it at your Hermes gateway. The app auto-detects which auth mode the dashboard is using — just fill in the fields it shows.
|
||||
The app detects which auth mode your dashboard uses and shows only the fields you need.
|
||||
|
||||
> **Security:** Use HTTPS for remote connections. Plain HTTP/WS is for trusted local networks only, because authentication does not encrypt the transport.
|
||||
|
||||
### 1. Start the dashboard
|
||||
|
||||
On your host machine, start the dashboard:
|
||||
On the host machine:
|
||||
|
||||
```bash
|
||||
hermes dashboard # loopback (127.0.0.1:9119) — no auth needed
|
||||
hermes dashboard --host 0.0.0.0 # LAN — requires auth
|
||||
```
|
||||
|
||||
For LAN access, configure credentials in `~/.hermes/config.yaml`:
|
||||
For LAN access, set credentials in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
dashboard:
|
||||
@@ -144,14 +102,17 @@ dashboard:
|
||||
|
||||
### 2. Connect the app
|
||||
|
||||
Tap **Sign in** on the landing screen and enter the dashboard host and port. The app probes the dashboard and reveals the fields you need:
|
||||
Tap **Sign in** on the landing screen and enter the dashboard host and port. The app probes the dashboard and shows the fields it needs.
|
||||
|
||||
| Auth mode | When | What you fill |
|
||||
| -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Token only** | Dashboard on same machine (loopback) | **Token** — grab from `~/.hermes/dashboard-token.txt` or `~/.hermes/.env` (`HERMES_DASHBOARD_SESSION_TOKEN`). The app can also auto-extract it from the dashboard page |
|
||||
| **Basic auth** | Dashboard on LAN with password gate | **Username** + **Password** (default `admin` / `hermes`). The app logs in, gets a session cookie, and mints a WebSocket ticket automatically |
|
||||
**Token only** (dashboard on the same machine, bound to loopback)
|
||||
|
||||
Use HTTPS for remote connections. Use HTTP only on a trusted local network.
|
||||
- Enter the **Token**. You can find it in `~/.hermes/dashboard-token.txt` or in `~/.hermes/.env` as `HERMES_DASHBOARD_SESSION_TOKEN`.
|
||||
- The app can also extract the token from the dashboard page for you.
|
||||
|
||||
**Basic auth** (dashboard on the LAN behind a password)
|
||||
|
||||
- Enter your **Username** and **Password** (default `admin` / `hermes`).
|
||||
- The app logs in, stores a session cookie, and requests a WebSocket ticket automatically.
|
||||
|
||||
### Cloudflare Access and custom headers
|
||||
|
||||
@@ -164,63 +125,99 @@ Your [Cloudflare Access policy](https://developers.cloudflare.com/cloudflare-one
|
||||
must accept the service token. These headers authenticate with Cloudflare; you still
|
||||
need to complete Hermes authentication.
|
||||
|
||||
You can add other custom header names and values in the same editor. To edit or
|
||||
remove saved headers, open **Custom headers** from login or the saved connection's
|
||||
edit dialog in Settings. Values are masked and stored in encrypted preferences.
|
||||
Saving an empty list removes the headers for that URL.
|
||||
**Other headers.** You can add any custom header name and value in the same editor.
|
||||
To edit or remove saved headers, open **Custom headers** from login, or from the saved
|
||||
connection's edit dialog in Settings. Values are masked and stored in encrypted
|
||||
preferences. Saving an empty list removes the headers for that URL.
|
||||
|
||||
Headers are shared by profiles with the same server URL. They apply to probes,
|
||||
login, API and media requests, and WebSocket handshakes. Each URL's scheme, host,
|
||||
port, and path prefix limit where its headers are sent. Redirects outside that
|
||||
scope do not receive the credentials; WebSocket redirects are not followed.
|
||||
The app manages `Authorization`, `Cookie`, and transport headers itself.
|
||||
**Where headers are sent.**
|
||||
|
||||
- Profiles with the same server URL share headers.
|
||||
- They apply to probes, login, API and media requests, and WebSocket handshakes.
|
||||
- Each URL's scheme, host, port, and path prefix limit where its headers go.
|
||||
- Redirects outside that scope do not receive them, and WebSocket redirects are not followed.
|
||||
- The app manages `Authorization`, `Cookie`, and transport headers itself.
|
||||
|
||||
### Connection profiles
|
||||
|
||||
Have multiple gateways? Switch between them in **Settings → Connection profiles**. Each profile stores its own host, port, and token — just tap to swap.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
app/src/main/java/com/m57/hermescontrol/
|
||||
├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models
|
||||
├── notification/ # Foreground service + inline reply for chat notifications
|
||||
├── theme/ # Preset-based design system (6 themes), status colors, spacing, typography
|
||||
├── ui/ # Compose feature screens + common components (HermesScaffold, StateViews)
|
||||
├── util/ # CronExpressionFormatter, LocaleContextWrapper
|
||||
└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Language:** Kotlin with KSP
|
||||
- **UI & Layout:** Jetpack Compose & Material 3 / Material You
|
||||
- **Navigation:** Navigation3 (Compose-first Routing)
|
||||
- **Networking:** Retrofit, OkHttp, Kotlinx Serialization
|
||||
- **Database:** Room with SQLCipher encryption
|
||||
- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore
|
||||
- **Theming:** Built-in presets + Material You dynamic colors; see [Themes](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md)
|
||||
- **Image Loading:** Coil
|
||||
- **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing
|
||||
- **Formatting:** `ktlint` 1.8.0 style rules (checked automatically in CI)
|
||||
|
||||
Dependency versions are maintained in [`gradle/libs.versions.toml`](gradle/libs.versions.toml); build configuration and dependency scopes live in [`app/build.gradle.kts`](app/build.gradle.kts).
|
||||
Have multiple gateways? Switch between them in **Settings → Connection profiles**. Each profile stores its own host, port, and token.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for our branch workflow, code style guidelines, and PR checklist.
|
||||
Contributions are welcome! Read [CONTRIBUTING.md](CONTRIBUTING.md) for the branch workflow, code style, and PR checklist.
|
||||
|
||||
### Translations
|
||||
- **Translations:** Help translate the app on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/).
|
||||
- **Conventions and architecture:** [AGENTS.md](AGENTS.md).
|
||||
- **Visual and interaction requirements:** [DESIGN.md](DESIGN.md).
|
||||
- **Theme implementation:** [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md).
|
||||
|
||||
Help translate Hermes Mobile into your language on [Hosted Weblate](https://hosted.weblate.org/projects/hermes-mobile/hermes-mobile/)!
|
||||
---
|
||||
|
||||
For operational conventions and architecture notes, refer to [AGENTS.md](AGENTS.md). [DESIGN.md](DESIGN.md) defines visual and interaction requirements; [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) explains theme implementation.
|
||||
## Build from source
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **JDK 21+** (used for Kotlin compilation and the Gradle toolchain).
|
||||
- An **Android SDK** matching the compile SDK in [`app/build.gradle.kts`](app/build.gradle.kts), from Android Studio or the **Nix** development environment. Set `ANDROID_HOME` or `sdk.dir` in `local.properties`.
|
||||
|
||||
### Build and install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Hy4ri/hermes-mobile.git
|
||||
cd hermes-mobile
|
||||
./gradlew assembleDebug
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
For release builds, set the keystore environment variables (`KEYSTORE_PATH`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`). Or let the GitHub Actions release workflow build on a `v*` tag push.
|
||||
|
||||
### Nix emulator
|
||||
|
||||
The Nix development shell turns on physical keyboard input in an existing `hermes_dev` AVD. It respects `ANDROID_AVD_HOME` and `ANDROID_USER_HOME`, defaulting to `~/.android/avd`. The shell does not create the AVD, so create `hermes_dev` first.
|
||||
|
||||
Close any running emulator, then cold boot once to apply the setting:
|
||||
|
||||
```bash
|
||||
nix develop --command emulator -avd hermes_dev -no-snapshot-load
|
||||
```
|
||||
|
||||
Later launches can omit `-no-snapshot-load`.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
app/src/main/java/com/m57/hermescontrol/
|
||||
├── data/ # Local (Room, AuthManager), Remote (Retrofit, OkHttp), WS (WebSocket), Models
|
||||
├── notification/ # Foreground service + inline reply for chat notifications
|
||||
├── theme/ # Preset-based design system (7 themes), status colors, spacing, typography
|
||||
├── ui/ # Compose feature screens + common components (HermesScaffold, StateViews)
|
||||
├── util/ # CronExpressionFormatter, LocaleContextWrapper
|
||||
└── Navigation*.kt # Navigation3 wiring, keys, screen registry, controller
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Language:** Kotlin with KSP
|
||||
- **UI:** Jetpack Compose, Material 3 / Material You
|
||||
- **Navigation:** Navigation3
|
||||
- **Networking:** Retrofit, OkHttp, Kotlinx Serialization
|
||||
- **Database:** Room with SQLCipher encryption
|
||||
- **Security:** `EncryptedSharedPreferences` (AES256-GCM), DataStore
|
||||
- **Images:** Coil
|
||||
- **Testing:** JUnit, MockK, Turbine, Espresso, Compose UI testing
|
||||
- **Formatting:** `ktlint` 1.8.0 (checked in CI)
|
||||
|
||||
Dependency versions live in [`gradle/libs.versions.toml`](gradle/libs.versions.toml). Build configuration and dependency scopes live in [`app/build.gradle.kts`](app/build.gradle.kts).
|
||||
|
||||
---
|
||||
|
||||
## Support the project
|
||||
|
||||
If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7).
|
||||
|
||||
---
|
||||
|
||||
@@ -228,4 +225,4 @@ For operational conventions and architecture notes, refer to [AGENTS.md](AGENTS.
|
||||
|
||||
Copyright © 2026 M57 (Hy4ri).
|
||||
|
||||
This project is licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details.
|
||||
Licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details.
|
||||
+17
-2
@@ -85,18 +85,26 @@ android {
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".dev"
|
||||
versionNameSuffix = "-dev"
|
||||
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = signingConfigs["release"]
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
@@ -309,6 +317,13 @@ tasks.named("check") {
|
||||
dependsOn("checkColorLiterals")
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
maxParallelForks = 2
|
||||
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
||||
if (!includeBenchmarks) {
|
||||
filter { excludeTestsMatching("*Benchmark") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okio.ByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class WsTransportDeviceTest {
|
||||
private class FakeWebSocket(
|
||||
private val request: Request,
|
||||
var accept: Boolean = true,
|
||||
var queueSizeValue: Long = 0L,
|
||||
) : WebSocket {
|
||||
val sentFrames = ConcurrentLinkedQueue<String>()
|
||||
val attempts = AtomicInteger(0)
|
||||
val closedCode = AtomicInteger(-1)
|
||||
val closedReason =
|
||||
java.util.concurrent.atomic
|
||||
.AtomicReference<String?>(null)
|
||||
val canceled = AtomicBoolean(false)
|
||||
|
||||
override fun request(): Request = request
|
||||
|
||||
override fun queueSize(): Long = queueSizeValue
|
||||
|
||||
override fun send(text: String): Boolean {
|
||||
attempts.incrementAndGet()
|
||||
if (!accept) return false
|
||||
sentFrames.add(text)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun send(bytes: ByteString): Boolean {
|
||||
attempts.incrementAndGet()
|
||||
if (!accept) return false
|
||||
sentFrames.add(bytes.utf8())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun close(
|
||||
code: Int,
|
||||
reason: String?,
|
||||
): Boolean {
|
||||
closedCode.set(code)
|
||||
closedReason.set(reason)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
canceled.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
private class TestFixture {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val lock = Any()
|
||||
val clock = AtomicLong(100L)
|
||||
val dummyRequest: Request = Request.Builder().url("http://localhost/ws").build()
|
||||
val openResponse: Response =
|
||||
Response
|
||||
.Builder()
|
||||
.request(dummyRequest)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(101)
|
||||
.message("Switching Protocols")
|
||||
.build()
|
||||
|
||||
val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
|
||||
val recordedEvents = ConcurrentLinkedQueue<WsEvent>()
|
||||
lateinit var transport: WsTransport
|
||||
lateinit var rpc: RpcChannel
|
||||
lateinit var health: ConnectionHealth
|
||||
lateinit var replay: ReplayTracker
|
||||
|
||||
init {
|
||||
health =
|
||||
ConnectionHealth(
|
||||
scope = scope,
|
||||
isConnected = { transport.isConnected },
|
||||
pingRequest = { _ -> },
|
||||
cancelSocket = { transport.cancelSocket() },
|
||||
pendingReply = { transport.pendingReply },
|
||||
nowMs = { clock.get() },
|
||||
)
|
||||
|
||||
replay =
|
||||
ReplayTracker(
|
||||
scope = scope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { recordedEvents.add(it) },
|
||||
)
|
||||
|
||||
rpc =
|
||||
RpcChannel(
|
||||
scope = scope,
|
||||
json = json,
|
||||
outboundLock = lock,
|
||||
sendRequest = { method, params, onSent ->
|
||||
transport.send(rpc.createRequest(method, params), onSent)
|
||||
},
|
||||
sendFrame = { transport.sendDirectFrame(it) },
|
||||
removeQueuedMessage = { transport.removeQueuedMessage(it) },
|
||||
onIdleCheck = { transport.disconnectIfIdleInBackground() },
|
||||
onResult = { transport.onRpcResult(it) },
|
||||
onError = { transport.onRpcError(it) },
|
||||
)
|
||||
|
||||
transport =
|
||||
WsTransport(
|
||||
scope = scope,
|
||||
outboundLock = lock,
|
||||
reconnectPolicy = ReconnectPolicy(),
|
||||
connectionHealth = health,
|
||||
replayTracker = replay,
|
||||
rpcChannel = rpc,
|
||||
onRawMessage = { raw ->
|
||||
rpc.handleIncomingFrame(raw, replay) { recordedEvents.add(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
try {
|
||||
transport.disconnect(clearPendingMessages = true)
|
||||
} finally {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transportRpcRoundTripWithStaleListenerIsolation() =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val fixture = TestFixture()
|
||||
try {
|
||||
val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
|
||||
fixture.transport.connectionGenerationForTest.set(1)
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
|
||||
val listenerGen1 = fixture.transport.createListenerForTest(1)
|
||||
listenerGen1.onOpen(fakeWs, fixture.openResponse)
|
||||
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
assertEquals(fakeWs, fixture.transport.webSocketForTest)
|
||||
|
||||
val deferred = fixture.rpc.request("gateway.ping", emptyMap())
|
||||
val sentFrame = requireNotNull(fakeWs.sentFrames.poll())
|
||||
|
||||
val parsedSent = fixture.json.decodeFromString<JsonRpcRequest>(sentFrame)
|
||||
val reqId = parsedSent.id
|
||||
|
||||
fixture.clock.set(200L)
|
||||
val responseJson = """{"jsonrpc":"2.0","id":"$reqId","result":42.0}"""
|
||||
listenerGen1.onMessage(fakeWs, responseJson)
|
||||
|
||||
val result = withTimeout(2_000L) { deferred.await() }
|
||||
assertEquals(JsonPrimitive(42.0), result)
|
||||
|
||||
val event = requireNotNull(fixture.recordedEvents.poll())
|
||||
assertTrue(event is WsEvent.RpcResult)
|
||||
assertEquals(reqId, (event as WsEvent.RpcResult).id)
|
||||
assertEquals(200L, fixture.health.lastPongTimestamp)
|
||||
|
||||
val staleListener = fixture.transport.createListenerForTest(0)
|
||||
val staleWs = FakeWebSocket(fixture.dummyRequest, accept = true)
|
||||
staleListener.onMessage(staleWs, """{"jsonrpc":"2.0","id":999,"result":99.0}""")
|
||||
listenerGen1.onMessage(staleWs, """{"jsonrpc":"2.0","id":998,"result":88.0}""")
|
||||
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
assertEquals(fakeWs, fixture.transport.webSocketForTest)
|
||||
assertEquals(200L, fixture.health.lastPongTimestamp)
|
||||
assertTrue(fixture.recordedEvents.isEmpty())
|
||||
} finally {
|
||||
fixture.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectingWsQueuesAndFlushesOnNextAcceptSocket() =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val fixture = TestFixture()
|
||||
try {
|
||||
val rejectingWs = FakeWebSocket(fixture.dummyRequest, accept = false, queueSizeValue = 1L)
|
||||
fixture.transport.connectionGenerationForTest.set(1)
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
|
||||
val listener1 = fixture.transport.createListenerForTest(1)
|
||||
listener1.onOpen(rejectingWs, fixture.openResponse)
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
|
||||
var recordedId: String? = null
|
||||
var callbackRanUnderLock = false
|
||||
val req1 = fixture.rpc.createRequest("test.one", emptyMap())
|
||||
val returnedId =
|
||||
fixture.transport.send(
|
||||
request = req1,
|
||||
onSent = { id ->
|
||||
callbackRanUnderLock = Thread.holdsLock(fixture.lock)
|
||||
recordedId = id
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(returnedId, req1.id.toString())
|
||||
assertEquals(returnedId, recordedId)
|
||||
assertTrue(callbackRanUnderLock)
|
||||
|
||||
val req2 = fixture.rpc.createRequest("test.two", emptyMap())
|
||||
fixture.transport.messageQueueForTest.add(fixture.rpc.encodeRequest(req2))
|
||||
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value)
|
||||
assertNotNull(fixture.transport.outboundDrainJobForTest)
|
||||
assertEquals(2, fixture.transport.messageQueueForTest.size)
|
||||
|
||||
val acceptWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
|
||||
val listenerReplacement = fixture.transport.createListenerForTest(1)
|
||||
listenerReplacement.onOpen(acceptWs, fixture.openResponse)
|
||||
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
assertEquals(ConnectionStatus.CONNECTED, fixture.transport.connectionStatus.value)
|
||||
assertNull(fixture.transport.outboundDrainJobForTest)
|
||||
|
||||
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||
val frameList = acceptWs.sentFrames.toList()
|
||||
assertEquals(
|
||||
listOf(fixture.rpc.encodeRequest(req1), fixture.rpc.encodeRequest(req2)),
|
||||
frameList,
|
||||
)
|
||||
} finally {
|
||||
fixture.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authClosingDoesNotPrematurelyDropPendingRpcAndClosedHaltsReconnect() =
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val fixture = TestFixture()
|
||||
try {
|
||||
val fakeWs = FakeWebSocket(fixture.dummyRequest, accept = true, queueSizeValue = 0L)
|
||||
fixture.transport.connectionGenerationForTest.set(1)
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
|
||||
val listener = fixture.transport.createListenerForTest(1)
|
||||
listener.onOpen(fakeWs, fixture.openResponse)
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
|
||||
val pendingDeferred = fixture.rpc.request("gateway.ping", emptyMap())
|
||||
assertTrue(fixture.rpc.hasPendingCalls())
|
||||
|
||||
listener.onClosing(fakeWs, 4401, "AUTH_EXPIRED")
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
assertTrue(fixture.rpc.hasPendingCalls())
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
listener.onClosed(fakeWs, 4401, "AUTH_EXPIRED")
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertNull(fixture.transport.reconnectJobForTest)
|
||||
assertTrue(fixture.rpc.hasPendingCalls())
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
fixture.transport.disconnect(clearPendingMessages = true)
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
pendingDeferred.cancel()
|
||||
} finally {
|
||||
fixture.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,15 @@ class ChatReadNotificationTest {
|
||||
SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")),
|
||||
SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context)
|
||||
val messages =
|
||||
mapServerMessages(
|
||||
sessionId,
|
||||
history,
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId)
|
||||
assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
@@ -61,6 +62,10 @@ class ChatScreenTest {
|
||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||
every { mockViewModel.transcriptState } returns
|
||||
MutableStateFlow(
|
||||
TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null),
|
||||
).asStateFlow()
|
||||
every { mockViewModel.connectionOperationState } returns
|
||||
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
||||
@@ -110,6 +115,10 @@ class ChatScreenTest {
|
||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||
every { mockViewModel.transcriptState } returns
|
||||
MutableStateFlow(
|
||||
TranscriptUiState.resolve(uiState, ChatTimelineState(), StreamingState(), null, null),
|
||||
).asStateFlow()
|
||||
every { mockViewModel.connectionOperationState } returns
|
||||
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||
every { mockViewModel.actionProgress } returns
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class ChatSearchRenderingTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun reasoningMatchExpandsCollapsedCardAndShowsTheHit() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
ReasoningCard(
|
||||
reasoningText = "a hidden needle in the trace",
|
||||
searchQuery = "needle",
|
||||
isCurrentMatch = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("a hidden needle in the trace").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolMatchRemainsVisibleInCollapsedHeader() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
ToolBubble(
|
||||
message = ChatMessage(role = MessageRole.TOOL, content = "opaque", toolName = "terminal"),
|
||||
searchQuery = "terminal",
|
||||
isCurrentMatch = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("terminal").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+48
@@ -1,8 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
@@ -30,4 +32,50 @@ class MarkdownHeadingRenderingTest {
|
||||
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quoteAndListContinuationRenderNestedCodeBlocks() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
MarkdownText(
|
||||
text =
|
||||
"""
|
||||
> # Quote heading
|
||||
> ```kotlin
|
||||
> val quoted = 1
|
||||
> ```
|
||||
|
||||
- Item
|
||||
```kotlin
|
||||
val listed = 2
|
||||
```
|
||||
""".trimIndent(),
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Quote heading").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Item").assertIsDisplayed()
|
||||
composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(2)
|
||||
composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingMarkdownRendersBlocksBeforeCompletion() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
MarkdownText(
|
||||
text = "## Live heading\n\n**ready**\n\n```kotlin\nval x = 1\n```",
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
isStreaming = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Live heading").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("ready").assertIsDisplayed()
|
||||
composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(1)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -32,4 +40,26 @@ class MarkdownTableRenderingTest {
|
||||
composeTestRule.onNodeWithText("**Name**").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("`code`").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun columnsSizeToContentAndStayAlignedAcrossRows() {
|
||||
composeTestRule.setContent {
|
||||
MaterialTheme {
|
||||
Box(Modifier.width(220.dp)) {
|
||||
MarkdownText(
|
||||
text = "| ID | Description |\n| --- | --- |\n| A | A considerably longer table value |",
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val idHeader = composeTestRule.onNodeWithTag("markdown_table_header_0").getUnclippedBoundsInRoot()
|
||||
val descHeader = composeTestRule.onNodeWithTag("markdown_table_header_1").getUnclippedBoundsInRoot()
|
||||
val idCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_0").getUnclippedBoundsInRoot()
|
||||
val descCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_1").getUnclippedBoundsInRoot()
|
||||
assertTrue(descHeader.right - descHeader.left > idHeader.right - idHeader.left)
|
||||
assertEquals(idHeader.right - idHeader.left, idCell.right - idCell.left)
|
||||
assertEquals(descHeader.right - descHeader.left, descCell.right - descCell.left)
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,59 @@ class MediaViewerDeviceTest {
|
||||
assertEquals(expectedSize, actualSize)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageUsesImageSurfaceAndStreamsSaveToDownloads() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val name = "viewer-image-${System.nanoTime()}.png"
|
||||
val file = File(context.cacheDir, name)
|
||||
val bitmap = android.graphics.Bitmap.createBitmap(8, 8, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
|
||||
bitmap.recycle()
|
||||
compose.setContent {
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
HermesControlTheme {
|
||||
if (visible) {
|
||||
MediaViewerDialog(file.toURI().toString(), { visible = false }, name, "image/png")
|
||||
}
|
||||
}
|
||||
}
|
||||
awaitTag("media_image")
|
||||
// Images never get the player surface.
|
||||
assertTrue(compose.onAllNodesWithTag("media_play_pause_button").fetchSemanticsNodes().isEmpty())
|
||||
compose.onNodeWithTag("media_save_button").assertIsDisplayed().performClick()
|
||||
|
||||
val resolver = context.contentResolver
|
||||
var savedUri: Uri? = null
|
||||
compose.waitUntil(15_000) {
|
||||
resolver
|
||||
.query(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
arrayOf(MediaStore.MediaColumns._ID),
|
||||
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
|
||||
arrayOf(name),
|
||||
null,
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
val id = c.getLong(c.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
|
||||
savedUri = ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
}
|
||||
try {
|
||||
assertArrayEquals(file.readBytes(), resolver.openInputStream(savedUri!!)?.use { it.readBytes() })
|
||||
} finally {
|
||||
savedUri?.let { resolver.delete(it, null, null) }
|
||||
}
|
||||
compose.onNodeWithTag("media_close_button").performClick()
|
||||
compose.waitUntil(15_000) {
|
||||
compose.onAllNodesWithTag("media_viewer_dialog").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSourceShowsRetryAndCanClose() {
|
||||
compose.setContent {
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyQuestionUi
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ClarifyBubbleTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun clickingProvidedChoice_invokesOnRespondSingleWithExactChoice() {
|
||||
var respondedSingle: String? = null
|
||||
var respondedBatch: Map<String, String>? = null
|
||||
var dismissed = false
|
||||
|
||||
val clarifyRequest =
|
||||
ClarifyUi(
|
||||
text = "Which environment should be targeted?",
|
||||
options = listOf("Staging", "Production"),
|
||||
clarifyId = "req-1",
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
HermesControlTheme {
|
||||
ClarifyBubble(
|
||||
clarifyRequest = clarifyRequest,
|
||||
onRespondSingle = { respondedSingle = it },
|
||||
onRespondBatch = { respondedBatch = it },
|
||||
onDismiss = { dismissed = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Staging").assertIsDisplayed().performClick()
|
||||
|
||||
compose.runOnIdle {
|
||||
assertEquals("Staging", respondedSingle)
|
||||
assertNull(respondedBatch)
|
||||
assertEquals(false, dismissed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clickingDismiss_invokesOnDismissWithoutResponse() {
|
||||
var respondedSingle: String? = null
|
||||
var respondedBatch: Map<String, String>? = null
|
||||
var dismissed = false
|
||||
|
||||
val dismissLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_dismiss)
|
||||
|
||||
val clarifyRequest =
|
||||
ClarifyUi(
|
||||
text = "Do you want to run database migrations?",
|
||||
options = listOf("Yes", "No"),
|
||||
clarifyId = "req-2",
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
HermesControlTheme {
|
||||
ClarifyBubble(
|
||||
clarifyRequest = clarifyRequest,
|
||||
onRespondSingle = { respondedSingle = it },
|
||||
onRespondBatch = { respondedBatch = it },
|
||||
onDismiss = { dismissed = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText(dismissLabel).assertIsDisplayed().performClick()
|
||||
|
||||
compose.runOnIdle {
|
||||
assertEquals(true, dismissed)
|
||||
assertNull(respondedSingle)
|
||||
assertNull(respondedBatch)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replacingRequestIdentity_resetsEnteredTextAndSelection() {
|
||||
val sharedQuestion =
|
||||
ClarifyQuestionUi(
|
||||
qid = "q-common",
|
||||
question = "Which build targets and extras should be enabled?",
|
||||
choices = listOf("APK", "AAB", "Native Symbols"),
|
||||
multiSelect = true,
|
||||
)
|
||||
|
||||
var currentRequest by mutableStateOf(
|
||||
ClarifyUi(
|
||||
text = sharedQuestion.question,
|
||||
clarifyId = "req-1",
|
||||
questions = listOf(sharedQuestion),
|
||||
),
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
HermesControlTheme {
|
||||
ClarifyBubble(
|
||||
clarifyRequest = currentRequest,
|
||||
onRespondSingle = {},
|
||||
onRespondBatch = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose
|
||||
.onNodeWithText("APK")
|
||||
.assertIsDisplayed()
|
||||
.assertIsNotSelected()
|
||||
.performClick()
|
||||
compose.onNodeWithText("APK").assertIsSelected()
|
||||
|
||||
val textInputNode = compose.onNode(hasSetTextAction()).assertIsDisplayed()
|
||||
textInputNode.performTextInput("--dry-run")
|
||||
textInputNode.assert(hasEditableText("--dry-run"))
|
||||
|
||||
// Replace request identity while retaining identical qid, question, and options
|
||||
compose.runOnIdle {
|
||||
currentRequest =
|
||||
ClarifyUi(
|
||||
text = sharedQuestion.question,
|
||||
clarifyId = "req-2",
|
||||
questions = listOf(sharedQuestion),
|
||||
)
|
||||
}
|
||||
|
||||
// Selected choice and typed text must reset under new clarifyId
|
||||
compose.onNodeWithText("APK").assertIsDisplayed().assertIsNotSelected()
|
||||
compose.onNode(hasSetTextAction()).assertIsDisplayed().assert(hasEditableText(""))
|
||||
}
|
||||
|
||||
// The field merges its placeholder label into Text, so assertTextEquals can
|
||||
// never match; compare only the typed value.
|
||||
private fun hasEditableText(value: String) =
|
||||
SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(value))
|
||||
}
|
||||
+26
-2
@@ -50,8 +50,8 @@ class ComposerReadinessTest {
|
||||
compose.runOnIdle { assertEquals(0, sends) }
|
||||
compose.runOnIdle { ready.value = true }
|
||||
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
|
||||
// Ready + typing = an interruptible generation: Stop replaces the
|
||||
// action glyph while queue-send stays available in the flat slot.
|
||||
// Ready + typing = an interruptible generation: Send keeps the primary
|
||||
// action slot and Stop moves to the flat slot beside it.
|
||||
compose.onNodeWithTag("stop_button").assertExists()
|
||||
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
|
||||
compose.runOnIdle { assertEquals(1, sends) }
|
||||
@@ -60,4 +60,28 @@ class ComposerReadinessTest {
|
||||
compose.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
||||
compose.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
onSend = {},
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = true,
|
||||
canInterrupt = true,
|
||||
isConnected = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
)
|
||||
}
|
||||
compose.onNodeWithTag("stop_button").assertExists()
|
||||
compose.onNodeWithTag("send_button").assertDoesNotExist()
|
||||
compose.onNodeWithTag("chat_input").performTextInput("next")
|
||||
compose.onNodeWithTag("send_button").assertExists()
|
||||
compose.onNodeWithTag("stop_button").assertExists()
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class CopyFeedbackTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Composable
|
||||
private fun CopyFeedbackHarness(
|
||||
tagPrefix: String,
|
||||
stateReceiver: (MutableState<Boolean>) -> Unit = {},
|
||||
) {
|
||||
val feedbackState = rememberCopyFeedback()
|
||||
stateReceiver(feedbackState)
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = if (feedbackState.value) "Copied" else "Idle",
|
||||
modifier = Modifier.testTag("${tagPrefix}_status"),
|
||||
)
|
||||
Text(
|
||||
text = "Trigger",
|
||||
modifier =
|
||||
Modifier
|
||||
.testTag("${tagPrefix}_trigger")
|
||||
.clickable { feedbackState.value = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialStateIsFalse() {
|
||||
composeTestRule.setContent {
|
||||
CopyFeedbackHarness(tagPrefix = "feedback")
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTag("feedback_status")
|
||||
.assertIsDisplayed()
|
||||
.assertTextEquals("Idle")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingTrueShowsFeedbackAndResetsAfter1500Ms() {
|
||||
composeTestRule.mainClock.autoAdvance = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
CopyFeedbackHarness(tagPrefix = "feedback")
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
|
||||
|
||||
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||
|
||||
// Advance 1400ms - should still be true (100ms before 1500ms timeout)
|
||||
composeTestRule.mainClock.advanceTimeBy(1400L)
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||
|
||||
// Advance another 100ms to hit exactly 1500ms
|
||||
composeTestRule.mainClock.advanceTimeBy(100L)
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingTrueAgainWhileAlreadyTrueDoesNotRestartActiveTimer() {
|
||||
composeTestRule.mainClock.autoAdvance = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
CopyFeedbackHarness(tagPrefix = "feedback")
|
||||
}
|
||||
|
||||
// Trigger at t = 0ms
|
||||
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||
|
||||
// Advance to t = 1000ms
|
||||
composeTestRule.mainClock.advanceTimeBy(1000L)
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||
|
||||
// Re-trigger setting true at t = 1000ms while already true
|
||||
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||
|
||||
// If the active timer is NOT restarted, it resets at t = 1500ms (500ms after the second click)
|
||||
composeTestRule.mainClock.advanceTimeBy(500L)
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
|
||||
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun separateComposedHookInstancesAreIndependent() {
|
||||
composeTestRule.mainClock.autoAdvance = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
Column {
|
||||
CopyFeedbackHarness(tagPrefix = "instance_a")
|
||||
CopyFeedbackHarness(tagPrefix = "instance_b")
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
|
||||
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
|
||||
|
||||
// Trigger only instance A
|
||||
composeTestRule.onNodeWithTag("instance_a_trigger").performClick()
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
|
||||
// Instance A should be active, instance B should remain idle
|
||||
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
|
||||
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
|
||||
|
||||
// Advance 1000ms
|
||||
composeTestRule.mainClock.advanceTimeBy(1000L)
|
||||
|
||||
// Trigger instance B at t = 1000ms
|
||||
composeTestRule.onNodeWithTag("instance_b_trigger").performClick()
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
|
||||
// Both should be Copied now
|
||||
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
|
||||
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
|
||||
|
||||
// Advance 500ms to t = 1500ms: Instance A should expire, Instance B should still have 1000ms remaining
|
||||
composeTestRule.mainClock.advanceTimeBy(500L)
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
|
||||
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
|
||||
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
|
||||
|
||||
// Advance remaining 1000ms for Instance B
|
||||
composeTestRule.mainClock.advanceTimeBy(1000L)
|
||||
composeTestRule.mainClock.advanceTimeByFrame()
|
||||
|
||||
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
|
||||
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
|
||||
}
|
||||
}
|
||||
+4
@@ -219,6 +219,10 @@ class VoiceNoteGestureTest {
|
||||
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||
composeTestRule.mainClock.advanceTimeBy(600)
|
||||
// The strip takes the field's decoration box mid-hold; the field must
|
||||
// keep its focus so the keyboard never collapses when recording starts
|
||||
// (device follow-up, #1247).
|
||||
composeTestRule.onNodeWithTag("chat_input").assertIsFocused()
|
||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { up() }
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
|
||||
+45
-18
@@ -12,7 +12,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
@@ -21,7 +22,6 @@ import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -116,31 +116,58 @@ class FullBleedChatAnchorTest {
|
||||
val tailKeys =
|
||||
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
|
||||
val listState = LazyListState(index, offset)
|
||||
val viewModel = mockk<ChatViewModel>(relaxed = true)
|
||||
lateinit var scope: CoroutineScope
|
||||
compose.setContent {
|
||||
scope = rememberCoroutineScope()
|
||||
val controller = rememberChatScrollController(listState, scope)
|
||||
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
|
||||
FullBleedChatList(
|
||||
messages = messages.value,
|
||||
streamingState = streaming.value,
|
||||
isAgentTyping = tailKey != null,
|
||||
transcript =
|
||||
TranscriptUiState.resolve(
|
||||
chat =
|
||||
ChatUiState(
|
||||
messages = messages.value,
|
||||
isAgentTyping = tailKey != null,
|
||||
typingEffectEnabled = false,
|
||||
typingEffectDelayMs = 30,
|
||||
isLoadingOlder = loading.value,
|
||||
currentSessionId = session.value,
|
||||
clarifyRequest =
|
||||
if (tailKey !=
|
||||
null
|
||||
) {
|
||||
ClarifyUi("Choose an option", listOf("Yes"))
|
||||
} else {
|
||||
null
|
||||
},
|
||||
vaultUnlockPrompt =
|
||||
if (tailKey != null) {
|
||||
VaultUnlockPromptUi(
|
||||
null,
|
||||
null,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
vaultSaveLoginPrompt =
|
||||
if (tailKey !=
|
||||
null
|
||||
) {
|
||||
VaultSaveLoginPromptUi(null, null)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
|
||||
),
|
||||
timeline = ChatTimelineState(),
|
||||
streaming = streaming.value,
|
||||
savingAttachmentPath = null,
|
||||
speakingMessageId = null,
|
||||
),
|
||||
actions = testTranscriptActions(),
|
||||
searchState = ChatSearchState(),
|
||||
typingEffectEnabled = false,
|
||||
typingEffectDelayMs = 30,
|
||||
isLoading = false,
|
||||
isLoadingOlder = loading.value,
|
||||
listState = listState,
|
||||
scrollController = controller,
|
||||
lastAnimatedMessageId = null,
|
||||
onLastAnimatedMessageIdChange = {},
|
||||
viewModel = viewModel,
|
||||
pagingSessionId = session.value,
|
||||
clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null,
|
||||
vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null,
|
||||
vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null,
|
||||
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+84
-34
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
@@ -12,19 +15,23 @@ import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeDown
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.SearchTarget
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -72,44 +79,87 @@ class FullBleedChatListTest {
|
||||
showUserMessageTokens: Boolean = true,
|
||||
showAssistantMessageTokens: Boolean = true,
|
||||
showTokensPerSecond: Boolean = true,
|
||||
searchState: ChatSearchState = ChatSearchState(),
|
||||
onController: (ChatScrollController) -> Unit = {},
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
val listState = LazyListState()
|
||||
val listState = remember { LazyListState() }
|
||||
// Frame-clocked scope, matching ChatScreen: animated scrolls need a MonotonicFrameClock.
|
||||
val scrollController = rememberChatScrollController(listState, rememberCoroutineScope())
|
||||
SideEffect { onController(scrollController) }
|
||||
FullBleedChatList(
|
||||
messages = messages,
|
||||
streamingState = streamingState,
|
||||
isAgentTyping = isAgentTyping,
|
||||
searchState = ChatSearchState(),
|
||||
typingEffectEnabled = false,
|
||||
typingEffectDelayMs = 30,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||
showTokensPerSecond = showTokensPerSecond,
|
||||
isLoading = false,
|
||||
isLoadingOlder = false,
|
||||
listState = listState,
|
||||
scrollController =
|
||||
ChatScrollController(
|
||||
listState = listState,
|
||||
scope = CoroutineScope(Dispatchers.Main.immediate),
|
||||
transcript =
|
||||
TranscriptUiState.resolve(
|
||||
chat =
|
||||
ChatUiState(
|
||||
messages = messages,
|
||||
isAgentTyping = isAgentTyping,
|
||||
typingEffectEnabled = false,
|
||||
typingEffectDelayMs = 30,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||
showTokensPerSecond = showTokensPerSecond,
|
||||
clarifyRequest =
|
||||
if (clarify) {
|
||||
com.m57.hermescontrol.ui.chat.ClarifyUi(
|
||||
text = "pick",
|
||||
options = listOf("a"),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
),
|
||||
timeline = ChatTimelineState(),
|
||||
streaming = streamingState,
|
||||
savingAttachmentPath = null,
|
||||
speakingMessageId = null,
|
||||
),
|
||||
lastAnimatedMessageId = null,
|
||||
onLastAnimatedMessageIdChange = {},
|
||||
viewModel = mockk<ChatViewModel>(relaxed = true),
|
||||
clarifyRequest =
|
||||
if (clarify) {
|
||||
com.m57.hermescontrol.ui.chat.ClarifyUi(
|
||||
text = "pick",
|
||||
options = listOf("a"),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
actions = testTranscriptActions(),
|
||||
searchState = searchState,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upwardUserGesture_pausesBottomFollow() {
|
||||
lateinit var scrollController: ChatScrollController
|
||||
render(
|
||||
messages = List(40) { index -> msg("u$index", MessageRole.USER) },
|
||||
onController = { scrollController = it },
|
||||
)
|
||||
composeTestRule.runOnIdle { scrollController.jumpToBottom() }
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onRoot().performTouchInput { swipeDown() }
|
||||
composeTestRule.runOnIdle { assertFalse(scrollController.isFollowingBottom) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchNavigatesToToolRowBeyondInitialViewport() {
|
||||
val search =
|
||||
ChatSearchState().apply {
|
||||
isActive = true
|
||||
query = "terminal"
|
||||
matchIndices = listOf(30)
|
||||
matchOffsets = listOf(0)
|
||||
matchTargets = listOf(SearchTarget.TOOL)
|
||||
currentIndex = 0
|
||||
matchedIds = setOf("tool")
|
||||
currentMatchId = "tool"
|
||||
}
|
||||
render(
|
||||
messages =
|
||||
List(30) { index -> msg("user-$index", MessageRole.USER) } +
|
||||
ChatMessage("tool", MessageRole.TOOL, "opaque", toolName = "terminal"),
|
||||
searchState = search,
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithTag("fullbleed_tool_row").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("terminal").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userMessages_keepBubble_agentProse_isFullBleed() {
|
||||
render(
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
/** No-op actions for transcript layout tests; callbacks are irrelevant to their assertions. */
|
||||
internal fun testTranscriptActions() =
|
||||
TranscriptActions(
|
||||
onLoadOlder = {},
|
||||
onOpenAttachment = {},
|
||||
onSaveAttachment = {},
|
||||
onImageClick = {},
|
||||
onRespondApproval = {},
|
||||
onRespondClarify = {},
|
||||
onRespondClarifyBatch = {},
|
||||
onDismissClarify = {},
|
||||
onRespondVaultUnlock = {},
|
||||
onDismissVaultUnlock = {},
|
||||
onRespondVaultSaveLogin = { _, _ -> },
|
||||
onDismissVaultSaveLogin = {},
|
||||
onRespondVaultCode = {},
|
||||
onDismissVaultCode = {},
|
||||
onToggleSpeak = {},
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import com.m57.hermescontrol.theme.resolveStatusColors
|
||||
import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class StatusBadgeColorTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun latteBadgesRenderStatusFillsAndMatchingOnColors() {
|
||||
val types = StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.CATPPUCCIN) {
|
||||
Column {
|
||||
types.forEach { type ->
|
||||
StatusBadge(type.name, type, Modifier.testTag("badge_$type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val colors = resolveStatusColors(ThemePreset.CATPPUCCIN, darkTheme = false)
|
||||
types.forEach { type ->
|
||||
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
|
||||
assertRenderedPair("badge_$type", background.toArgb(), foreground.toArgb())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nordLiveIndicatorsRenderStatusFillsAndMatchingOnColors() {
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.NORD) {
|
||||
Column {
|
||||
SessionLiveStatusIndicator(SessionLiveStatus.WORKING, "working")
|
||||
SessionLiveStatusIndicator(SessionLiveStatus.WAITING, "waiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
val colors = resolveStatusColors(ThemePreset.NORD, darkTheme = false)
|
||||
assertRenderedPair("session_live_status_working", colors.success.toArgb(), colors.onSuccess.toArgb())
|
||||
assertRenderedPair("session_live_status_waiting", colors.warning.toArgb(), colors.onWarning.toArgb())
|
||||
}
|
||||
|
||||
private fun assertRenderedPair(
|
||||
tag: String,
|
||||
background: Int,
|
||||
foreground: Int,
|
||||
) {
|
||||
// PR #1417: verify pixels as well as the palette mapping, so a renderer
|
||||
// switching back to accent-on-container cannot leave the contrast test green.
|
||||
val pixels = composeTestRule.onNodeWithTag(tag).captureToImage().toPixelMap()
|
||||
assertEquals("$tag must render the status fill", background, pixels[pixels.width / 2, 1].toArgb())
|
||||
assertTrue(
|
||||
"$tag must render the matching on-status text/icon",
|
||||
(0 until pixels.height).any { y ->
|
||||
(0 until pixels.width).any { x -> pixels[x, y].toArgb() == foreground }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package com.m57.hermescontrol.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.RadioButtonDefaults
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import com.m57.hermescontrol.theme.ThemeRegistry
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
@MediumTest
|
||||
class ThemeComponentGalleryTest(
|
||||
private val preset: ThemePreset,
|
||||
private val dark: Boolean,
|
||||
) {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private var samples = emptyList<Sample>()
|
||||
|
||||
@Test
|
||||
fun enabledControlsRenderTheirAccessibleDefaultStateColors() {
|
||||
composeTestRule.setContent {
|
||||
HermesControlTheme(
|
||||
themePreset = preset,
|
||||
themePreference = if (dark) ThemePreference.DARK else ThemePreference.LIGHT,
|
||||
) {
|
||||
Gallery()
|
||||
}
|
||||
}
|
||||
composeTestRule.waitForIdle()
|
||||
// Assert the public defaults from the installed Compose version, not a copied token table.
|
||||
assertTrue("gallery must publish its state contracts", samples.isNotEmpty())
|
||||
samples.forEach { sample ->
|
||||
val a = sample.foreground.luminance()
|
||||
val b = sample.background.luminance()
|
||||
val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f)
|
||||
assertTrue("$preset dark=$dark ${sample.tag}: $ratio < ${sample.minimum}", ratio >= sample.minimum)
|
||||
}
|
||||
// Pixel evidence catches defaults that differ from the actual rendered state (including focus).
|
||||
samples.groupBy { it.tag }.forEach { (tag, contracts) ->
|
||||
val pixels =
|
||||
composeTestRule
|
||||
.onNodeWithTag(tag)
|
||||
.performScrollTo()
|
||||
.captureToImage()
|
||||
.toPixelMap()
|
||||
val rendered =
|
||||
buildSet {
|
||||
for (y in 0 until pixels.height) {
|
||||
for (x in 0 until pixels.width) add(pixels[x, y].toArgb())
|
||||
}
|
||||
}
|
||||
contracts.forEach { sample ->
|
||||
assertTrue("$preset dark=$dark $tag must render its foreground", sample.foreground.toArgb() in rendered)
|
||||
if (sample.checkBackground) {
|
||||
assertTrue("$preset dark=$dark $tag must render its fill", sample.background.toArgb() in rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Gallery() {
|
||||
val c = MaterialTheme.colorScheme
|
||||
val switches = SwitchDefaults.colors()
|
||||
val radios = RadioButtonDefaults.colors()
|
||||
val checks = CheckboxDefaults.colors()
|
||||
val sliders = SliderDefaults.colors()
|
||||
val fields = OutlinedTextFieldDefaults.colors()
|
||||
val segments = SegmentedButtonDefaults.colors()
|
||||
val focus = remember { FocusRequester() }
|
||||
SideEffect {
|
||||
samples =
|
||||
listOf(
|
||||
Sample("switch_on", switches.checkedThumbColor, switches.checkedTrackColor, checkBackground = true),
|
||||
Sample("switch_on", switches.checkedTrackColor, c.surface),
|
||||
Sample(
|
||||
"switch_off",
|
||||
switches.uncheckedThumbColor,
|
||||
switches.uncheckedTrackColor,
|
||||
checkBackground = true,
|
||||
),
|
||||
Sample("switch_off", switches.uncheckedBorderColor, c.surface),
|
||||
Sample("radio_on", radios.selectedColor, c.surface),
|
||||
Sample("radio_off", radios.unselectedColor, c.surface),
|
||||
Sample("check_on", checks.checkedCheckmarkColor, checks.checkedBoxColor, checkBackground = true),
|
||||
Sample("check_on", checks.checkedBorderColor, c.surface),
|
||||
Sample("check_off", checks.uncheckedBorderColor, c.surface),
|
||||
Sample("slider", sliders.activeTrackColor, sliders.inactiveTrackColor, checkBackground = true),
|
||||
Sample(
|
||||
"field_normal",
|
||||
fields.unfocusedIndicatorColor,
|
||||
fields.unfocusedContainerColor.compositeOver(c.surface),
|
||||
),
|
||||
Sample("field_normal", fields.unfocusedLabelColor, c.surface, 4.5f),
|
||||
Sample(
|
||||
"field_focus",
|
||||
fields.focusedIndicatorColor,
|
||||
fields.focusedContainerColor.compositeOver(c.surface),
|
||||
),
|
||||
Sample("field_focus", fields.focusedLabelColor, c.surface, 4.5f),
|
||||
Sample(
|
||||
"field_error",
|
||||
fields.errorIndicatorColor,
|
||||
fields.errorContainerColor.compositeOver(c.surface),
|
||||
),
|
||||
Sample("field_error", fields.errorSupportingTextColor, c.surface, 4.5f),
|
||||
Sample("segment_on", segments.activeContentColor, segments.activeContainerColor, 4.5f, true),
|
||||
Sample("segment_on", segments.activeBorderColor, segments.activeContainerColor),
|
||||
Sample(
|
||||
"segment_off",
|
||||
segments.inactiveContentColor,
|
||||
segments.inactiveContainerColor.compositeOver(c.surface),
|
||||
4.5f,
|
||||
),
|
||||
Sample("segment_off", segments.inactiveBorderColor, c.surface),
|
||||
Sample("primary_text", c.primary, c.surface, 4.5f),
|
||||
Sample("error_text", c.error, c.surface, 4.5f),
|
||||
)
|
||||
}
|
||||
Surface(color = c.surface, modifier = Modifier.fillMaxSize()) {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()).padding(8.dp)) {
|
||||
Text("$preset ${if (dark) "dark" else "light"}")
|
||||
Row {
|
||||
Switch(true, {}, Modifier.testTag("switch_on"))
|
||||
Switch(false, {}, Modifier.testTag("switch_off"))
|
||||
RadioButton(true, {}, Modifier.testTag("radio_on"))
|
||||
RadioButton(false, {}, Modifier.testTag("radio_off"))
|
||||
}
|
||||
Row {
|
||||
Checkbox(true, {}, Modifier.testTag("check_on"))
|
||||
Checkbox(false, {}, Modifier.testTag("check_off"))
|
||||
}
|
||||
Slider(0.5f, {}, Modifier.testTag("slider"))
|
||||
OutlinedTextField("Value", {
|
||||
}, Modifier.fillMaxWidth().testTag("field_normal"), readOnly = true, label = { Text("Normal") })
|
||||
OutlinedTextField(
|
||||
"Value",
|
||||
{
|
||||
},
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(
|
||||
focus,
|
||||
).testTag("field_focus"),
|
||||
readOnly = true,
|
||||
label = { Text("Focused") },
|
||||
)
|
||||
OutlinedTextField("Value", {
|
||||
}, Modifier.fillMaxWidth().testTag("field_error"), readOnly = true, isError = true, label = {
|
||||
Text("Error")
|
||||
}, supportingText = { Text("Error text") })
|
||||
SingleChoiceSegmentedButtonRow {
|
||||
SegmentedButton(
|
||||
true,
|
||||
{},
|
||||
SegmentedButtonDefaults.itemShape(0, 2),
|
||||
Modifier.testTag("segment_on"),
|
||||
) { Text("Selected") }
|
||||
SegmentedButton(
|
||||
false,
|
||||
{
|
||||
},
|
||||
SegmentedButtonDefaults.itemShape(
|
||||
1,
|
||||
2,
|
||||
),
|
||||
Modifier.testTag("segment_off"),
|
||||
) { Text("Unselected") }
|
||||
}
|
||||
Text("Primary foreground", Modifier.testTag("primary_text"), color = c.primary)
|
||||
Text("Error foreground", Modifier.testTag("error_text"), color = c.error)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||
}
|
||||
|
||||
private data class Sample(
|
||||
val tag: String,
|
||||
val foreground: Color,
|
||||
val background: Color,
|
||||
val minimum: Float = 3f,
|
||||
val checkBackground: Boolean = false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0} dark={1}")
|
||||
fun presets(): List<Array<Any>> =
|
||||
ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf<Any>(preset, it) } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
</resources>
|
||||
@@ -71,6 +71,10 @@
|
||||
android:name=".notification.NotificationReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".notification.StopKeepConnectedReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -215,14 +215,22 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
var lastBackPressTime by remember { mutableLongStateOf(0L) }
|
||||
val backToastText = stringResource(R.string.press_back_again_to_exit)
|
||||
|
||||
// Double-back to exit on root ChatScreen
|
||||
// Double-back to exit on root ChatScreen — unless the chat was opened from
|
||||
// a list (History, Bots): then the first back returns to that list (Telegram
|
||||
// chat-list parity) and only a further double-back exits.
|
||||
BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBackPressTime < 2000L) {
|
||||
(context as? Activity)?.finish()
|
||||
val pendingReturn = NavigationController.chatReturnScreen
|
||||
if (pendingReturn != null) {
|
||||
NavigationController.consumeChatReturnScreen()
|
||||
NavigationController.navigateTo(pendingReturn)
|
||||
} else {
|
||||
lastBackPressTime = now
|
||||
Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show()
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastBackPressTime < 2000L) {
|
||||
(context as? Activity)?.finish()
|
||||
} else {
|
||||
lastBackPressTime = now
|
||||
Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@ object NavigationController {
|
||||
private set
|
||||
var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The list screen a chat was opened from (History, Bots). Back out of that
|
||||
* chat returns here instead of exiting the app (Telegram chat-list parity).
|
||||
* Consumed by MainNavigation's root-chat back handler; cleared by any other
|
||||
* navigation so a stale target can never hijack a later back press.
|
||||
*/
|
||||
var chatReturnScreen: NavKey? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
val pendingSessionId: String? get() = pendingChatNavigation?.sessionId
|
||||
|
||||
private var nextChatNavigationRequestId = 0L
|
||||
@@ -45,8 +55,18 @@ object NavigationController {
|
||||
*/
|
||||
fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key }
|
||||
|
||||
/** Returns the armed return screen and disarms it. */
|
||||
fun consumeChatReturnScreen(): NavKey? = chatReturnScreen.also { chatReturnScreen = null }
|
||||
|
||||
fun navigateTo(key: NavKey) {
|
||||
val stack = backStack ?: return
|
||||
|
||||
// Any explicit navigation invalidates the pending chat return — only a
|
||||
// session/chat opened from a list arms it again (see [queueChatNavigation]).
|
||||
// This must precede the dedup guard: re-selecting Chat in the drawer is
|
||||
// still an explicit navigation and must not keep a stale return armed.
|
||||
chatReturnScreen = null
|
||||
|
||||
if (stack.lastOrNull() == key) return
|
||||
|
||||
if (key == ChatScreen) {
|
||||
@@ -73,11 +93,16 @@ object NavigationController {
|
||||
}
|
||||
|
||||
fun openChatSession(sessionId: String) {
|
||||
queueChatNavigation(sessionId, scrollToBottom = false)
|
||||
// Opened from a list (History cards, search results, Bots): arm the
|
||||
// return so back from the chat lands back on that list instead of
|
||||
// exiting the app (Telegram chat-list parity).
|
||||
queueChatNavigation(sessionId, scrollToBottom = false, returnToList = true)
|
||||
}
|
||||
|
||||
fun openChatSessionFromNotification(sessionId: String) {
|
||||
queueChatNavigation(sessionId, scrollToBottom = true)
|
||||
// A notification open is not a list navigation: back keeps the classic
|
||||
// root-chat exit flow.
|
||||
queueChatNavigation(sessionId, scrollToBottom = true, returnToList = false)
|
||||
}
|
||||
|
||||
fun openNewChat() {
|
||||
@@ -91,8 +116,18 @@ object NavigationController {
|
||||
private fun queueChatNavigation(
|
||||
sessionId: String,
|
||||
scrollToBottom: Boolean,
|
||||
returnToList: Boolean,
|
||||
) {
|
||||
if (sessionId.isBlank()) return
|
||||
// Snapshot the origin BEFORE navigateTo resets the stack to [ChatScreen].
|
||||
val returnScreen =
|
||||
if (returnToList) {
|
||||
backStack
|
||||
?.lastOrNull()
|
||||
?.takeIf { it != ChatScreen && isPrimaryScreen(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
pendingChatNavigation =
|
||||
PendingChatNavigation(
|
||||
sessionId = sessionId,
|
||||
@@ -100,6 +135,8 @@ object NavigationController {
|
||||
requestId = ++nextChatNavigationRequestId,
|
||||
)
|
||||
navigateTo(ChatScreen)
|
||||
// Set after navigateTo so the reset itself does not disarm the return.
|
||||
chatReturnScreen = returnScreen
|
||||
}
|
||||
|
||||
fun consumePendingChatNavigation(): PendingChatNavigation? =
|
||||
@@ -113,6 +150,7 @@ object NavigationController {
|
||||
/** Clear the stack and navigate to the given screen atomically. */
|
||||
fun resetTo(screen: NavKey) {
|
||||
val stack = backStack ?: return
|
||||
chatReturnScreen = null
|
||||
stack.clear()
|
||||
stack.add(screen)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ data class ServerStoreState(
|
||||
val showTokensPerSecond: Boolean = true,
|
||||
val showModelProvider: Boolean = false,
|
||||
val keepConnectedInBackground: Boolean = false,
|
||||
// Notify when ANY session finishes while the app is backgrounded. Needs a
|
||||
// live background connection, so it implies the keep-connected lease.
|
||||
val notifySessionCompletions: Boolean = false,
|
||||
// Opt-in update channel: when true the in-app update check also considers
|
||||
// pre-release release-candidate GitHub releases, not just stable ones.
|
||||
val checkReleaseCandidateUpdates: Boolean = false,
|
||||
|
||||
@@ -871,6 +871,12 @@ object AuthManager {
|
||||
serverStore.update { it.copy(keepConnectedInBackground = enabled) }
|
||||
}
|
||||
|
||||
fun isNotifySessionCompletions(): Boolean = serverStore.getLatestState().notifySessionCompletions
|
||||
|
||||
fun setNotifySessionCompletions(enabled: Boolean) {
|
||||
serverStore.update { it.copy(notifySessionCompletions = enabled) }
|
||||
}
|
||||
|
||||
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
||||
|
||||
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
||||
|
||||
@@ -66,7 +66,9 @@ internal fun canonicalMessageOrder(
|
||||
id: String,
|
||||
sessionId: String,
|
||||
): Long? {
|
||||
val prefix = "rest-$sessionId-"
|
||||
val prefix =
|
||||
com.m57.hermescontrol.ui.chat.RestMessageId
|
||||
.sessionPrefix(sessionId)
|
||||
if (!id.startsWith(prefix)) return null
|
||||
val suffix = id.removePrefix(prefix)
|
||||
return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull()
|
||||
|
||||
@@ -17,6 +17,9 @@ import kotlinx.serialization.Serializable
|
||||
* file that lives on the *gateway host* (agent-delivered `MEDIA:` directive).
|
||||
* Null for locally-picked attachments.
|
||||
* @param source Where the attachment originated.
|
||||
* @param contentOffset For agent `MEDIA:` attachments, the char offset in the message's
|
||||
* stripped text where the directive appeared, so the renderer can place it in-flow.
|
||||
* Null means "append after the text".
|
||||
*/
|
||||
@Serializable
|
||||
data class Attachment(
|
||||
@@ -26,6 +29,7 @@ data class Attachment(
|
||||
val size: Long = 0,
|
||||
val gatewayUrl: String? = null,
|
||||
val source: AttachmentSource = AttachmentSource.LOCAL,
|
||||
val contentOffset: Int? = null,
|
||||
) {
|
||||
val mediaKind: MediaKind
|
||||
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Body for POST api/credentials/pool. `apiKey` serializes as `api_key`. */
|
||||
@Serializable
|
||||
data class CredentialPoolAddRequest(
|
||||
val provider: String,
|
||||
val apiKey: String,
|
||||
val label: String? = null,
|
||||
)
|
||||
@@ -9,3 +9,8 @@ data class DebugShareResponse(
|
||||
val redacted: Boolean? = null,
|
||||
val auto_delete_seconds: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DebugShareRequest(
|
||||
val redact: Boolean? = null,
|
||||
)
|
||||
@@ -17,3 +17,19 @@ data class HookEntry(
|
||||
val approved_at: String? = null,
|
||||
val executable: Boolean? = null,
|
||||
)
|
||||
|
||||
/** Body for POST api/ops/hooks. The backend field is `approve` (writes the consent allowlist entry). */
|
||||
@Serializable
|
||||
data class HookCreateRequest(
|
||||
val event: String,
|
||||
val command: String,
|
||||
val matcher: String? = null,
|
||||
val timeout: Int? = null,
|
||||
val approve: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HookDeleteRequest(
|
||||
val event: String,
|
||||
val command: String,
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class McpServersResponse(
|
||||
@@ -19,12 +22,21 @@ data class McpServer(
|
||||
val status: String? = null,
|
||||
val error: String? = null,
|
||||
val auth: String? = null,
|
||||
val tools: List<String>? = null,
|
||||
val tools: JsonElement? = null,
|
||||
val source: String = "config",
|
||||
val plugin: String? = null,
|
||||
) {
|
||||
// #1283: plugin-provided servers are managed by their owning plugin.
|
||||
val isPluginOwned: Boolean get() = source == "plugin"
|
||||
|
||||
// Backend sends `tools` as null, a name list, or a `{include, exclude}` filter object.
|
||||
val toolCount: Int?
|
||||
get() =
|
||||
when (val t = tools) {
|
||||
is JsonArray -> t.size
|
||||
is JsonObject -> (t["include"] as? JsonArray)?.size
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -71,13 +83,15 @@ data class McpCatalogEntry(
|
||||
val url: String? = null,
|
||||
val command: String? = null,
|
||||
val args: List<String>? = null,
|
||||
val env: List<McpCatalogEnvVar>? = null,
|
||||
@SerialName("required_env") val env: List<McpCatalogEnvVar>? = null,
|
||||
val installed: Boolean = false,
|
||||
val enabled: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class McpCatalogEnvVar(
|
||||
val key: String,
|
||||
val label: String? = null,
|
||||
@SerialName("name") val key: String,
|
||||
@SerialName("prompt") val label: String? = null,
|
||||
val description: String? = null,
|
||||
val required: Boolean = false,
|
||||
)
|
||||
@@ -96,3 +110,9 @@ data class McpOAuthFlowResponse(
|
||||
val authorizationUrl: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/** Body for PUT api/mcp/servers/{name}; only the fields being changed are sent. */
|
||||
@Serializable
|
||||
data class McpServerUpdateRequest(
|
||||
val env: Map<String, String>? = null,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** One emoji reaction on a message. Tapback semantics: at most one per [author] per message. */
|
||||
data class MessageReaction(
|
||||
val emoji: String,
|
||||
val author: String,
|
||||
)
|
||||
|
||||
/** Reactions carried by a live `message.reaction` event payload (`reactions` list of maps). */
|
||||
internal fun parseMessageReactions(raw: Any?): List<MessageReaction> =
|
||||
(raw as? List<*>).orEmpty().mapNotNull { item ->
|
||||
val map = item as? Map<*, *> ?: return@mapNotNull null
|
||||
val emoji = (map["emoji"] as? String)?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
MessageReaction(emoji = emoji, author = map["author"] as? String ?: "")
|
||||
}
|
||||
|
||||
/** Reactions persisted under `display_metadata.reactions` on a REST transcript row. */
|
||||
internal fun parseMessageReactions(displayMetadata: JsonElement?): List<MessageReaction> {
|
||||
val list = (displayMetadata as? JsonObject)?.get("reactions") as? JsonArray ?: return emptyList()
|
||||
return list.mapNotNull { item ->
|
||||
val obj = item as? JsonObject ?: return@mapNotNull null
|
||||
val emoji =
|
||||
(obj["emoji"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
val author = (obj["author"] as? JsonPrimitive)?.takeIf { it.isString }?.content.orEmpty()
|
||||
MessageReaction(emoji = emoji, author = author)
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,20 @@ data class MainModelAssignment(
|
||||
data class MoaModelSlot(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
val reasoning_effort: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MoaConfigPreset(
|
||||
val reference_models: List<MoaModelSlot>,
|
||||
val aggregator: MoaModelSlot,
|
||||
val reference_temperature: Double = 0.7,
|
||||
val aggregator_temperature: Double = 0.3,
|
||||
// null = omitted from API calls (provider default); the backend sends null when unset.
|
||||
val reference_temperature: Double? = null,
|
||||
val aggregator_temperature: Double? = null,
|
||||
val reference_timeout: Double? = null,
|
||||
val degraded_reference_policy: String = "loud",
|
||||
val fanout: String? = null,
|
||||
val max_tokens: Int = 4096,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
@@ -44,8 +50,12 @@ data class MoaConfigResponse(
|
||||
val presets: Map<String, MoaConfigPreset> = emptyMap(),
|
||||
val reference_models: List<MoaModelSlot> = emptyList(),
|
||||
val aggregator: MoaModelSlot = MoaModelSlot("", ""),
|
||||
val reference_temperature: Double = 0.7,
|
||||
val aggregator_temperature: Double = 0.3,
|
||||
// null = omitted from API calls (provider default); the backend sends null when unset.
|
||||
val reference_temperature: Double? = null,
|
||||
val aggregator_temperature: Double? = null,
|
||||
val reference_timeout: Double? = null,
|
||||
val degraded_reference_policy: String = "loud",
|
||||
val fanout: String? = null,
|
||||
val max_tokens: Int = 4096,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* OAuth provider catalog entry returned by `GET /api/providers/oauth`.
|
||||
@@ -10,7 +20,8 @@ import kotlinx.serialization.Serializable
|
||||
* id, name, flow ("pkce" | "device_code" | "external"), cli_command,
|
||||
* docs_url, disconnect_hint, disconnect_command, disconnectable, status.
|
||||
*
|
||||
* `expires_at` in the status comes back as an ISO timestamp string.
|
||||
* `expires_at` / `last_refresh` come back as an ISO string or a numeric epoch depending on the
|
||||
* credential source, so they decode through [LenientStringSerializer].
|
||||
*/
|
||||
@Serializable
|
||||
data class OAuthProvider(
|
||||
@@ -31,9 +42,13 @@ data class OAuthProviderStatus(
|
||||
val source: String? = null,
|
||||
@SerialName("source_label") val sourceLabel: String? = null,
|
||||
@SerialName("token_preview") val tokenPreview: String? = null,
|
||||
@SerialName("expires_at") val expiresAt: String? = null,
|
||||
@SerialName("expires_at")
|
||||
@Serializable(with = LenientStringSerializer::class)
|
||||
val expiresAt: String? = null,
|
||||
@SerialName("has_refresh_token") val hasRefreshToken: Boolean? = null,
|
||||
@SerialName("last_refresh") val lastRefresh: String? = null,
|
||||
@SerialName("last_refresh")
|
||||
@Serializable(with = LenientStringSerializer::class)
|
||||
val lastRefresh: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@@ -98,3 +113,25 @@ data class OAuthCancelResponse(
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/** Decodes a JSON string or number (e.g. epoch millis) into its string form; null stays null. */
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
object LenientStringSerializer : KSerializer<String?> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("LenientString", PrimitiveKind.STRING)
|
||||
|
||||
override fun deserialize(decoder: Decoder): String? {
|
||||
if (decoder is JsonDecoder) {
|
||||
val element = decoder.decodeJsonElement()
|
||||
return if (element is JsonPrimitive && element !is JsonNull) element.content else null
|
||||
}
|
||||
return decoder.decodeString()
|
||||
}
|
||||
|
||||
override fun serialize(
|
||||
encoder: Encoder,
|
||||
value: String?,
|
||||
) {
|
||||
if (value == null) encoder.encodeNull() else encoder.encodeString(value)
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,10 @@ data class SessionRenameRequest(
|
||||
@Serializable
|
||||
data class BulkDeleteRequest(
|
||||
val ids: List<String>,
|
||||
val delete_all: Boolean = false,
|
||||
// Destructive POSTs read the profile off the BODY (not the ?profile=
|
||||
// query rewrite) — an unnamed bulk delete is refused with 400 on a
|
||||
// multi-profile backend. Null stays valid for single-profile hosts.
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -134,5 +137,13 @@ data class BulkDeleteResponse(
|
||||
|
||||
@Serializable
|
||||
data class PruneRequest(
|
||||
val days: Int,
|
||||
// Server contract: `older_than_days` (hermes_cli/web_models.py SessionPrune).
|
||||
// The old `days` key was unknown to the backend, so the user's choice was
|
||||
// silently dropped and the server pruned its own 90-day default.
|
||||
@SerialName("older_than_days") val olderThanDays: Int,
|
||||
// Destructive POSTs read the profile off the BODY (not the ?profile= query
|
||||
// rewrite): on a multi-profile backend an unnamed prune is refused with
|
||||
// 400 (web_routers/_common.py destructive_profile). Null stays valid for
|
||||
// single-profile hosts.
|
||||
val profile: String? = null,
|
||||
)
|
||||
@@ -30,6 +30,7 @@ data class ActiveSessionItem(
|
||||
val status: String? = null,
|
||||
@SerialName("last_active")
|
||||
val lastActive: Double? = null,
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,6 +75,10 @@ data class SessionMessage(
|
||||
val timestampText: String?
|
||||
get() = (timestamp as? JsonPrimitive)?.content
|
||||
|
||||
/** Emoji reactions persisted on this row (`display_metadata.reactions`). */
|
||||
val reactions: List<MessageReaction>
|
||||
get() = parseMessageReactions(display_metadata)
|
||||
|
||||
val tokenCount: Int?
|
||||
get() {
|
||||
val prim = token_count as? JsonPrimitive ?: return null
|
||||
|
||||
@@ -20,10 +20,12 @@ import com.m57.hermescontrol.data.model.CreateCronJobRequest
|
||||
import com.m57.hermescontrol.data.model.CreateProfileRequest
|
||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||
import com.m57.hermescontrol.data.model.CreateWebhookRequest
|
||||
import com.m57.hermescontrol.data.model.CredentialPoolAddRequest
|
||||
import com.m57.hermescontrol.data.model.CredentialPoolResponse
|
||||
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.CuratorResponse
|
||||
import com.m57.hermescontrol.data.model.DebugShareRequest
|
||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
||||
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
||||
@@ -38,6 +40,8 @@ import com.m57.hermescontrol.data.model.EnvVarUpdate
|
||||
import com.m57.hermescontrol.data.model.GatewayMigrationPlan
|
||||
import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse
|
||||
import com.m57.hermescontrol.data.model.HealthStatus
|
||||
import com.m57.hermescontrol.data.model.HookCreateRequest
|
||||
import com.m57.hermescontrol.data.model.HookDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.HookResponse
|
||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
@@ -57,6 +61,7 @@ import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest
|
||||
@@ -583,7 +588,7 @@ interface HermesApiService : KanbanApiService {
|
||||
@PUT("api/mcp/servers/{name}")
|
||||
suspend fun updateMcpServer(
|
||||
@Path("name") name: String,
|
||||
@Body body: Map<String, Any>,
|
||||
@Body body: McpServerUpdateRequest,
|
||||
): Response<McpServer>
|
||||
|
||||
@POST("api/mcp/servers/{name}/auth")
|
||||
@@ -602,7 +607,7 @@ interface HermesApiService : KanbanApiService {
|
||||
@POST("api/mcp/catalog/install")
|
||||
suspend fun installMcpCatalogEntry(
|
||||
@Body body: McpCatalogInstallRequest,
|
||||
): Response<Map<String, Any>>
|
||||
): Response<ActionResponse>
|
||||
|
||||
@GET("api/webhooks")
|
||||
suspend fun getWebhooks(): Response<WebhooksResponse>
|
||||
@@ -881,7 +886,7 @@ interface HermesApiService : KanbanApiService {
|
||||
@PUT("api/curator/paused")
|
||||
suspend fun setCuratorPaused(
|
||||
@Body body: Map<String, Boolean>,
|
||||
): Response<Map<String, Any>>
|
||||
): Response<ActionResponse>
|
||||
|
||||
@POST("api/curator/run")
|
||||
suspend fun runCurator(): Response<ActionResponse>
|
||||
@@ -918,14 +923,14 @@ interface HermesApiService : KanbanApiService {
|
||||
|
||||
@POST("api/credentials/pool")
|
||||
suspend fun addCredentialPoolEntry(
|
||||
@Body body: Map<String, String>,
|
||||
): Response<Map<String, Any>>
|
||||
@Body body: CredentialPoolAddRequest,
|
||||
): Response<ActionResponse>
|
||||
|
||||
@DELETE("api/credentials/pool/{provider}/{index}")
|
||||
suspend fun removeCredentialPoolEntry(
|
||||
@Path("provider") provider: String,
|
||||
@Path("index") index: Int,
|
||||
): Response<Map<String, Any>>
|
||||
): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Operations ─────────────────────────────────────────────
|
||||
@POST("api/ops/security-audit")
|
||||
@@ -968,7 +973,7 @@ interface HermesApiService : KanbanApiService {
|
||||
// ── Admin: Debug share ────────────────────────────────────────────
|
||||
@POST("api/ops/debug-share")
|
||||
suspend fun runDebugShare(
|
||||
@Body body: Map<String, Any>,
|
||||
@Body body: DebugShareRequest,
|
||||
): Response<DebugShareResponse>
|
||||
|
||||
// ── Admin: Checkpoints ────────────────────────────────────────────
|
||||
@@ -984,13 +989,13 @@ interface HermesApiService : KanbanApiService {
|
||||
|
||||
@POST("api/ops/hooks")
|
||||
suspend fun createHook(
|
||||
@Body body: Map<String, Any>,
|
||||
): Response<Map<String, Any>>
|
||||
@Body body: HookCreateRequest,
|
||||
): Response<ActionResponse>
|
||||
|
||||
@DELETE("api/ops/hooks")
|
||||
@HTTP(method = "DELETE", path = "api/ops/hooks", hasBody = true)
|
||||
suspend fun deleteHook(
|
||||
@Body body: Map<String, String>,
|
||||
): Response<Map<String, Any>>
|
||||
@Body body: HookDeleteRequest,
|
||||
): Response<ActionResponse>
|
||||
|
||||
// ── Admin: Action status (log viewer) ────────────────────────────
|
||||
@GET("api/actions/{name}/status")
|
||||
|
||||
@@ -33,6 +33,10 @@ object ProfileScopeInterceptor : Interceptor {
|
||||
// multi-profile host transcribes under the launch profile
|
||||
// (review, PR #1250).
|
||||
"/api/audio",
|
||||
// Curator state is per profile (config-scoped reads) and the run
|
||||
// trigger is privileged: `POST /api/curator/run` is refused with
|
||||
// 400 on a multi-profile backend without ?profile=.
|
||||
"/api/curator",
|
||||
"/api/config",
|
||||
"/api/credentials/pool",
|
||||
"/api/cron",
|
||||
|
||||
@@ -222,7 +222,10 @@ open class AppUpdateChecker(
|
||||
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
|
||||
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
|
||||
val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base URL")
|
||||
if (next.host != base.host || next.encodedPath != base.encodedPath.trimEnd('/') + "/releases") {
|
||||
val expectedReposPath = base.encodedPath.trimEnd('/') + "/releases"
|
||||
val isReposPath = next.encodedPath == expectedReposPath
|
||||
val isRepositoriesPath = REPOSITORIES_RELEASES_PATH.matches(next.encodedPath)
|
||||
if (next.host != base.host || (!isReposPath && !isRepositoriesPath)) {
|
||||
throw IOException("GitHub returned an untrusted pagination URL")
|
||||
}
|
||||
return next.toString()
|
||||
@@ -340,6 +343,7 @@ open class AppUpdateChecker(
|
||||
|
||||
const val RELEASE_LIST_PAGE_SIZE = 100
|
||||
val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
|
||||
val REPOSITORIES_RELEASES_PATH = Regex("^/repositories/[0-9]+/releases$")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,20 @@ import com.m57.hermescontrol.data.model.ConnectorCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsAccountsRemoveParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsCatalogParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicyGetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsToolsParams
|
||||
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -48,10 +62,9 @@ interface AccountConnectorRepository {
|
||||
expectedRevision: String,
|
||||
): AccountConnectorResult<ConnectorPolicy>
|
||||
|
||||
suspend fun operationRequest(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Any?
|
||||
suspend fun operationRespond(params: ConnectionRespondParams): Any?
|
||||
|
||||
suspend fun operationWake(params: ConnectorsOperationStatusParams): Any?
|
||||
|
||||
suspend fun operationStatus(
|
||||
opId: String,
|
||||
@@ -61,25 +74,8 @@ interface AccountConnectorRepository {
|
||||
}
|
||||
|
||||
class HermesAccountConnectorRepository(
|
||||
private val rpc: suspend (String, Map<String, Any>) -> Any? = { method, params ->
|
||||
val deferred = HermesWsClient.request(method, params)
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (
|
||||
e: CancellationException,
|
||||
) {
|
||||
deferred.cancel(e)
|
||||
throw e
|
||||
} finally {
|
||||
if (!deferred.isCompleted) deferred.cancel()
|
||||
}
|
||||
},
|
||||
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||
) : AccountConnectorRepository {
|
||||
private suspend fun request(
|
||||
method: String,
|
||||
params: Map<String, Any> = emptyMap(),
|
||||
) = rpc(method, params)
|
||||
|
||||
private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> =
|
||||
try {
|
||||
AccountConnectorResult.Success(block())
|
||||
@@ -97,12 +93,6 @@ class HermesAccountConnectorRepository(
|
||||
AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway."))
|
||||
}
|
||||
|
||||
private fun accountParams(params: Map<String, Any> = emptyMap()): Map<String, Any> =
|
||||
buildMap {
|
||||
putAll(params)
|
||||
AuthManager.activeProfileId.value?.let { put("profile", it) }
|
||||
}
|
||||
|
||||
private fun obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject
|
||||
|
||||
private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
@@ -116,10 +106,11 @@ class HermesAccountConnectorRepository(
|
||||
override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult =
|
||||
try {
|
||||
ConnectorParser.parseListResult(
|
||||
rpc(
|
||||
WsMethods.CONNECTORS_LIST,
|
||||
accountParams(
|
||||
mapOf("owner" to mapOf("type" to "account")),
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_LIST,
|
||||
ConnectorsListParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -146,27 +137,48 @@ class HermesAccountConnectorRepository(
|
||||
) = guarded {
|
||||
require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug))
|
||||
parseOperation(
|
||||
request(
|
||||
WsMethods.CONNECTORS_CONNECT,
|
||||
accountParams(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"connectors" to slugs,
|
||||
"reconnect" to reconnect,
|
||||
),
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_CONNECT,
|
||||
ConnectorsConnectParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
connectors = slugs,
|
||||
reconnect = reconnect,
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun operationRequest(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account"))))
|
||||
override suspend fun operationRespond(params: ConnectionRespondParams): Any? =
|
||||
caller.call(
|
||||
RpcMethods.CONNECTION_RESPOND,
|
||||
params.copy(
|
||||
owner = ConnectorOwner.account(),
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
)
|
||||
|
||||
override suspend fun operationWake(params: ConnectorsOperationStatusParams): Any? =
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_OPERATION_WAKE,
|
||||
params.copy(
|
||||
owner = ConnectorOwner.account(),
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
)
|
||||
|
||||
override suspend fun operationStatus(opId: String) =
|
||||
guarded {
|
||||
parseOperation(operationRequest(WsMethods.CONNECTORS_OPERATION_STATUS, mapOf("op_id" to opId)))
|
||||
parseOperation(
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_OPERATION_STATUS,
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = opId,
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -180,7 +192,14 @@ class HermesAccountConnectorRepository(
|
||||
|
||||
override suspend fun catalog() =
|
||||
guarded {
|
||||
obj(request(WsMethods.CONNECTORS_CATALOG, accountParams()))["connectors"]!!.jsonArray.map {
|
||||
val response =
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_CATALOG,
|
||||
ConnectorsCatalogParams(
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
)
|
||||
obj(response)["connectors"]!!.jsonArray.map {
|
||||
val row = it.jsonObject
|
||||
ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category"))
|
||||
}
|
||||
@@ -188,7 +207,14 @@ class HermesAccountConnectorRepository(
|
||||
|
||||
override suspend fun accounts() =
|
||||
guarded {
|
||||
obj(request(WsMethods.CONNECTORS_ACCOUNTS, accountParams()))["accounts"]!!.jsonArray.map { e ->
|
||||
val response =
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_ACCOUNTS,
|
||||
ConnectorsAccountsParams(
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
)
|
||||
obj(response)["accounts"]!!.jsonArray.map { e ->
|
||||
val r = e.jsonObject
|
||||
ConnectorAccount(
|
||||
r.str("connection_id"),
|
||||
@@ -209,10 +235,11 @@ class HermesAccountConnectorRepository(
|
||||
require(connectionId.isNotBlank())
|
||||
val response =
|
||||
obj(
|
||||
request(
|
||||
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||
accountParams(
|
||||
mapOf("connection_id" to connectionId),
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||
ConnectorsAccountsRemoveParams(
|
||||
connectionId = connectionId,
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -225,12 +252,16 @@ class HermesAccountConnectorRepository(
|
||||
refresh: Boolean,
|
||||
) = guarded {
|
||||
require(ConnectorRepository.isValidSlug(slug))
|
||||
val params =
|
||||
buildMap<String, Any> {
|
||||
put("slug", slug)
|
||||
if (refresh) put("refresh", true)
|
||||
}
|
||||
obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e ->
|
||||
val response =
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_TOOLS,
|
||||
ConnectorsToolsParams(
|
||||
slug = slug,
|
||||
refresh = if (refresh) true else null,
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
)
|
||||
obj(response)["tools"]!!.jsonArray.map { e ->
|
||||
val r = e.jsonObject
|
||||
ConnectorTool(
|
||||
r.str("slug"),
|
||||
@@ -245,7 +276,15 @@ class HermesAccountConnectorRepository(
|
||||
|
||||
override suspend fun policy() =
|
||||
guarded {
|
||||
val root = obj(request(WsMethods.CONNECTORS_POLICY_GET, accountParams()))
|
||||
val root =
|
||||
obj(
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_POLICY_GET,
|
||||
ConnectorsPolicyGetParams(
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
val layers = root.array("layers").map { it.jsonObject }
|
||||
val member = layers.firstOrNull { it.str("kind") == "member" }
|
||||
parsePolicy(root["effective"]!!.jsonObject).copy(
|
||||
@@ -293,13 +332,12 @@ class HermesAccountConnectorRepository(
|
||||
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
|
||||
val result =
|
||||
obj(
|
||||
request(
|
||||
WsMethods.CONNECTORS_POLICY_SET,
|
||||
accountParams(
|
||||
mapOf(
|
||||
"change" to change,
|
||||
"expected_revision" to expectedRevision,
|
||||
),
|
||||
caller.call(
|
||||
RpcMethods.CONNECTORS_POLICY_SET,
|
||||
ConnectorsPolicySetParams(
|
||||
change = ConnectorParser.toJsonElement(change) as JsonObject,
|
||||
expectedRevision = expectedRevision,
|
||||
profile = AuthManager.activeProfileId.value,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.SubscriptionStateResponse
|
||||
import com.m57.hermescontrol.data.model.SubscriptionUpgradeResponse
|
||||
import com.m57.hermescontrol.data.model.UsageBarsResponse
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.ws.contract.EmptyParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SubscriptionPreviewParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SubscriptionUpgradeParams
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -90,50 +95,47 @@ object BillingRepository {
|
||||
}
|
||||
|
||||
suspend fun getSubscriptionState(): SubscriptionStateResponse? {
|
||||
val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_STATE).await()
|
||||
val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_STATE, EmptyParams)
|
||||
return decode(result)
|
||||
}
|
||||
|
||||
suspend fun getUsageBars(): UsageBarsResponse? {
|
||||
val result = HermesWsClient.request(WsMethods.USAGE_BARS).await()
|
||||
val result = HermesWsClient.call(RpcMethods.USAGE_BARS, EmptyParams)
|
||||
return decode(result)
|
||||
}
|
||||
|
||||
suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SUBSCRIPTION_PREVIEW,
|
||||
mapOf("subscription_type_id" to subscriptionTypeId),
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SUBSCRIPTION_PREVIEW,
|
||||
SubscriptionPreviewParams(subscriptionTypeId = subscriptionTypeId),
|
||||
)
|
||||
return decode(result)
|
||||
}
|
||||
|
||||
suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SUBSCRIPTION_CHANGE,
|
||||
buildMap {
|
||||
request.subscription_type_id?.let { put("subscription_type_id", it) }
|
||||
request.cancel?.let { put("cancel", it) }
|
||||
},
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SUBSCRIPTION_CHANGE,
|
||||
SubscriptionChangeParams(
|
||||
subscriptionTypeId = request.subscription_type_id,
|
||||
cancel = request.cancel,
|
||||
),
|
||||
)
|
||||
return decode(result)
|
||||
}
|
||||
|
||||
suspend fun resumeSubscription(): SubscriptionResumeResponse? {
|
||||
val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_RESUME).await()
|
||||
val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_RESUME, EmptyParams)
|
||||
return decode(result)
|
||||
}
|
||||
|
||||
suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SUBSCRIPTION_UPGRADE,
|
||||
mapOf("subscription_type_id" to subscriptionTypeId),
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SUBSCRIPTION_UPGRADE,
|
||||
SubscriptionUpgradeParams(subscriptionTypeId = subscriptionTypeId),
|
||||
)
|
||||
return decode(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class ConnectionHealth(
|
||||
private val scope: CoroutineScope,
|
||||
private val isConnected: () -> Boolean,
|
||||
private val pingRequest: suspend (Long) -> Unit,
|
||||
private val cancelSocket: () -> Unit,
|
||||
private val pendingReply: () -> Boolean,
|
||||
private val nowMs: () -> Long = { System.nanoTime() / 1_000_000L },
|
||||
private val log: (String) -> Unit = {},
|
||||
private val debugLog: (String) -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
internal const val HEARTBEAT_INTERVAL_MS = 15_000L
|
||||
internal const val STALE_THRESHOLD_MS = 30_000L
|
||||
internal const val LIVENESS_PROBE_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var lastPongTimestamp: Long = 0L
|
||||
internal set
|
||||
|
||||
private val _lastLatencyMs = MutableStateFlow<Long?>(null)
|
||||
val lastLatencyMs: StateFlow<Long?> = _lastLatencyMs.asStateFlow()
|
||||
|
||||
val isHealthy: Boolean
|
||||
get() = isConnected() && (nowMs() - lastPongTimestamp < STALE_THRESHOLD_MS)
|
||||
|
||||
private var healthJob: Job? = null
|
||||
private var foregroundProbeJob: Job? = null
|
||||
private var transportProbeJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
healthJob?.cancel()
|
||||
lastPongTimestamp = nowMs()
|
||||
healthJob =
|
||||
scope.launch {
|
||||
while (isConnected()) {
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
if (isConnected()) {
|
||||
try {
|
||||
ping(timeoutMs = LIVENESS_PROBE_TIMEOUT_MS)
|
||||
} catch (e: Exception) {
|
||||
log("Health check ping failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (!runHealthCheckPass()) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
healthJob?.cancel()
|
||||
healthJob = null
|
||||
_lastLatencyMs.value = null
|
||||
}
|
||||
|
||||
fun onInboundFrame() {
|
||||
lastPongTimestamp = nowMs()
|
||||
}
|
||||
|
||||
suspend fun ping(timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS): Long {
|
||||
val start = nowMs()
|
||||
pingRequest(timeoutMs)
|
||||
val latency = (nowMs() - start).coerceAtLeast(0L)
|
||||
lastPongTimestamp = nowMs()
|
||||
_lastLatencyMs.value = latency
|
||||
return latency
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun setLastLatencyForTest(latency: Long?) {
|
||||
_lastLatencyMs.value = latency
|
||||
}
|
||||
|
||||
private fun runHealthCheckPass(): Boolean {
|
||||
if (!isConnected()) return false
|
||||
val staleMs = nowMs() - lastPongTimestamp
|
||||
if (staleMs <= STALE_THRESHOLD_MS) return true
|
||||
log("WebSocket stale (${staleMs / 1000}s without frames) — cancelling to trigger reconnect")
|
||||
cancelSocket()
|
||||
return false
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun forceHealthCheckForTest(staleMillis: Long) {
|
||||
lastPongTimestamp = nowMs() - staleMillis
|
||||
runHealthCheckPass()
|
||||
}
|
||||
|
||||
fun probeLivenessOnWake(deferredOnce: Boolean = false) {
|
||||
if (!isConnected()) return
|
||||
foregroundProbeJob?.cancel()
|
||||
foregroundProbeJob =
|
||||
scope.launch {
|
||||
val alive = runCatching { ping(LIVENESS_PROBE_TIMEOUT_MS) }.isSuccess
|
||||
if (alive) return@launch
|
||||
if (pendingReply() && !deferredOnce) {
|
||||
delay(3_000L)
|
||||
probeLivenessOnWake(deferredOnce = true)
|
||||
return@launch
|
||||
}
|
||||
log("Foreground liveness probe failed — cancelling socket")
|
||||
cancelSocket()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelForegroundProbe() {
|
||||
foregroundProbeJob?.cancel()
|
||||
foregroundProbeJob = null
|
||||
}
|
||||
|
||||
fun probeLivenessOnTransportChange(
|
||||
timeoutMs: Long = LIVENESS_PROBE_TIMEOUT_MS,
|
||||
onFailureAction: () -> Unit = cancelSocket,
|
||||
) {
|
||||
if (!isConnected()) return
|
||||
transportProbeJob?.cancel()
|
||||
transportProbeJob =
|
||||
scope.launch {
|
||||
debugLog("Network transport changed — probing WebSocket liveness")
|
||||
val alive = runCatching { ping(timeoutMs) }.isSuccess
|
||||
if (alive) {
|
||||
debugLog("WebSocket liveness probe succeeded on new transport")
|
||||
return@launch
|
||||
}
|
||||
log("Transport change liveness probe failed — cancelling socket")
|
||||
onFailureAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ package com.m57.hermescontrol.data.ws
|
||||
import com.m57.hermescontrol.data.model.ConnectorConnectResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorError
|
||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams
|
||||
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
@@ -42,19 +48,7 @@ interface ConnectorRepository {
|
||||
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
|
||||
*/
|
||||
class HermesConnectorRepository(
|
||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
||||
val deferred = HermesWsClient.request(method, params)
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (e: CancellationException) {
|
||||
deferred.cancel(e)
|
||||
throw e
|
||||
} finally {
|
||||
if (!deferred.isCompleted) {
|
||||
deferred.cancel()
|
||||
}
|
||||
}
|
||||
},
|
||||
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||
) : ConnectorRepository {
|
||||
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
|
||||
if (sessionId.isBlank()) {
|
||||
@@ -64,8 +58,8 @@ class HermesConnectorRepository(
|
||||
}
|
||||
|
||||
return try {
|
||||
val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId))
|
||||
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
|
||||
val params = ConnectorsListParams(owner = ConnectorOwner.session(sessionId))
|
||||
val result = caller.call(RpcMethods.CONNECTORS_LIST, params)
|
||||
ConnectorParser.parseListResult(result)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -107,12 +101,12 @@ class HermesConnectorRepository(
|
||||
|
||||
return try {
|
||||
val params =
|
||||
mapOf(
|
||||
"owner" to ConnectorRepository.sessionOwner(sessionId),
|
||||
"connectors" to connectors,
|
||||
"reconnect" to reconnect,
|
||||
ConnectorsConnectParams(
|
||||
owner = ConnectorOwner.session(sessionId),
|
||||
connectors = connectors,
|
||||
reconnect = reconnect,
|
||||
)
|
||||
val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
|
||||
val result = caller.call(RpcMethods.CONNECTORS_CONNECT, params)
|
||||
ConnectorParser.parseConnectResult(result)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.model.parseMessageReactions
|
||||
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@@ -333,6 +334,20 @@ object EventParser {
|
||||
WsEvent.ReactionEvent(kind)
|
||||
}
|
||||
|
||||
"message.reaction" -> {
|
||||
val rowId = (payload?.get("row_id") as? Number)?.toLong()
|
||||
if (rowId == null) {
|
||||
WsEvent.Unknown(rawJson)
|
||||
} else {
|
||||
WsEvent.MessageReactionUpdated(
|
||||
rowId = rowId,
|
||||
reactions = parseMessageReactions(payload["reactions"]),
|
||||
role = payload["role"] as? String ?: "",
|
||||
sessionId = sessionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"approval.request" -> {
|
||||
val command = payload?.get("command") as? String
|
||||
val description = payload?.get("description") as? String
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,8 @@ import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
@@ -16,13 +18,8 @@ class ModelOptionsRepository(
|
||||
private val connected: () -> Boolean = {
|
||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||
},
|
||||
private val request: suspend (Map<String, Any>) -> Any? = { params ->
|
||||
val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params)
|
||||
try {
|
||||
deferred.await()
|
||||
} finally {
|
||||
if (!deferred.isCompleted) deferred.cancel()
|
||||
}
|
||||
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
|
||||
},
|
||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||
@@ -32,9 +29,7 @@ class ModelOptionsRepository(
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (connected()) {
|
||||
try {
|
||||
val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false))
|
||||
// request() returns raw JsonElement, unlike the pushed-event Map representation.
|
||||
val json = result as? JsonElement ?: error("Invalid model.options result")
|
||||
val json = request(ModelOptionsParams(refresh = refresh, includeUnconfigured = false))
|
||||
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
|
||||
} catch (e: CancellationException) {
|
||||
// A disconnected socket may cancel the independent RPC deferred. Only fall back
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||
import com.m57.hermescontrol.data.ws.contract.PluginsManageParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
@@ -11,23 +13,14 @@ import kotlinx.serialization.json.decodeFromJsonElement
|
||||
* Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC.
|
||||
*/
|
||||
class PluginManageRepository(
|
||||
private val rpcRequest: suspend (String, Map<String, Any?>) -> Any? = { method, params ->
|
||||
val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" })
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (e: CancellationException) {
|
||||
deferred.cancel(e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||
) {
|
||||
suspend fun listPlugins(profile: String? = null): List<AgentPluginRow> {
|
||||
val params = mutableMapOf<String, Any?>("action" to "list")
|
||||
if (!profile.isNullOrBlank()) {
|
||||
params["profile"] = profile
|
||||
}
|
||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
|
||||
val element = result.toJsonElement()
|
||||
val element =
|
||||
caller.call(
|
||||
RpcMethods.PLUGINS_MANAGE,
|
||||
PluginsManageParams(action = "list", profile = profile.orNullIfBlank()),
|
||||
)
|
||||
val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList()
|
||||
return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement)
|
||||
}
|
||||
@@ -38,15 +31,15 @@ class PluginManageRepository(
|
||||
profile: String? = null,
|
||||
): PluginsManageSettingsResult {
|
||||
val params =
|
||||
mutableMapOf<String, Any?>(
|
||||
"action" to "settings",
|
||||
"key" to key,
|
||||
"values" to JsonObject(values),
|
||||
PluginsManageParams(
|
||||
action = "settings",
|
||||
key = key,
|
||||
values = JsonObject(values),
|
||||
profile = profile.orNullIfBlank(),
|
||||
)
|
||||
if (!profile.isNullOrBlank()) {
|
||||
params["profile"] = profile
|
||||
}
|
||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
|
||||
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result.toJsonElement())
|
||||
val result = caller.call(RpcMethods.PLUGINS_MANAGE, params)
|
||||
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result)
|
||||
}
|
||||
|
||||
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||
import com.m57.hermescontrol.data.ws.contract.PluginsManageParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
@@ -14,18 +17,10 @@ data class PluginRemovalResult(
|
||||
|
||||
/** Removes a user-installed plugin through the gateway's canonical plugin manager. */
|
||||
class PluginRemovalRepository(
|
||||
private val rpcRequest: suspend (String, Map<String, Any>) -> Any? = { method, params ->
|
||||
val deferred = HermesWsClient.request(method, params)
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (e: CancellationException) {
|
||||
deferred.cancel(e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||
) {
|
||||
suspend fun remove(name: String): PluginRemovalResult {
|
||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name))
|
||||
return Json.decodeFromJsonElement<PluginRemovalResult>(result.toJsonElement())
|
||||
val result = caller.call(RpcMethods.PLUGINS_MANAGE, PluginsManageParams(action = "remove", name = name))
|
||||
return Json.decodeFromJsonElement<PluginRemovalResult>(result)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws
|
||||
import com.m57.hermescontrol.data.model.ProjectInfo
|
||||
import com.m57.hermescontrol.data.model.ProjectsListResponse
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||
import com.m57.hermescontrol.data.ws.contract.ProjectsListParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@@ -17,14 +21,12 @@ class HermesProjectsSource(
|
||||
// Only ask a live socket: an offline request would be queued and force a reconnect on
|
||||
// every refresh. The view model refetches when the connection comes back.
|
||||
private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED },
|
||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
||||
HermesWsClient.request(method, params).await()
|
||||
},
|
||||
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||
) : ProjectsSource {
|
||||
override suspend fun fetchProjects(): List<ProjectInfo>? {
|
||||
if (!isConnected()) return null
|
||||
return try {
|
||||
val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap()))
|
||||
val element = caller.call(RpcMethods.PROJECTS_LIST, ProjectsListParams)
|
||||
if (element is JsonObject && element.containsKey("projects")) {
|
||||
OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
internal class ReconnectPolicy(
|
||||
private val random: () -> Double = { Math.random() },
|
||||
) {
|
||||
@Volatile
|
||||
var currentBackoff: Long = INITIAL_BACKOFF_MS
|
||||
private var initialBackoffMs: Long = INITIAL_BACKOFF_MS
|
||||
private var testBackoffOverrideMs: Long? = null
|
||||
|
||||
fun nextDelayMs(): Long {
|
||||
val override = testBackoffOverrideMs
|
||||
if (override != null) return override
|
||||
val ceiling = currentBackoff
|
||||
if (ceiling <= 0L) return 0L
|
||||
return (random() * ceiling).toLong().coerceAtLeast(BACKOFF_BASE_MS).coerceAtMost(ceiling)
|
||||
}
|
||||
|
||||
fun advance() {
|
||||
currentBackoff = (currentBackoff * BACKOFF_MULTIPLIER).toLong().coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
currentBackoff = initialBackoffMs
|
||||
}
|
||||
|
||||
fun setBackoffForTest(initialMillis: Long) {
|
||||
initialBackoffMs = initialMillis
|
||||
currentBackoff = initialMillis
|
||||
testBackoffOverrideMs = initialMillis
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val INITIAL_BACKOFF_MS = 1_000L
|
||||
private const val BACKOFF_BASE_MS = 300L
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Tracks session event sequence watermarks and orchestrates event replay on reconnect.
|
||||
*
|
||||
* Extracted from [HermesWsClient] (issue #1016, issue #1376).
|
||||
*/
|
||||
internal class ReplayTracker(
|
||||
private val scope: CoroutineScope,
|
||||
private val fetchEventsSince: suspend (SessionEventsSinceParams) -> SessionEventsSinceResult,
|
||||
private val emitEvent: (WsEvent) -> Unit,
|
||||
private val resolveStoredSessionId: (String?) -> String? = { it },
|
||||
private val onReplayFailure: (String, Exception) -> Unit = { _, _ -> },
|
||||
) {
|
||||
private val lastSeenSeq = ConcurrentHashMap<String, Int>()
|
||||
|
||||
@Volatile
|
||||
private var replayEpoch: String? = null
|
||||
|
||||
@Volatile
|
||||
private var replayInFlight: Boolean = false
|
||||
|
||||
private val replayHold = ConcurrentHashMap<String, MutableList<Pair<Int?, WsEvent>>>()
|
||||
|
||||
@Volatile
|
||||
private var replayJob: Job? = null
|
||||
|
||||
/**
|
||||
* Synchronously arms the hold buffers for all known sessions and launches replay in [scope].
|
||||
* Arming synchronously ensures racing live frames are held instead of advancing watermarks.
|
||||
*/
|
||||
fun triggerReplay() {
|
||||
if (lastSeenSeq.isEmpty() || replayInFlight) return
|
||||
replayInFlight = true
|
||||
val sessionsToReplay = lastSeenSeq.keys().toList()
|
||||
for (sid in sessionsToReplay) {
|
||||
replayHold[sid] = Collections.synchronizedList(mutableListOf())
|
||||
}
|
||||
replayJob?.cancel()
|
||||
replayJob =
|
||||
scope.launch {
|
||||
fetchReplay(sessionsToReplay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates replay epoch from gateway.ready.
|
||||
* When [epoch] is non-empty and changes from a previous non-null value, resets sequence watermarks.
|
||||
*/
|
||||
fun updateEpoch(epoch: String?) {
|
||||
if (!epoch.isNullOrEmpty()) {
|
||||
if (replayEpoch != null && replayEpoch != epoch) {
|
||||
lastSeenSeq.clear()
|
||||
}
|
||||
replayEpoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an incoming event is a duplicate against the current watermark for [sessionId].
|
||||
* Returns true if [seq] <= watermark.
|
||||
*/
|
||||
fun isDuplicate(
|
||||
sessionId: String,
|
||||
seq: Int,
|
||||
): Boolean {
|
||||
val prev = lastSeenSeq[sessionId] ?: 0
|
||||
return seq <= prev
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an incoming live event with sequence tracking.
|
||||
* Evaluates [eventFactory] only after checking for duplicates.
|
||||
* If duplicate, drops it without calling [eventFactory].
|
||||
* If replay is in flight for [sessionId], buffers it in replayHold.
|
||||
* Otherwise advances watermark and emits the event.
|
||||
*/
|
||||
fun acceptLiveEvent(
|
||||
sessionId: String,
|
||||
seq: Int,
|
||||
eventFactory: () -> WsEvent,
|
||||
) {
|
||||
if (isDuplicate(sessionId, seq)) {
|
||||
return
|
||||
}
|
||||
val finalEvent = decorateEvent(eventFactory())
|
||||
if (replayInFlight && replayHold.containsKey(sessionId)) {
|
||||
replayHold[sessionId]?.add(seq to finalEvent)
|
||||
return
|
||||
}
|
||||
lastSeenSeq[sessionId] = seq
|
||||
emitEvent(finalEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an incoming live event with sequence tracking.
|
||||
* If duplicate, drops it.
|
||||
* If replay is in flight for [sessionId], buffers it in replayHold.
|
||||
* Otherwise advances watermark and emits the event.
|
||||
*/
|
||||
fun acceptLiveEvent(
|
||||
sessionId: String,
|
||||
seq: Int,
|
||||
event: WsEvent,
|
||||
) {
|
||||
acceptLiveEvent(sessionId, seq) { event }
|
||||
}
|
||||
|
||||
private fun decorateEvent(event: WsEvent): WsEvent =
|
||||
if (event is WsEvent.MessageComplete) {
|
||||
event.copy(
|
||||
storedSessionId = resolveStoredSessionId(event.sessionId),
|
||||
)
|
||||
} else {
|
||||
event
|
||||
}
|
||||
|
||||
private suspend fun fetchReplay(sessionsToReplay: List<String> = lastSeenSeq.keys().toList()) {
|
||||
try {
|
||||
for (sid in sessionsToReplay) {
|
||||
val lastSeen = lastSeenSeq[sid] ?: continue
|
||||
try {
|
||||
val res =
|
||||
fetchEventsSince(
|
||||
SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen),
|
||||
)
|
||||
|
||||
val epoch = res.epoch
|
||||
val epochChanged =
|
||||
!epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
|
||||
val truncated = res.truncated == true
|
||||
val latestSeq = res.latestSeq
|
||||
|
||||
if (epochChanged) replayEpoch = epoch
|
||||
|
||||
if (epochChanged || truncated) {
|
||||
// Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
|
||||
// Partial replay would silently hole the transcript — fast-forward watermark
|
||||
// and request full transcript resync.
|
||||
if (epochChanged) lastSeenSeq.clear()
|
||||
if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
|
||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||
continue
|
||||
}
|
||||
val eventsList = res.events
|
||||
if (eventsList != null) {
|
||||
for (element in eventsList) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val eventMap = (element as? JsonObject)?.toAny() as? Map<String, Any?> ?: continue
|
||||
val eventSeq = (eventMap["seq"] as? Number)?.toInt()
|
||||
val eventSid = (eventMap["session_id"] as? String) ?: sid
|
||||
if (eventSeq != null && eventSid.isNotEmpty()) {
|
||||
val prev = lastSeenSeq[eventSid] ?: 0
|
||||
if (eventSeq <= prev) continue
|
||||
lastSeenSeq[eventSid] = eventSeq
|
||||
}
|
||||
val parsedEvent = EventParser.parseParams(eventMap)
|
||||
val finalEvent = decorateEvent(parsedEvent)
|
||||
emitEvent(finalEvent)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onReplayFailure(sid, e)
|
||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
for (sid in sessionsToReplay) {
|
||||
val held = replayHold.remove(sid)
|
||||
if (held != null) {
|
||||
synchronized(held) {
|
||||
for ((heldSeq, heldEvent) in held) {
|
||||
if (heldSeq != null) {
|
||||
val prev = lastSeenSeq[sid] ?: 0
|
||||
if (heldSeq <= prev) continue
|
||||
lastSeenSeq[sid] = heldSeq
|
||||
}
|
||||
emitEvent(heldEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
replayInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete teardown matching disconnect (issue #1016).
|
||||
* Cancels any active replay job and clears all holds, watermarks, and epoch.
|
||||
*/
|
||||
fun clear() {
|
||||
lastSeenSeq.clear()
|
||||
replayHold.clear()
|
||||
replayEpoch = null
|
||||
replayJob?.cancel()
|
||||
replayJob = null
|
||||
replayInFlight = false
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun getSeqWatermarks(): Map<String, Int> = HashMap(lastSeenSeq)
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun setSeqWatermark(
|
||||
sessionId: String,
|
||||
seq: Int,
|
||||
) {
|
||||
lastSeenSeq[sessionId] = seq
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun clearSeqWatermarks() {
|
||||
lastSeenSeq.clear()
|
||||
replayHold.clear()
|
||||
replayEpoch = null
|
||||
replayInFlight = false
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun isReplayInFlight(): Boolean = replayInFlight
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun setReplayEpochForTest(epoch: String?) {
|
||||
replayEpoch = epoch
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal suspend fun fetchReplayForTest() {
|
||||
fetchReplay()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Socket-independent JSON-RPC 2.0 request correlation and response dispatcher.
|
||||
* Manages request IDs, in-flight pending calls, timeouts, cancellation cleanup,
|
||||
* and server response frame formatting.
|
||||
*/
|
||||
internal class RpcChannel(
|
||||
private val scope: CoroutineScope,
|
||||
private val json: Json,
|
||||
private val sendRequest: (method: String, params: Map<String, Any>, onSent: ((String) -> Unit)?) -> String,
|
||||
private val sendFrame: (String) -> Boolean,
|
||||
private val removeQueuedMessage: (String) -> Unit,
|
||||
private val onIdleCheck: () -> Unit,
|
||||
private val logRejected: (method: String, id: String) -> Unit = { _, _ -> },
|
||||
private val outboundLock: Any = Any(),
|
||||
private val onResult: (String) -> Unit = {},
|
||||
private val onError: (String) -> Unit = {},
|
||||
private val resolveStoredSessionId: (String?) -> String? = { it },
|
||||
private val logParseFailure: (Exception) -> Unit = {},
|
||||
private val decorateParams: (String, Map<String, Any>) -> Map<String, Any> = { _, p -> p },
|
||||
) {
|
||||
/** A single in-flight [request] awaiting its RPC result/error. */
|
||||
private data class PendingCall(
|
||||
val method: String,
|
||||
val deferred: CompletableDeferred<Any?>,
|
||||
var timeoutJob: Job? = null,
|
||||
val suppressErrorEvent: Boolean = false,
|
||||
)
|
||||
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val pendingCalls = ConcurrentHashMap<String, PendingCall>()
|
||||
private val capabilityRequestIds = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun nextId(): String = requestId.incrementAndGet().toString()
|
||||
|
||||
fun hasPendingCalls(): Boolean = pendingCalls.isNotEmpty()
|
||||
|
||||
fun pendingCallIdsForTest(): Set<String> = pendingCalls.keys.toSet()
|
||||
|
||||
fun pendingCallTimeoutForTest(id: String): Job? = pendingCalls[id]?.timeoutJob
|
||||
|
||||
fun isErrorEventSuppressed(id: String): Boolean = pendingCalls[id]?.suppressErrorEvent == true
|
||||
|
||||
fun registerCapabilityRequest(id: String) {
|
||||
capabilityRequestIds.add(id)
|
||||
}
|
||||
|
||||
fun clearCapabilityRequests() {
|
||||
capabilityRequestIds.clear()
|
||||
}
|
||||
|
||||
fun consumeCapabilityResponse(event: WsEvent): Boolean {
|
||||
val id =
|
||||
when (event) {
|
||||
is WsEvent.RpcResult -> event.id
|
||||
is WsEvent.RpcError -> event.id
|
||||
else -> return false
|
||||
}
|
||||
val isCapabilityResponse =
|
||||
synchronized(outboundLock) {
|
||||
capabilityRequestIds.remove(id)
|
||||
}
|
||||
if (!isCapabilityResponse) return false
|
||||
|
||||
removeQueuedMessage(id)
|
||||
return true
|
||||
}
|
||||
|
||||
fun createRequest(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): JsonRpcRequest {
|
||||
val id = nextId()
|
||||
val decorated = decorateParams(method, params)
|
||||
return JsonRpcRequest(
|
||||
id = id,
|
||||
method = method,
|
||||
params = decorated.mapValues { it.value.toJsonElement() },
|
||||
)
|
||||
}
|
||||
|
||||
fun encodeRequest(request: JsonRpcRequest): String = json.encodeToString(request)
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request that expects a result and returns a [CompletableDeferred] for it.
|
||||
*/
|
||||
fun request(
|
||||
method: String,
|
||||
params: Map<String, Any> = emptyMap(),
|
||||
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
|
||||
suppressErrorEvent: Boolean = false,
|
||||
): CompletableDeferred<Any?> {
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
val id =
|
||||
sendRequest(method, params) { reqId ->
|
||||
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
||||
}
|
||||
deferred.invokeOnCompletion { cause ->
|
||||
if (cause is CancellationException) {
|
||||
pendingCalls.remove(id)?.let { call ->
|
||||
call.timeoutJob?.cancel()
|
||||
removeQueuedMessage(id)
|
||||
onIdleCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arm the per-request timeout (fires if the server never answers).
|
||||
pendingCalls[id]?.timeoutJob =
|
||||
scope.launch {
|
||||
delay(timeoutMs)
|
||||
resolvePending(id, null, JsonRpcError(-1, "Request timed out: $method"))
|
||||
}
|
||||
return deferred
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-suspending typed twin of [request] for callers that must enqueue inside a
|
||||
* non-suspending owner check and await the raw result themselves.
|
||||
*/
|
||||
fun <P, R> requestTyped(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
|
||||
suppressErrorEvent: Boolean = false,
|
||||
): CompletableDeferred<Any?> = request(method.name, encodeParams(method, params), timeoutMs, suppressErrorEvent)
|
||||
|
||||
/**
|
||||
* Send a typed JSON-RPC request and await its deserialized result.
|
||||
*/
|
||||
suspend fun <P, R> call(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
timeoutMs: Long = HermesWsClient.REQUEST_TIMEOUT_MS,
|
||||
suppressErrorEvent: Boolean = false,
|
||||
): R = awaitResult(method, requestTyped(method, params, timeoutMs, suppressErrorEvent))
|
||||
|
||||
suspend fun <P, R> awaitResult(
|
||||
method: RpcMethod<P, R>,
|
||||
deferred: CompletableDeferred<Any?>,
|
||||
): R {
|
||||
val result =
|
||||
try {
|
||||
deferred.await()
|
||||
} catch (e: CancellationException) {
|
||||
deferred.cancel(e)
|
||||
throw e
|
||||
}
|
||||
val element = result.toJsonElement()
|
||||
return json.decodeFromJsonElement(method.result, element)
|
||||
}
|
||||
|
||||
fun <P, R> encodeParams(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
): JsonObject {
|
||||
val encoded = json.encodeToJsonElement(method.params, params)
|
||||
require(encoded is JsonObject) {
|
||||
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
/** Complete (or fail) a single pending call and cancel its timer. */
|
||||
fun resolvePending(
|
||||
id: String,
|
||||
result: Any?,
|
||||
error: JsonRpcError?,
|
||||
) {
|
||||
val call = pendingCalls.remove(id) ?: return
|
||||
removeQueuedMessage(id)
|
||||
call.timeoutJob?.cancel()
|
||||
if (error != null) {
|
||||
call.deferred.completeExceptionally(
|
||||
HermesWsClient.HermesRpcException(
|
||||
message = error.message,
|
||||
code = error.code,
|
||||
data = error.data,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
call.deferred.complete(result)
|
||||
}
|
||||
onIdleCheck()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail and clear every in-flight [request].
|
||||
*/
|
||||
fun rejectAllPending(
|
||||
error: HermesWsClient.HermesRpcException =
|
||||
HermesWsClient.HermesRpcException("Connection lost — request cancelled"),
|
||||
) {
|
||||
if (pendingCalls.isEmpty()) return
|
||||
val snapshot = pendingCalls.toList()
|
||||
pendingCalls.clear()
|
||||
for ((id, call) in snapshot) {
|
||||
removeQueuedMessage(id)
|
||||
logRejected(call.method, id)
|
||||
call.timeoutJob?.cancel()
|
||||
call.deferred.completeExceptionally(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer a gateway-originated JSON-RPC request.
|
||||
*/
|
||||
fun respondToServerRequest(
|
||||
id: String,
|
||||
result: JsonElement,
|
||||
): Boolean {
|
||||
if (id.isBlank()) return false
|
||||
val frameJson =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id)
|
||||
put("result", result)
|
||||
}.toString()
|
||||
return sendFrame(frameJson)
|
||||
}
|
||||
|
||||
/** Answer a gateway-originated request with a standard JSON-RPC error. */
|
||||
fun respondToServerRequestError(
|
||||
id: String,
|
||||
code: Int,
|
||||
message: String,
|
||||
): Boolean {
|
||||
if (id.isBlank()) return false
|
||||
val frameJson =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id)
|
||||
put(
|
||||
"error",
|
||||
buildJsonObject {
|
||||
put("code", code)
|
||||
put("message", message)
|
||||
},
|
||||
)
|
||||
}.toString()
|
||||
return sendFrame(frameJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming WebSocket text frame: JSON decoding, request correlation,
|
||||
* server request dispatch, sequence deduplication via [replayTracker], and event emission.
|
||||
*/
|
||||
fun handleIncomingFrame(
|
||||
text: String,
|
||||
replayTracker: ReplayTracker,
|
||||
emitEvent: (WsEvent) -> Unit,
|
||||
) {
|
||||
val event =
|
||||
try {
|
||||
val rpc = json.decodeFromString<JsonRpcResponse>(text)
|
||||
// Resolve pending request() calls with the RAW JsonElement from
|
||||
// rpc.result, BEFORE EventParser.parse() converts it via toAny().
|
||||
// This preserves integer/float type fidelity needed by callers
|
||||
// that re-deserialize the result into typed data classes.
|
||||
val rpcId = rpc.id
|
||||
if (rpcId != null && rpc.error == null && rpc.result != null) {
|
||||
onResult(rpcId)
|
||||
removeQueuedMessage(rpcId)
|
||||
resolvePending(rpcId, rpc.result, null)
|
||||
}
|
||||
|
||||
// Resume/replay responses carry still-open server requests
|
||||
// separately from the event ring. Re-deliver them through the
|
||||
// same generic path before exposing the RPC result.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val openRequests =
|
||||
(rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List<Map<*, *>>
|
||||
openRequests?.forEach { openRequest ->
|
||||
val openId = openRequest["id"] as? String ?: return@forEach
|
||||
val method = openRequest["method"] as? String ?: return@forEach
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val params = openRequest["params"] as? Map<String, Any?> ?: emptyMap()
|
||||
emitEvent(WsEvent.ServerRequest(openId, method, params, replayed = true))
|
||||
}
|
||||
|
||||
if (rpc.id == null) {
|
||||
// Issue #1163: inspect scalars, not a throwaway copy of the whole params tree.
|
||||
val sid = rpc.params?.eventSessionId()
|
||||
val seq = ((rpc.params?.get("seq") as? JsonPrimitive)?.toAny() as? Number)?.toInt()
|
||||
|
||||
if (!sid.isNullOrBlank() && seq != null) {
|
||||
replayTracker.acceptLiveEvent(sid, seq) {
|
||||
EventParser.parse(rpc, text)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val parsed = EventParser.parse(rpc, text)
|
||||
if (parsed is WsEvent.MessageComplete) {
|
||||
parsed.copy(
|
||||
storedSessionId = resolveStoredSessionId(parsed.sessionId),
|
||||
)
|
||||
} else {
|
||||
parsed
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logParseFailure(e)
|
||||
WsEvent.Unknown(text)
|
||||
}
|
||||
if (consumeCapabilityResponse(event)) return
|
||||
when (event) {
|
||||
is WsEvent.RpcResult -> {
|
||||
onResult(event.id)
|
||||
removeQueuedMessage(event.id)
|
||||
resolvePending(event.id, event.result, null)
|
||||
}
|
||||
|
||||
is WsEvent.RpcError -> {
|
||||
onError(event.id)
|
||||
if (pendingCalls[event.id]?.suppressErrorEvent == true) {
|
||||
// Opt-in suppression: the caller already handles the
|
||||
// failure through the CompletableDeferred, so the event
|
||||
// copy would only surface a duplicate UI banner for
|
||||
// optional features (e.g. "subagent.list" on gateways
|
||||
// without the method, issue #1089). Default request()
|
||||
// behavior still emits the event for shared consumers.
|
||||
resolvePending(event.id, null, event.error)
|
||||
return
|
||||
}
|
||||
removeQueuedMessage(event.id)
|
||||
resolvePending(event.id, null, event.error)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
emitEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.data.ws
|
||||
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
|
||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -148,8 +150,8 @@ object SessionLiveStatusDecoder {
|
||||
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
|
||||
*/
|
||||
class HermesSessionLiveStatusSource(
|
||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
||||
HermesWsClient.request(method, params).await()
|
||||
private val rpcRequest: suspend () -> Any? = {
|
||||
HermesWsClient.call(RpcMethods.SESSION_ACTIVE_LIST, SessionActiveListParams())
|
||||
},
|
||||
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
|
||||
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
|
||||
@@ -159,7 +161,7 @@ class HermesSessionLiveStatusSource(
|
||||
|
||||
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
|
||||
try {
|
||||
val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap())
|
||||
val raw = rpcRequest()
|
||||
SessionLiveStatusDecoder.decodeSnapshot(raw)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.ws
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.model.SubagentListResponse
|
||||
import com.m57.hermescontrol.data.model.SubagentTailResponse
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SubagentTailParams
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
@@ -33,12 +36,11 @@ object SubagentRepository {
|
||||
if (sessionId.isBlank()) return null
|
||||
return try {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SUBAGENT_LIST,
|
||||
mapOf("session_id" to sessionId),
|
||||
suppressErrorEvent = true,
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SUBAGENT_LIST,
|
||||
SessionIdParams(sessionId),
|
||||
suppressErrorEvent = true,
|
||||
)
|
||||
decode<SubagentListResponse>(result)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
|
||||
@@ -55,24 +57,15 @@ object SubagentRepository {
|
||||
suspend fun tailSubagent(
|
||||
sessionId: String,
|
||||
subagentId: String,
|
||||
maxBytes: Int = 16384,
|
||||
): SubagentTailResponse? {
|
||||
if (subagentId.isBlank()) return null
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"subagent_id" to subagentId,
|
||||
"max_bytes" to maxBytes,
|
||||
)
|
||||
if (sessionId.isNotBlank()) {
|
||||
params["session_id"] = sessionId
|
||||
}
|
||||
// Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379).
|
||||
if (subagentId.isBlank() || sessionId.isBlank()) return null
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SUBAGENT_TAIL,
|
||||
params,
|
||||
suppressErrorEvent = true,
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SUBAGENT_TAIL,
|
||||
SubagentTailParams(sessionId, subagentId),
|
||||
suppressErrorEvent = true,
|
||||
)
|
||||
return decode<SubagentTailResponse>(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import com.m57.hermescontrol.data.model.VaultSourceSetResponse
|
||||
import com.m57.hermescontrol.data.model.VaultSourcesResponse
|
||||
import com.m57.hermescontrol.data.model.VaultUnlockResponse
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.ws.contract.EmptyParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.VaultLockParams
|
||||
import com.m57.hermescontrol.data.ws.contract.VaultSourceSetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -48,7 +53,7 @@ object VaultRepository {
|
||||
}
|
||||
|
||||
suspend fun getSources(): List<VaultSource> {
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_SOURCES).await()
|
||||
val result = HermesWsClient.call(RpcMethods.VAULT_SOURCES, EmptyParams)
|
||||
return decode<VaultSourcesResponse>(result)?.sources ?: emptyList()
|
||||
}
|
||||
|
||||
@@ -57,11 +62,10 @@ object VaultRepository {
|
||||
enabled: Boolean,
|
||||
): Boolean {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.VAULT_SOURCE_SET,
|
||||
mapOf("name" to name, "enabled" to enabled),
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
RpcMethods.VAULT_SOURCE_SET,
|
||||
VaultSourceSetParams(name = name, enabled = enabled),
|
||||
)
|
||||
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
|
||||
}
|
||||
|
||||
@@ -70,22 +74,21 @@ object VaultRepository {
|
||||
password: String,
|
||||
): Boolean {
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.VAULT_UNLOCK,
|
||||
mapOf("name" to name, "password" to password),
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
RpcMethods.VAULT_UNLOCK,
|
||||
VaultUnlockParams(name = name, password = password),
|
||||
)
|
||||
return decode<VaultUnlockResponse>(result)?.unlocked == true
|
||||
}
|
||||
|
||||
suspend fun lockSource(name: String? = null): Boolean {
|
||||
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
|
||||
val params = VaultLockParams(name = name)
|
||||
val result = HermesWsClient.call(RpcMethods.VAULT_LOCK, params)
|
||||
return decode<VaultLockResponse>(result)?.locked == true
|
||||
}
|
||||
|
||||
suspend fun listItems(): List<VaultItem> {
|
||||
val result = HermesWsClient.request(WsMethods.VAULT_LIST).await()
|
||||
val result = HermesWsClient.call(RpcMethods.VAULT_LIST, EmptyParams)
|
||||
return decode<VaultListResponse>(result)?.items ?: emptyList()
|
||||
}
|
||||
}
|
||||
@@ -445,6 +445,18 @@ sealed class WsEvent {
|
||||
val kind: String = "",
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* `message.reaction`: the agent (or another client) set/cleared a tapback on a stored
|
||||
* message. [reactions] is the row's full list after the write; [rowId] is the gateway
|
||||
* SQLite row id, matched against `ChatMessage.serverRowId`.
|
||||
*/
|
||||
data class MessageReactionUpdated(
|
||||
val rowId: Long,
|
||||
val reactions: List<com.m57.hermescontrol.data.model.MessageReaction>,
|
||||
val role: String,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Replay resync (internal) ──────────────────────────────────────────
|
||||
|
||||
/** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */
|
||||
|
||||
@@ -94,6 +94,9 @@ object WsMethods {
|
||||
/** Kill a single background process (scoped to the active session). */
|
||||
const val PROCESS_KILL = "process.kill"
|
||||
|
||||
/** Kill every background process in the gateway registry (`/stop`, global). */
|
||||
const val PROCESS_STOP = "process.stop"
|
||||
|
||||
// ── Subagents (issue #1089) ───────────────────────────────────────────
|
||||
|
||||
/** List active subagents and delegations for a session. */
|
||||
|
||||
@@ -0,0 +1,912 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.await
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.utf8Size
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Socket lifecycle, authentication, outbound queueing, and network transport management.
|
||||
*
|
||||
* Extracted from [HermesWsClient] (issue #1376).
|
||||
*/
|
||||
internal class WsTransport(
|
||||
private val scope: CoroutineScope,
|
||||
private val outboundLock: Any,
|
||||
private val reconnectPolicy: ReconnectPolicy,
|
||||
private val connectionHealth: ConnectionHealth,
|
||||
private val replayTracker: ReplayTracker,
|
||||
private val rpcChannel: RpcChannel,
|
||||
private val onRawMessage: (String) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "HermesWsClient"
|
||||
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
|
||||
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
||||
}
|
||||
|
||||
private val connectionGeneration = AtomicInteger(0)
|
||||
private val connected = AtomicBoolean(false)
|
||||
private val intentionalClose = AtomicBoolean(false)
|
||||
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||
private val appInForeground = AtomicBoolean(true)
|
||||
private val externalActivityConnectionLease = AtomicBoolean(false)
|
||||
private val backgroundConnectionLease = AtomicBoolean(false)
|
||||
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var closingSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var reconnectJob: Job? = null
|
||||
|
||||
@Volatile
|
||||
private var outboundDrainJob: Job? = null
|
||||
|
||||
private val networkCollectorStarted = AtomicBoolean(false)
|
||||
|
||||
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
var pendingReply: Boolean = false
|
||||
internal set
|
||||
|
||||
private val pendingPromptSubmits = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val isConnected: Boolean get() = connected.get()
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
appInForeground.set(foreground)
|
||||
if (!foreground) {
|
||||
disconnectIfIdleInBackground()
|
||||
return
|
||||
}
|
||||
connectionHealth.probeLivenessOnWake()
|
||||
}
|
||||
|
||||
fun acquireExternalActivityConnectionLease() {
|
||||
externalActivityConnectionLease.set(true)
|
||||
}
|
||||
|
||||
fun releaseExternalActivityConnectionLease() {
|
||||
externalActivityConnectionLease.set(false)
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
|
||||
fun acquireBackgroundConnectionLease() {
|
||||
backgroundConnectionLease.set(true)
|
||||
}
|
||||
|
||||
fun releaseBackgroundConnectionLease() {
|
||||
backgroundConnectionLease.set(false)
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
|
||||
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
|
||||
|
||||
fun disconnectIfIdleInBackground() {
|
||||
synchronized(outboundLock) {
|
||||
if (!appInForeground.get() &&
|
||||
!externalActivityConnectionLease.get() &&
|
||||
!backgroundConnectionLease.get() &&
|
||||
!pendingReply &&
|
||||
!rpcChannel.hasPendingCalls() &&
|
||||
messageQueue.isEmpty()
|
||||
) {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
if (networkCollectorStarted.compareAndSet(false, true)) {
|
||||
scope.launch {
|
||||
NetworkMonitor.networkChanges.collect { networkAvailable ->
|
||||
reconnectForNetworkChange(networkAvailable)
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
NetworkMonitor.transportChanges.collect {
|
||||
connectionHealth.probeLivenessOnTransportChange(
|
||||
onFailureAction = { cancelSocket() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
var socketToCancel: WebSocket? = null
|
||||
val shouldOpen =
|
||||
synchronized(outboundLock) {
|
||||
acceptQueuedMessages.set(true)
|
||||
if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
|
||||
connectionGeneration.incrementAndGet()
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
socketToCancel = webSocket
|
||||
webSocket = null
|
||||
closingSocket = null
|
||||
connected.set(false)
|
||||
connectionHealth.stop()
|
||||
}
|
||||
if (connected.get()) {
|
||||
Log.d(TAG, "Already connected — skipping")
|
||||
return@synchronized false
|
||||
}
|
||||
if (_connectionStatus.value == ConnectionStatus.CONNECTING ||
|
||||
_connectionStatus.value == ConnectionStatus.RECONNECTING
|
||||
) {
|
||||
Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping")
|
||||
return@synchronized false
|
||||
}
|
||||
// An explicit connect follows successful authentication and is
|
||||
// therefore allowed to leave the terminal AUTH_EXPIRED state.
|
||||
intentionalClose.set(false)
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||
reconnectPolicy.reset()
|
||||
true
|
||||
}
|
||||
if (socketToCancel != null) {
|
||||
rpcChannel.rejectAllPending()
|
||||
socketToCancel.cancel()
|
||||
}
|
||||
if (shouldOpen) openSocket()
|
||||
}
|
||||
|
||||
fun reconnectForNetworkChange(
|
||||
networkAvailable: Boolean = NetworkMonitor.isConnected.value,
|
||||
openReplacement: () -> Unit = ::openSocket,
|
||||
) {
|
||||
val socketToCancel: WebSocket?
|
||||
val shouldOpen: Boolean
|
||||
synchronized(outboundLock) {
|
||||
if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "Default network changed — reconnecting WebSocket")
|
||||
reconnectPolicy.reset()
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
connectionGeneration.incrementAndGet()
|
||||
socketToCancel = webSocket
|
||||
webSocket = null
|
||||
closingSocket = null
|
||||
connected.set(false)
|
||||
connectionHealth.stop()
|
||||
shouldOpen = networkAvailable && AuthManager.isAutoReconnect()
|
||||
_connectionStatus.value =
|
||||
when {
|
||||
!networkAvailable -> ConnectionStatus.NO_NETWORK
|
||||
shouldOpen -> ConnectionStatus.RECONNECTING
|
||||
else -> ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
rpcChannel.rejectAllPending()
|
||||
}
|
||||
socketToCancel?.cancel()
|
||||
if (shouldOpen) openReplacement()
|
||||
}
|
||||
|
||||
suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
|
||||
val isGated =
|
||||
try {
|
||||
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||
} catch (_: IllegalStateException) {
|
||||
// serverStore not initialized yet (transient early call); treat
|
||||
// as loopback so a stale-token refresh still runs. Log so a real
|
||||
// misconfiguration isn't silently swallowed.
|
||||
Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated")
|
||||
false
|
||||
}
|
||||
if (!isGated) {
|
||||
// The loopback dashboard token is regenerated on every server
|
||||
// restart. Refresh it before each WebSocket handshake so automatic
|
||||
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
|
||||
val token =
|
||||
runCatching {
|
||||
DashboardSessionTokenRefresher.refreshAsync()
|
||||
}.getOrNull()
|
||||
synchronized(outboundLock) {
|
||||
if (isCurrentGeneration(generation) && token != null) {
|
||||
runCatching { AuthManager.setToken(token) }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
val ticketResult = requestWsTicket()
|
||||
if (!ticketResult.ticket.isNullOrBlank()) {
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrentGeneration(generation)) return false
|
||||
AuthManager.setToken(ticketResult.ticket)
|
||||
}
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||
return true
|
||||
}
|
||||
val status =
|
||||
when {
|
||||
ticketResult.exception -> ConnectionStatus.RECONNECTING
|
||||
ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED
|
||||
ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING
|
||||
ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING
|
||||
else -> ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
return handleWsTicketRefreshFailure(status, generation)
|
||||
}
|
||||
|
||||
suspend fun mintWsTicket(): String? {
|
||||
val isGated =
|
||||
try {
|
||||
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||
} catch (_: IllegalStateException) {
|
||||
false
|
||||
}
|
||||
if (!isGated) {
|
||||
DashboardSessionTokenRefresher.refreshAsync()
|
||||
return AuthManager.getToken()
|
||||
}
|
||||
return requestWsTicket().ticket
|
||||
}
|
||||
|
||||
private data class TicketRequestResult(
|
||||
val ticket: String?,
|
||||
val httpCode: Int?,
|
||||
val exception: Boolean = false,
|
||||
)
|
||||
|
||||
private suspend fun requestWsTicket(): TicketRequestResult {
|
||||
try {
|
||||
val client = OkHttpProvider.probe
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString())
|
||||
.post("{}".toRequestBody())
|
||||
.build()
|
||||
|
||||
val (code, body) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (CookieManager.isInitialized()) {
|
||||
CookieManager.useStore(CookieManager.cookieJar.currentServer())
|
||||
}
|
||||
client.newCall(request).await().use { resp ->
|
||||
resp.code to resp.body.string()
|
||||
}
|
||||
}
|
||||
|
||||
if (code in 200..299) {
|
||||
// Real JSON decode — the ticket is base64url and may contain
|
||||
// characters a naive [^"]+ regex cannot extract (see
|
||||
// testGatedMode_parsesEscapedTicketFromRealJsonShape).
|
||||
val parsed =
|
||||
runCatching {
|
||||
OkHttpProvider.json.decodeFromString<WsTicketResponse>(body)
|
||||
}.getOrNull()
|
||||
val ticket = parsed?.ticket
|
||||
if (!ticket.isNullOrBlank()) {
|
||||
return TicketRequestResult(ticket, null)
|
||||
}
|
||||
Log.w(TAG, "WS ticket mint failed: unparseable ticket response")
|
||||
} else {
|
||||
Log.w(TAG, "WS ticket mint failed: HTTP $code")
|
||||
return TicketRequestResult(null, code)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Include the stack: the exception class alone (e.g.
|
||||
// NetworkOnMainThreadException) can't show WHICH caller on what
|
||||
// thread tripped the mint (main-scope kanban events connect vs
|
||||
// the WS reconnect path).
|
||||
Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e)
|
||||
return TicketRequestResult(null, null, exception = true)
|
||||
}
|
||||
return TicketRequestResult(null, null)
|
||||
}
|
||||
|
||||
private fun handleWsTicketRefreshFailure(
|
||||
status: ConnectionStatus,
|
||||
generation: Int,
|
||||
): Boolean {
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrentGeneration(generation)) return false
|
||||
_connectionStatus.value = status
|
||||
if (status == ConnectionStatus.RECONNECTING) scheduleReconnect()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun disconnect(clearPendingMessages: Boolean = false) {
|
||||
ActiveSessionHolder.clear()
|
||||
synchronized(outboundLock) {
|
||||
intentionalClose.set(true)
|
||||
acceptQueuedMessages.set(!clearPendingMessages)
|
||||
connectionGeneration.incrementAndGet()
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
connectionHealth.cancelForegroundProbe()
|
||||
connectionHealth.stop()
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
closingSocket = null
|
||||
rpcChannel.clearCapabilityRequests()
|
||||
if (clearPendingMessages) {
|
||||
backgroundConnectionLease.set(false)
|
||||
messageQueue.clear()
|
||||
queuedMessagesById.clear()
|
||||
pendingPromptSubmits.clear()
|
||||
pendingReply = false
|
||||
replayTracker.clear()
|
||||
}
|
||||
connected.set(false)
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSocket() {
|
||||
synchronized(outboundLock) {
|
||||
if (connected.get()) webSocket?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDirectFrame(frameJson: String): Boolean =
|
||||
synchronized(outboundLock) {
|
||||
val ws = webSocket
|
||||
if (ws == null || !connected.get()) false else ws.send(frameJson)
|
||||
}
|
||||
|
||||
fun send(
|
||||
request: JsonRpcRequest,
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
queueIfDisconnected: Boolean = true,
|
||||
): String {
|
||||
val id = request.id
|
||||
val method = request.method
|
||||
val json = rpcChannel.encodeRequest(request)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
|
||||
}
|
||||
var reconnect = false
|
||||
synchronized(outboundLock) {
|
||||
if (method == WsMethods.PROMPT_SUBMIT) {
|
||||
pendingPromptSubmits.add(id)
|
||||
pendingReply = true
|
||||
}
|
||||
val ws = webSocket
|
||||
if (ws != null && connected.get()) {
|
||||
// Never replay send(true): the server may have executed it even if its response is lost.
|
||||
if (ws.send(json)) {
|
||||
onSent?.invoke(id)
|
||||
} else {
|
||||
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
|
||||
if (queueIfDisconnected && isRetryableMessage(json)) {
|
||||
onSent?.invoke(id)
|
||||
Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
|
||||
queueMessage(id, json)
|
||||
recoverRejectedSocket(ws)
|
||||
} else {
|
||||
Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
|
||||
}
|
||||
}
|
||||
} else if (acceptQueuedMessages.get() && queueIfDisconnected) {
|
||||
if (isRetryableMessage(json)) {
|
||||
onSent?.invoke(id)
|
||||
Log.d(TAG, "WS disconnected — queuing message")
|
||||
queueMessage(id, json)
|
||||
reconnect = true
|
||||
} else {
|
||||
Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reconnect) connect()
|
||||
return id
|
||||
}
|
||||
|
||||
fun removeQueuedMessage(id: String) {
|
||||
synchronized(outboundLock) {
|
||||
queuedMessagesById.remove(id)?.let(messageQueue::remove)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRpcResult(id: String) {
|
||||
synchronized(outboundLock) {
|
||||
pendingPromptSubmits.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun onRpcError(id: String) {
|
||||
synchronized(outboundLock) {
|
||||
if (pendingPromptSubmits.remove(id) && pendingPromptSubmits.isEmpty()) {
|
||||
pendingReply = false
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun observeEvent(event: WsEvent) {
|
||||
when (event) {
|
||||
is WsEvent.MessageStart,
|
||||
is WsEvent.MessageToken,
|
||||
is WsEvent.ThinkingDelta,
|
||||
is WsEvent.ReasoningDelta,
|
||||
is WsEvent.ToolStart,
|
||||
-> {
|
||||
pendingReply = true
|
||||
}
|
||||
|
||||
is WsEvent.MessageComplete -> {
|
||||
pendingReply = false
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRetryableMessage(json: String): Boolean {
|
||||
if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false
|
||||
if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true
|
||||
return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong()
|
||||
}
|
||||
|
||||
private fun queueMessage(
|
||||
id: String,
|
||||
json: String,
|
||||
) {
|
||||
if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json)
|
||||
}
|
||||
|
||||
private fun markQueuedMessageSent(json: String) {
|
||||
queuedMessagesById.entries.removeIf { it.value == json }
|
||||
}
|
||||
|
||||
private fun recoverRejectedSocket(ws: WebSocket) {
|
||||
connected.set(false)
|
||||
if (closingSocket === ws) return
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
if (ws.queueSize() == 0L) {
|
||||
ws.cancel()
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
if (intentionalClose.get()) return
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob =
|
||||
scope.launch {
|
||||
delay(OUTBOUND_DRAIN_TIMEOUT_MS)
|
||||
synchronized(outboundLock) {
|
||||
if (!connected.get() && !intentionalClose.get() && webSocket === ws) {
|
||||
ws.cancel()
|
||||
outboundDrainJob = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
sessionId: String,
|
||||
text: String,
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
queued: Boolean = false,
|
||||
): String =
|
||||
send(
|
||||
rpcChannel.createRequest(
|
||||
method = RpcMethods.PROMPT_SUBMIT.name,
|
||||
params =
|
||||
PromptSubmitParams(
|
||||
sessionId = sessionId,
|
||||
text = text,
|
||||
queued = queued.takeIf { it },
|
||||
).let { rpcChannel.encodeParams(RpcMethods.PROMPT_SUBMIT, it) },
|
||||
),
|
||||
onSent = onSent,
|
||||
)
|
||||
|
||||
fun sendRedirect(
|
||||
sessionId: String,
|
||||
text: String,
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
): String =
|
||||
send(
|
||||
rpcChannel.createRequest(
|
||||
method = RpcMethods.SESSION_REDIRECT.name,
|
||||
params =
|
||||
SessionCorrectionParams(
|
||||
sessionId = sessionId,
|
||||
text = text,
|
||||
).let { rpcChannel.encodeParams(RpcMethods.SESSION_REDIRECT, it) },
|
||||
),
|
||||
onSent = onSent,
|
||||
)
|
||||
|
||||
internal fun openSocket() {
|
||||
val generation =
|
||||
synchronized(outboundLock) {
|
||||
if (intentionalClose.get()) return
|
||||
connectionGeneration.incrementAndGet()
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (!refreshWsTicketIfNeeded(generation)) {
|
||||
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
|
||||
return@launch
|
||||
}
|
||||
if (!isCurrentGeneration(generation)) return@launch
|
||||
val url = AuthManager.wsUrl()
|
||||
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
|
||||
synchronized(outboundLock) {
|
||||
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
|
||||
webSocket = newSocket
|
||||
} else {
|
||||
newSocket.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrentGeneration(generation: Int): Boolean =
|
||||
connectionGeneration.get() == generation && !intentionalClose.get()
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
synchronized(outboundLock) {
|
||||
if (intentionalClose.get() ||
|
||||
_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED ||
|
||||
reconnectJob?.isActive == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!AuthManager.isAutoReconnect()) {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
||||
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
return
|
||||
}
|
||||
if (!NetworkMonitor.isConnected.value) {
|
||||
Log.d(TAG, "No network available — delaying reconnect scheduling")
|
||||
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||
return
|
||||
}
|
||||
val reconnectDelay = reconnectPolicy.nextDelayMs()
|
||||
reconnectPolicy.advance()
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …")
|
||||
|
||||
reconnectJob =
|
||||
scope.launch {
|
||||
delay(reconnectDelay)
|
||||
val owner = currentCoroutineContext()[Job]
|
||||
val shouldOpen =
|
||||
synchronized(outboundLock) {
|
||||
if (reconnectJob !== owner) {
|
||||
false
|
||||
} else {
|
||||
reconnectJob = null
|
||||
!intentionalClose.get() &&
|
||||
!connected.get() &&
|
||||
_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED
|
||||
}
|
||||
}
|
||||
if (shouldOpen) openSocket()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTerminalAuthClose(
|
||||
code: Int,
|
||||
reason: String,
|
||||
): Boolean =
|
||||
code == 4401 || code == 4403 ||
|
||||
reason.contains("unauthorized", ignoreCase = true) ||
|
||||
reason.startsWith("auth:", ignoreCase = true)
|
||||
|
||||
private inner class WsListenerImpl(
|
||||
private val generation: Int,
|
||||
) : WebSocketListener() {
|
||||
private fun isCurrent(): Boolean = isCurrentGeneration(generation)
|
||||
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) {
|
||||
webSocket.close(1000, "Superseded")
|
||||
return
|
||||
}
|
||||
this@WsTransport.webSocket = webSocket
|
||||
closingSocket = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
Log.i(TAG, "WebSocket opened")
|
||||
connected.set(true)
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
reconnectPolicy.reset()
|
||||
connectionHealth.start()
|
||||
|
||||
while (true) {
|
||||
val msg = messageQueue.peek() ?: break
|
||||
if (!isRetryableMessage(msg)) {
|
||||
Log.w(TAG, "Dropping oversized queued message")
|
||||
messageQueue.poll()
|
||||
markQueuedMessageSent(msg)
|
||||
continue
|
||||
}
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
|
||||
if (!webSocket.send(msg)) {
|
||||
recoverRejectedSocket(webSocket)
|
||||
break
|
||||
}
|
||||
messageQueue.poll()
|
||||
markQueuedMessageSent(msg)
|
||||
}
|
||||
replayTracker.triggerReplay()
|
||||
}
|
||||
disconnectIfIdleInBackground()
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (!isCurrent() || this@WsTransport.webSocket !== webSocket) return
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
|
||||
connectionHealth.onInboundFrame()
|
||||
onRawMessage(text)
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) {
|
||||
webSocket.close(code, reason)
|
||||
return
|
||||
}
|
||||
closingSocket = webSocket
|
||||
// Do NOT log [reason] — it may carry server-side context.
|
||||
Log.d(TAG, "WebSocket closing: $code")
|
||||
if (isTerminalAuthClose(code, reason)) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
}
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) return
|
||||
connectionGeneration.incrementAndGet()
|
||||
closingSocket = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
|
||||
// Do NOT log [reason] — it may carry server-side context. The
|
||||
// reason is still inspected internally to detect auth failures.
|
||||
Log.i(TAG, "WebSocket closed: $code")
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
connectionHealth.stop()
|
||||
rpcChannel.clearCapabilityRequests()
|
||||
if (isTerminalAuthClose(code, reason)) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
synchronized(outboundLock) {
|
||||
if (!isCurrent()) return
|
||||
connectionGeneration.incrementAndGet()
|
||||
closingSocket = null
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
|
||||
// Log the exception class only — [Throwable.message] can leak URLs
|
||||
// or headers. The message is still inspected internally for auth
|
||||
// detection.
|
||||
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
connectionHealth.stop()
|
||||
rpcChannel.clearCapabilityRequests()
|
||||
val code = response?.code ?: 0
|
||||
if (code == 401 || code == 4401 || code == 4403 ||
|
||||
t.message?.contains("401") == true ||
|
||||
t.message?.contains("4401") == true ||
|
||||
t.message?.contains("4403") == true ||
|
||||
t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||
) {
|
||||
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test seams (issue #1376 extraction support) ──────────────────────
|
||||
|
||||
@VisibleForTesting
|
||||
internal val connectedForTest: AtomicBoolean
|
||||
get() = connected
|
||||
|
||||
@VisibleForTesting
|
||||
internal val intentionalCloseForTest: AtomicBoolean
|
||||
get() = intentionalClose
|
||||
|
||||
@VisibleForTesting
|
||||
internal val connectionGenerationForTest: AtomicInteger
|
||||
get() = connectionGeneration
|
||||
|
||||
@VisibleForTesting
|
||||
internal val messageQueueForTest: ConcurrentLinkedQueue<String>
|
||||
get() = messageQueue
|
||||
|
||||
@VisibleForTesting
|
||||
internal val outboundLockForTest: Any
|
||||
get() = outboundLock
|
||||
|
||||
@VisibleForTesting
|
||||
internal val acceptQueuedMessagesForTest: AtomicBoolean
|
||||
get() = acceptQueuedMessages
|
||||
|
||||
@VisibleForTesting
|
||||
internal val connectionStatusForTest: MutableStateFlow<ConnectionStatus>
|
||||
get() = _connectionStatus
|
||||
|
||||
@VisibleForTesting
|
||||
internal var webSocketForTest: WebSocket?
|
||||
get() = webSocket
|
||||
set(value) {
|
||||
webSocket = value
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal var currentBackoffForTest: Long
|
||||
get() = synchronized(outboundLock) { reconnectPolicy.currentBackoff }
|
||||
set(value) {
|
||||
synchronized(outboundLock) {
|
||||
reconnectPolicy.currentBackoff = value
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal var reconnectJobForTest: Job?
|
||||
get() = reconnectJob
|
||||
set(value) {
|
||||
reconnectJob = value
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal var outboundDrainJobForTest: Job?
|
||||
get() = outboundDrainJob
|
||||
set(value) {
|
||||
outboundDrainJob = value
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun setReconnectBackoffForTest(initialMillis: Long) {
|
||||
synchronized(outboundLock) {
|
||||
reconnectPolicy.setBackoffForTest(initialMillis)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun scheduleReconnectForTest() {
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun createListenerForTest(generation: Int): WebSocketListener = WsListenerImpl(generation)
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
|
||||
_connectionStatus.value = status
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatSafeOutgoingFrameLog(
|
||||
id: String,
|
||||
method: String,
|
||||
paramsKeys: Collection<String>?,
|
||||
byteLength: Int,
|
||||
queued: Boolean = false,
|
||||
): String {
|
||||
val prefix = if (queued) "→ (queued)" else "→"
|
||||
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
|
||||
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatSafeQueuedFrameLog(msg: String): String =
|
||||
try {
|
||||
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
|
||||
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
|
||||
} catch (_: Throwable) {
|
||||
"→ (queued) frame bytes=${msg.length}"
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun formatSafeIncomingFrameLog(text: String): String =
|
||||
try {
|
||||
val element = OkHttpProvider.json.parseToJsonElement(text)
|
||||
if (element is JsonObject) {
|
||||
val id = (element["id"] as? JsonPrimitive)?.content
|
||||
val method = (element["method"] as? JsonPrimitive)?.content
|
||||
val hasResult = element.containsKey("result")
|
||||
val resultKeys =
|
||||
(element["result"] as? JsonObject)?.keys?.joinToString(
|
||||
",",
|
||||
prefix = "[",
|
||||
postfix = "]",
|
||||
)
|
||||
val errorObj = element["error"] as? JsonObject
|
||||
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
|
||||
val sb = StringBuilder("← ")
|
||||
if (id != null) sb.append("id=").append(id).append(" ")
|
||||
if (method != null) sb.append("method=").append(method).append(" ")
|
||||
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
|
||||
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
|
||||
sb.append("bytes=").append(text.length).toString()
|
||||
} else {
|
||||
"← non-object frame bytes=${text.length}"
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
"← frame bytes=${text.length}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApprovalPendingParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApprovalReceivedParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("request_id") val requestId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ApprovalRespondParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("choice") val choice: String? = null,
|
||||
@SerialName("all") val all: Boolean? = null,
|
||||
@SerialName("request_id") val requestId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionActiveListParams(
|
||||
@SerialName("current_session_id") val currentSessionId: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data object EmptyParams
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionPreviewParams(
|
||||
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionChangeParams(
|
||||
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
|
||||
@SerialName("cancel") val cancel: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionUpgradeParams(
|
||||
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultSourceSetParams(
|
||||
@SerialName("name") val name: String? = null,
|
||||
@SerialName("enabled") val enabled: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultUnlockParams(
|
||||
@SerialName("name") val name: String? = null,
|
||||
@SerialName("password") val password: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VaultLockParams(
|
||||
@SerialName("name") val name: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** `commands.catalog` is sent without explicit params; the active profile is injected by WsProfileParams. */
|
||||
@Serializable
|
||||
data object CommandsCatalogParams
|
||||
|
||||
/** `arg` is sent whenever non-null, including an empty string, exactly like the old map payload. */
|
||||
@Serializable
|
||||
data class CommandDispatchParams(
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("arg") val arg: String? = null,
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SlashExecParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("command") val command: String,
|
||||
)
|
||||
|
||||
/** `data_url` carries the bytes for an uploaded file; `path` is for gateway-visible files. */
|
||||
@Serializable
|
||||
data class FileAttachParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("path") val path: String? = null,
|
||||
@SerialName("data_url") val dataUrl: String? = null,
|
||||
@SerialName("name") val name: String? = null,
|
||||
)
|
||||
|
||||
/** The mobile client always sends the payload as `content_base64`; the `data` alias is not modelled. */
|
||||
@Serializable
|
||||
data class ImageAttachBytesParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("content_base64") val contentBase64: String? = null,
|
||||
@SerialName("filename") val filename: String? = null,
|
||||
@SerialName("ext") val ext: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `config.set` params. [key] and [value] are always encoded (no defaults); the rest are omitted
|
||||
* when null. `profile` is injected by WsProfileParams, so it is not a field here.
|
||||
*/
|
||||
@Serializable
|
||||
data class ConfigSetParams(
|
||||
@SerialName("key") val key: String,
|
||||
@SerialName("value") val value: String,
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
@SerialName("scope") val scope: String? = null,
|
||||
@SerialName("confirm_expensive_model") val confirmExpensiveModel: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConfigGetParams(
|
||||
@SerialName("key") val key: String,
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
@SerialName("cwd") val cwd: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `model.options` params. Flags are `Boolean?` so an explicit `false` is still sent while an
|
||||
* unset flag is omitted (the client Json has `encodeDefaults = false`).
|
||||
*/
|
||||
@Serializable
|
||||
data class ModelOptionsParams(
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
@SerialName("explicit_only") val explicitOnly: Boolean? = null,
|
||||
@SerialName("include_unconfigured") val includeUnconfigured: Boolean? = null,
|
||||
@SerialName("refresh") val refresh: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClientCapabilitiesParams(
|
||||
@SerialName("server_requests") val serverRequests: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAnswerTarget(
|
||||
@SerialName("name") val name: String,
|
||||
@SerialName("status") val status: String,
|
||||
@SerialName("detail") val detail: String? = null,
|
||||
@SerialName("env") val env: Map<String, String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAnswer(
|
||||
@SerialName("targets") val targets: List<ConnectionAnswerTarget>? = null,
|
||||
@SerialName("settled_by") val settledBy: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionRespondParams(
|
||||
@SerialName("owner") val owner: ConnectorOwner,
|
||||
@SerialName("op_id") val opId: String,
|
||||
@SerialName("result") val result: ConnectionAnswer,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class ConnectorOwner(
|
||||
@SerialName("type") val type: String,
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun account() = ConnectorOwner("account")
|
||||
|
||||
fun session(sessionId: String) = ConnectorOwner("session", sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsListParams(
|
||||
@SerialName("owner") val owner: ConnectorOwner,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsConnectParams(
|
||||
@SerialName("owner") val owner: ConnectorOwner,
|
||||
@SerialName("connectors") val connectors: List<String>,
|
||||
@SerialName("reconnect") val reconnect: Boolean? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsOperationStatusParams(
|
||||
@SerialName("owner") val owner: ConnectorOwner,
|
||||
@SerialName("op_id") val opId: String,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsCatalogParams(
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsAccountsParams(
|
||||
@SerialName("connector") val connector: String? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsAccountsRemoveParams(
|
||||
@SerialName("connection_id") val connectionId: String,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsPolicyGetParams(
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsPolicySetParams(
|
||||
@SerialName("change") val change: JsonObject,
|
||||
@SerialName("expected_revision") val expectedRevision: String,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectorsToolsParams(
|
||||
@SerialName("slug") val slug: String,
|
||||
@SerialName("refresh") val refresh: Boolean? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* `plugins.manage` params. [action] has no default so it is always encoded; every other field is
|
||||
* omitted when null. [profile] is an explicit override only; otherwise WsProfileParams injects it.
|
||||
*/
|
||||
@Serializable
|
||||
data class PluginsManageParams(
|
||||
@SerialName("action") val action: String,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
@SerialName("key") val key: String? = null,
|
||||
@SerialName("name") val name: String? = null,
|
||||
@SerialName("enable") val enable: Boolean? = null,
|
||||
@SerialName("identifier") val identifier: String? = null,
|
||||
@SerialName("repo") val repo: String? = null,
|
||||
@SerialName("catalog_name") val catalogName: String? = null,
|
||||
@SerialName("force") val force: Boolean? = null,
|
||||
@SerialName("ref") val ref: String? = null,
|
||||
@SerialName("accept_capabilities") val acceptCapabilities: Boolean? = null,
|
||||
@SerialName("values") val values: JsonObject? = null,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ProcessKillParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("process_id") val processId: String,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* `process.stop` kills every background process in the gateway registry (the desktop `/stop` second half). Desktop
|
||||
* sends `{}`: the backend call is global, so no `session_id` or `profile` override is sent.
|
||||
*/
|
||||
@Serializable
|
||||
data object ProcessStopParams
|
||||
|
||||
@Serializable
|
||||
data class ProcessStopResult(
|
||||
val killed: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* `profiles.configure` params for the sections the app writes: `ui_meta` and the bot `soul`.
|
||||
* `profile` is not a field: WsProfileParams injects it when the method is profile scoped.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProfilesConfigureParams(
|
||||
@SerialName("name") val name: String? = null,
|
||||
@SerialName("ui_meta") val uiMeta: JsonObject? = null,
|
||||
@SerialName("soul") val soul: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** `profiles.list` takes no explicit params here; the active profile is injected by WsProfileParams. */
|
||||
@Serializable
|
||||
data object ProfilesListParams
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** `projects.list` takes no explicit params; the active profile is injected by WsProfileParams. */
|
||||
@Serializable
|
||||
data object ProjectsListParams
|
||||
@@ -0,0 +1,539 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
class RpcMethod<P, R>(
|
||||
val name: String,
|
||||
val params: KSerializer<P>,
|
||||
val result: KSerializer<R>,
|
||||
)
|
||||
|
||||
interface TypedRpcSender {
|
||||
fun <P> send(
|
||||
method: RpcMethod<P, *>,
|
||||
params: P,
|
||||
onSent: ((String) -> Unit)? = null,
|
||||
): String
|
||||
}
|
||||
|
||||
interface TypedRpcCaller {
|
||||
suspend fun <P, R> call(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
): R
|
||||
}
|
||||
|
||||
object HermesRpcCaller : TypedRpcCaller {
|
||||
override suspend fun <P, R> call(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
): R = HermesWsClient.call(method, params)
|
||||
}
|
||||
|
||||
object RpcMethods {
|
||||
val SESSION_EVENTS_SINCE: RpcMethod<SessionEventsSinceParams, SessionEventsSinceResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams.serializer(),
|
||||
SessionEventsSinceResult.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_CREATE: RpcMethod<SessionCreateParams, SessionCreateResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_CREATE,
|
||||
SessionCreateParams.serializer(),
|
||||
SessionCreateResult.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_RESUME: RpcMethod<SessionResumeParams, SessionResumeResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_RESUME,
|
||||
SessionResumeParams.serializer(),
|
||||
SessionResumeResult.serializer(),
|
||||
)
|
||||
|
||||
val PROMPT_SUBMIT: RpcMethod<PromptSubmitParams, PromptSubmitResult> =
|
||||
RpcMethod(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
PromptSubmitParams.serializer(),
|
||||
PromptSubmitResult.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_INTERRUPT: RpcMethod<SessionInterruptParams, SessionInterruptResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_INTERRUPT,
|
||||
SessionInterruptParams.serializer(),
|
||||
SessionInterruptResult.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_STEER: RpcMethod<SessionCorrectionParams, SessionCorrectionResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_STEER,
|
||||
SessionCorrectionParams.serializer(),
|
||||
SessionCorrectionResult.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_REDIRECT: RpcMethod<SessionCorrectionParams, SessionCorrectionResult> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_REDIRECT,
|
||||
SessionCorrectionParams.serializer(),
|
||||
SessionCorrectionResult.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val SESSION_LIST: RpcMethod<SessionListParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_LIST,
|
||||
SessionListParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_BRANCH: RpcMethod<SessionBranchParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_BRANCH,
|
||||
SessionBranchParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_BRANCH_WHOLE: RpcMethod<SessionBranchWholeParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_BRANCH_WHOLE,
|
||||
SessionBranchWholeParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_COMPRESS: RpcMethod<SessionCompressParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_COMPRESS,
|
||||
SessionCompressParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_CONTEXT_BREAKDOWN: RpcMethod<SessionIdParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_CONTEXT_BREAKDOWN,
|
||||
SessionIdParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SESSION_USAGE: RpcMethod<SessionIdParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_USAGE,
|
||||
SessionIdParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val PROMPT_BTW: RpcMethod<PromptBtwParams, PromptBtwResult> =
|
||||
RpcMethod(
|
||||
WsMethods.PROMPT_BTW,
|
||||
PromptBtwParams.serializer(),
|
||||
PromptBtwResult.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val APPROVAL_PENDING: RpcMethod<ApprovalPendingParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.APPROVAL_PENDING,
|
||||
ApprovalPendingParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val APPROVAL_RECEIVED: RpcMethod<ApprovalReceivedParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.APPROVAL_RECEIVED,
|
||||
ApprovalReceivedParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val APPROVAL_RESPOND: RpcMethod<ApprovalRespondParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.APPROVAL_RESPOND,
|
||||
ApprovalRespondParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val SESSION_ACTIVE_LIST: RpcMethod<SessionActiveListParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SESSION_ACTIVE_LIST,
|
||||
SessionActiveListParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val SUBSCRIPTION_STATE: RpcMethod<EmptyParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SUBSCRIPTION_STATE,
|
||||
EmptyParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SUBSCRIPTION_PREVIEW: RpcMethod<SubscriptionPreviewParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SUBSCRIPTION_PREVIEW,
|
||||
SubscriptionPreviewParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SUBSCRIPTION_CHANGE: RpcMethod<SubscriptionChangeParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SUBSCRIPTION_CHANGE,
|
||||
SubscriptionChangeParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SUBSCRIPTION_UPGRADE: RpcMethod<SubscriptionUpgradeParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SUBSCRIPTION_UPGRADE,
|
||||
SubscriptionUpgradeParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val SUBSCRIPTION_RESUME: RpcMethod<EmptyParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SUBSCRIPTION_RESUME,
|
||||
EmptyParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val USAGE_BARS: RpcMethod<EmptyParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.USAGE_BARS,
|
||||
EmptyParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val VAULT_LIST: RpcMethod<EmptyParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.VAULT_LIST,
|
||||
EmptyParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val VAULT_SOURCES: RpcMethod<EmptyParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.VAULT_SOURCES,
|
||||
EmptyParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val VAULT_SOURCE_SET: RpcMethod<VaultSourceSetParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.VAULT_SOURCE_SET,
|
||||
VaultSourceSetParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val VAULT_UNLOCK: RpcMethod<VaultUnlockParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.VAULT_UNLOCK,
|
||||
VaultUnlockParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val VAULT_LOCK: RpcMethod<VaultLockParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.VAULT_LOCK,
|
||||
VaultLockParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_LIST: RpcMethod<ConnectorsListParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_LIST,
|
||||
ConnectorsListParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_CONNECT: RpcMethod<ConnectorsConnectParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_CONNECT,
|
||||
ConnectorsConnectParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_OPERATION_STATUS: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_OPERATION_STATUS,
|
||||
ConnectorsOperationStatusParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_CATALOG: RpcMethod<ConnectorsCatalogParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_CATALOG,
|
||||
ConnectorsCatalogParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_ACCOUNTS: RpcMethod<ConnectorsAccountsParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_ACCOUNTS,
|
||||
ConnectorsAccountsParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_ACCOUNTS_REMOVE: RpcMethod<ConnectorsAccountsRemoveParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||
ConnectorsAccountsRemoveParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_POLICY_GET: RpcMethod<ConnectorsPolicyGetParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_POLICY_GET,
|
||||
ConnectorsPolicyGetParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_POLICY_SET: RpcMethod<ConnectorsPolicySetParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_POLICY_SET,
|
||||
ConnectorsPolicySetParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_TOOLS: RpcMethod<ConnectorsToolsParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_TOOLS,
|
||||
ConnectorsToolsParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTION_RESPOND: RpcMethod<ConnectionRespondParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
ConnectionRespondParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||
val CONNECTORS_OPERATION_WAKE: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONNECTORS_OPERATION_WAKE,
|
||||
ConnectorsOperationStatusParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val SUBAGENT_LIST: RpcMethod<SessionIdParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SUBAGENT_LIST,
|
||||
SessionIdParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val SUBAGENT_TAIL: RpcMethod<SubagentTailParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SUBAGENT_TAIL,
|
||||
SubagentTailParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val PROCESS_LIST: RpcMethod<SessionIdParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.PROCESS_LIST,
|
||||
SessionIdParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val PROCESS_KILL: RpcMethod<ProcessKillParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.PROCESS_KILL,
|
||||
ProcessKillParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
val PROCESS_STOP: RpcMethod<ProcessStopParams, ProcessStopResult> =
|
||||
RpcMethod(
|
||||
WsMethods.PROCESS_STOP,
|
||||
ProcessStopParams.serializer(),
|
||||
ProcessStopResult.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val PLUGINS_MANAGE: RpcMethod<PluginsManageParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.PLUGINS_MANAGE,
|
||||
PluginsManageParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val PROJECTS_LIST: RpcMethod<ProjectsListParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.PROJECTS_LIST,
|
||||
ProjectsListParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val PROFILES_LIST: RpcMethod<ProfilesListParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.PROFILES_LIST,
|
||||
ProfilesListParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val PROFILES_CONFIGURE: RpcMethod<ProfilesConfigureParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.PROFILES_CONFIGURE,
|
||||
ProfilesConfigureParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val CONFIG_SET: RpcMethod<ConfigSetParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONFIG_SET,
|
||||
ConfigSetParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val CONFIG_GET: RpcMethod<ConfigGetParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CONFIG_GET,
|
||||
ConfigGetParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val MODEL_OPTIONS: RpcMethod<ModelOptionsParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.MODEL_OPTIONS,
|
||||
ModelOptionsParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val CLIENT_CAPABILITIES: RpcMethod<ClientCapabilitiesParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.CLIENT_CAPABILITIES,
|
||||
ClientCapabilitiesParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val COMMANDS_CATALOG: RpcMethod<CommandsCatalogParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.COMMANDS_CATALOG,
|
||||
CommandsCatalogParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val COMMAND_DISPATCH: RpcMethod<CommandDispatchParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.COMMAND_DISPATCH,
|
||||
CommandDispatchParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val SLASH_EXEC: RpcMethod<SlashExecParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.SLASH_EXEC,
|
||||
SlashExecParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val FILE_ATTACH: RpcMethod<FileAttachParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.FILE_ATTACH,
|
||||
FileAttachParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||
val IMAGE_ATTACH_BYTES: RpcMethod<ImageAttachBytesParams, JsonElement> =
|
||||
RpcMethod(
|
||||
WsMethods.IMAGE_ATTACH_BYTES,
|
||||
ImageAttachBytesParams.serializer(),
|
||||
JsonElement.serializer(),
|
||||
)
|
||||
|
||||
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
|
||||
val all: List<RpcMethod<*, *>> =
|
||||
listOf(
|
||||
SESSION_EVENTS_SINCE,
|
||||
SESSION_CREATE,
|
||||
SESSION_RESUME,
|
||||
PROMPT_SUBMIT,
|
||||
SESSION_INTERRUPT,
|
||||
SESSION_STEER,
|
||||
SESSION_REDIRECT,
|
||||
SESSION_LIST,
|
||||
SESSION_BRANCH,
|
||||
SESSION_BRANCH_WHOLE,
|
||||
SESSION_COMPRESS,
|
||||
SESSION_CONTEXT_BREAKDOWN,
|
||||
SESSION_USAGE,
|
||||
PROMPT_BTW,
|
||||
APPROVAL_PENDING,
|
||||
APPROVAL_RECEIVED,
|
||||
APPROVAL_RESPOND,
|
||||
SESSION_ACTIVE_LIST,
|
||||
SUBSCRIPTION_STATE,
|
||||
SUBSCRIPTION_PREVIEW,
|
||||
SUBSCRIPTION_CHANGE,
|
||||
SUBSCRIPTION_UPGRADE,
|
||||
SUBSCRIPTION_RESUME,
|
||||
USAGE_BARS,
|
||||
VAULT_LIST,
|
||||
VAULT_SOURCES,
|
||||
VAULT_SOURCE_SET,
|
||||
VAULT_UNLOCK,
|
||||
VAULT_LOCK,
|
||||
CONNECTORS_LIST,
|
||||
CONNECTORS_CONNECT,
|
||||
CONNECTORS_OPERATION_STATUS,
|
||||
CONNECTORS_CATALOG,
|
||||
CONNECTORS_ACCOUNTS,
|
||||
CONNECTORS_ACCOUNTS_REMOVE,
|
||||
CONNECTORS_POLICY_GET,
|
||||
CONNECTORS_POLICY_SET,
|
||||
CONNECTORS_TOOLS,
|
||||
CONNECTION_RESPOND,
|
||||
CONNECTORS_OPERATION_WAKE,
|
||||
SUBAGENT_LIST,
|
||||
SUBAGENT_TAIL,
|
||||
PROCESS_LIST,
|
||||
PROCESS_KILL,
|
||||
PROCESS_STOP,
|
||||
PLUGINS_MANAGE,
|
||||
PROJECTS_LIST,
|
||||
PROFILES_LIST,
|
||||
PROFILES_CONFIGURE,
|
||||
CONFIG_SET,
|
||||
CONFIG_GET,
|
||||
MODEL_OPTIONS,
|
||||
CLIENT_CAPABILITIES,
|
||||
COMMANDS_CATALOG,
|
||||
COMMAND_DISPATCH,
|
||||
SLASH_EXEC,
|
||||
FILE_ATTACH,
|
||||
IMAGE_ATTACH_BYTES,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SessionInterruptParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionCorrectionParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("text") val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionInterruptResult(
|
||||
val status: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionCorrectionResult(
|
||||
val status: String? = null,
|
||||
val text: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class SessionEventsSinceParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("last_seen") val lastSeen: Int? = null,
|
||||
) // NO profile field: profile is injected by WsProfileParams only.
|
||||
|
||||
// Tolerant reader: every field nullable/defaulted, mirrors what the old untyped parser tolerated.
|
||||
@Serializable
|
||||
data class SessionEventsSinceResult(
|
||||
val events: List<JsonElement>? = null,
|
||||
@SerialName("latest_seq") val latestSeq: Int? = null,
|
||||
val truncated: Boolean? = null,
|
||||
val epoch: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data object SessionListParams
|
||||
|
||||
@Serializable
|
||||
data class SessionIdParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionBranchParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("name") val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionBranchWholeParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("name") val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionCompressParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("focus_topic") val focusTopic: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptBtwParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("text") val text: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptBtwResult(
|
||||
@SerialName("task_id") val taskId: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SessionCreateParams(
|
||||
@SerialName("source") val source: String? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
@SerialName("title") val title: String? = null,
|
||||
@SerialName("hidden") val hidden: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionResumeParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptSubmitParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("text") val text: String,
|
||||
@SerialName("queued") val queued: Boolean? = null,
|
||||
)
|
||||
|
||||
// tolerant readers: all nullable with null defaults, only fields callers read
|
||||
@Serializable
|
||||
data class SessionCreateResult(
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
@SerialName("stored_session_id") val storedSessionId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SessionResumeResult(
|
||||
@SerialName("session_id") val sessionId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PromptSubmitResult(
|
||||
val status: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SubagentTailParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("subagent_id") val subagentId: String,
|
||||
)
|
||||
+12
@@ -51,6 +51,7 @@ class BackgroundConnectionController(
|
||||
status = status,
|
||||
isAutoReconnect = AuthManager.isAutoReconnect(),
|
||||
hasActiveNetwork = NetworkMonitor.isConnected.value,
|
||||
notifyCompletionsOptIn = AuthManager.isNotifySessionCompletions(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +99,17 @@ class BackgroundConnectionController(
|
||||
}
|
||||
}
|
||||
|
||||
/** The persistent opt-in was turned off while backgrounded (e.g. from the notification action). */
|
||||
fun onKeepConnectedDisabled() {
|
||||
val snapshot = snapshotProvider(false)
|
||||
if (!snapshot.appInForeground) {
|
||||
releaseLease()
|
||||
}
|
||||
// Force a re-post if the service stays up (pending reply) so the action button disappears.
|
||||
currentNotificationState = BackgroundNotificationState.None
|
||||
reconcileState()
|
||||
}
|
||||
|
||||
fun reconcileState() {
|
||||
val snapshot = snapshotProvider(false)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
|
||||
|
||||
@@ -14,6 +14,11 @@ data class BackgroundConnectionSnapshot(
|
||||
val status: ConnectionStatus,
|
||||
val isAutoReconnect: Boolean = true,
|
||||
val hasActiveNetwork: Boolean = true,
|
||||
/**
|
||||
* User opted into "notify when a session completes" — completions arrive
|
||||
* only over a live connection, so this implies the keep-connected lease.
|
||||
*/
|
||||
val notifyCompletionsOptIn: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
fun ineligible(
|
||||
@@ -77,8 +82,12 @@ object BackgroundConnectionPolicy {
|
||||
)
|
||||
}
|
||||
|
||||
// Demand check: need either a pending reply or persistent opt-in
|
||||
val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn
|
||||
// Demand check: need a pending reply or a persistent opt-in. The
|
||||
// completions notify opt-in implies the keep-connected lease — a
|
||||
// session can only be observed finishing over a live connection.
|
||||
val notifyCompletionsDemand = snapshot.notifyCompletionsOptIn
|
||||
val keepConnectedLease = snapshot.keepConnectedOptIn || notifyCompletionsDemand
|
||||
val hasDemand = snapshot.pendingReply || keepConnectedLease
|
||||
if (!hasDemand) {
|
||||
return BackgroundConnectionDecision(
|
||||
shouldHoldService = false,
|
||||
@@ -134,7 +143,7 @@ object BackgroundConnectionPolicy {
|
||||
|
||||
return BackgroundConnectionDecision(
|
||||
shouldHoldService = true,
|
||||
shouldHoldPersistentLease = snapshot.keepConnectedOptIn,
|
||||
shouldHoldPersistentLease = keepConnectedLease,
|
||||
notificationState = notificationState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,15 +9,21 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.m57.hermescontrol.MainActivity
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.ActiveSessionsResponse
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams
|
||||
import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -26,6 +32,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.serializer
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal data class MessageCompleteNotificationPlan(
|
||||
@@ -37,6 +46,87 @@ internal data class MessageCompleteNotificationPlan(
|
||||
val allowInlineReply: Boolean,
|
||||
)
|
||||
|
||||
internal data class CompletedSessionRef(
|
||||
val storedId: String,
|
||||
val title: String?,
|
||||
)
|
||||
|
||||
private const val TAG = "ChatNotificationService"
|
||||
|
||||
/** Short cap so a slow `session.active_list` cannot hold a completion alert hostage. */
|
||||
private const val ACTIVE_LIST_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** Where a background `message.complete` goes. */
|
||||
internal sealed interface MessageCompleteRoute {
|
||||
/** The reply flow: reply notification, then the foreground service is retired. */
|
||||
data object Reply : MessageCompleteRoute
|
||||
|
||||
/** A session the phone did not submit finished; the opt-in alert fires. */
|
||||
data object ForeignSession : MessageCompleteRoute
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a completion the way the service did before the opt-in existed:
|
||||
* toggle OFF must keep the legacy behavior exactly — every completion uses
|
||||
* the reply path, even when `ActiveSessionHolder` was cleared by a background
|
||||
* reconnect (dropping the completion would both hide the reply notification
|
||||
* and strand the foreground service).
|
||||
*/
|
||||
internal fun messageCompleteRoute(
|
||||
holderSessionId: String?,
|
||||
toggleOn: Boolean,
|
||||
eventSessionId: String?,
|
||||
): MessageCompleteRoute =
|
||||
if (!toggleOn || (!eventSessionId.isNullOrBlank() && eventSessionId == holderSessionId)) {
|
||||
MessageCompleteRoute.Reply
|
||||
} else {
|
||||
MessageCompleteRoute.ForeignSession
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WS event's runtime session id to the STORED session row the app can
|
||||
* open. The gateway emits `message.complete` with its ephemeral transport id
|
||||
* (`uuid4().hex[:8]`), while History/`switchSession` use the persisted
|
||||
* `session_key` — a runtime id taps into a blank chat. `session.active_list`
|
||||
* lists every live session (idle included) with both ids plus the title.
|
||||
*
|
||||
* Accepts the typed RPC result (`JsonElement`) and its pre-JSON `Map` shape,
|
||||
* mirroring `SessionLiveStatusDecoder.decodeResponse`.
|
||||
*/
|
||||
internal fun parseActiveSessionLookup(
|
||||
raw: Any?,
|
||||
runtimeId: String,
|
||||
): CompletedSessionRef? {
|
||||
if (runtimeId.isBlank()) return null
|
||||
val element =
|
||||
when (raw) {
|
||||
is JsonObject -> {
|
||||
raw
|
||||
}
|
||||
|
||||
is String -> {
|
||||
runCatching { lookupJson.parseToJsonElement(raw) }.getOrNull() ?: return null
|
||||
}
|
||||
|
||||
else -> {
|
||||
return null
|
||||
}
|
||||
}
|
||||
val response =
|
||||
runCatching {
|
||||
lookupJson.decodeFromJsonElement(serializer<ActiveSessionsResponse>(), element)
|
||||
}.getOrNull() ?: return null
|
||||
for (item in response.sessions) {
|
||||
if (item.id?.trim() != runtimeId) continue
|
||||
val storedId = item.sessionKey?.trim().orEmpty()
|
||||
if (storedId.isBlank()) return null
|
||||
return CompletedSessionRef(storedId, item.title?.trim()?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val lookupJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
internal fun messageCompleteNotificationPlan(
|
||||
event: WsEvent.MessageComplete,
|
||||
targetSessionId: String?,
|
||||
@@ -106,6 +196,9 @@ class ChatNotificationService : Service() {
|
||||
internal const val NOTIFICATION_ID = 1
|
||||
internal const val PENDING_NOTIFICATION_ID = 2
|
||||
|
||||
/** Session-complete notifications: one fixed id, tagged per stored session. */
|
||||
private const val SESSION_COMPLETE_NOTIFICATION_ID = 10_000
|
||||
|
||||
private val isAppInForeground = AtomicBoolean(false)
|
||||
internal val lifecycle = ForegroundServiceLifecycle()
|
||||
|
||||
@@ -152,43 +245,99 @@ class ChatNotificationService : Service() {
|
||||
if (!isAppInForeground.get()) {
|
||||
when (event) {
|
||||
is WsEvent.MessageComplete -> {
|
||||
val targetSessionId =
|
||||
event.storedSessionId
|
||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
||||
val plan =
|
||||
messageCompleteNotificationPlan(
|
||||
event = event,
|
||||
targetSessionId = targetSessionId,
|
||||
newMessageText = getString(R.string.notif_new_message),
|
||||
failureText = getString(R.string.chat_reply_failed_title),
|
||||
)
|
||||
showReplyNotification(
|
||||
text = plan.text,
|
||||
sessionId = plan.sessionId,
|
||||
isReplyMessage = plan.isReplyMessage,
|
||||
completionId = plan.completionId,
|
||||
allowInlineReply = plan.allowInlineReply,
|
||||
// The durable REST row for this turn, when the
|
||||
// boundary armed before the prompt was submitted
|
||||
// still lets us name it unambiguously. Null is a
|
||||
// normal, safe outcome: the notification is then
|
||||
// never auto-dismissed from REST hydration, which
|
||||
// is strictly better than dismissing the wrong
|
||||
// duplicate reply.
|
||||
serverMessageId =
|
||||
plan.correlationText?.let {
|
||||
coalesceTurnRow(plan.sessionId, it)
|
||||
},
|
||||
)
|
||||
// The wait is over — retire the foreground
|
||||
// service. The reply notification above
|
||||
// replaces the persistent "waiting" one,
|
||||
// and the pendingReply flag is cleared by
|
||||
// HermesWsClient's own collector, so the
|
||||
// service is not restarted on the next
|
||||
// ON_STOP (issue #794).
|
||||
// A delayed completion must not retire a newer turn/start.
|
||||
BackgroundConnectionController.default.onReplyCompleted(generation)
|
||||
when (
|
||||
val route =
|
||||
messageCompleteRoute(
|
||||
holderSessionId = ActiveSessionHolder.activeSessionId.value,
|
||||
toggleOn = AuthManager.isNotifySessionCompletions(),
|
||||
eventSessionId = event.sessionId,
|
||||
)
|
||||
) {
|
||||
MessageCompleteRoute.Reply -> {
|
||||
val targetSessionId =
|
||||
event.storedSessionId
|
||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
||||
val plan =
|
||||
messageCompleteNotificationPlan(
|
||||
event = event,
|
||||
targetSessionId = targetSessionId,
|
||||
newMessageText = getString(R.string.notif_new_message),
|
||||
failureText = getString(R.string.chat_reply_failed_title),
|
||||
)
|
||||
showReplyNotification(
|
||||
text = plan.text,
|
||||
sessionId = plan.sessionId,
|
||||
isReplyMessage = plan.isReplyMessage,
|
||||
completionId = plan.completionId,
|
||||
allowInlineReply = plan.allowInlineReply,
|
||||
// The durable REST row for this turn, when the
|
||||
// boundary armed before the prompt was submitted
|
||||
// still lets us name it unambiguously. Null is a
|
||||
// normal, safe outcome: the notification is then
|
||||
// never auto-dismissed from REST hydration.
|
||||
serverMessageId =
|
||||
plan.correlationText?.let {
|
||||
coalesceTurnRow(plan.sessionId, it)
|
||||
},
|
||||
)
|
||||
// The wait is over: retire the foreground service
|
||||
// (issue #794). A delayed completion must not
|
||||
// retire a newer turn/start.
|
||||
BackgroundConnectionController.default.onReplyCompleted(generation)
|
||||
}
|
||||
|
||||
MessageCompleteRoute.ForeignSession -> {
|
||||
// Another session finished. Resolve its runtime
|
||||
// id to the STORED session row (the gateway's
|
||||
// transport id means nothing to History/chat);
|
||||
// without the stored id the tap would open a
|
||||
// blank chat, so skip instead. The notification
|
||||
// is plain — no inline reply, no reply-tracker
|
||||
// registration — and the service keeps listening
|
||||
// for the next completion (the opt-in demand
|
||||
// holds it).
|
||||
val ref =
|
||||
try {
|
||||
parseActiveSessionLookup(
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_ACTIVE_LIST,
|
||||
SessionActiveListParams(),
|
||||
timeoutMs = ACTIVE_LIST_TIMEOUT_MS,
|
||||
),
|
||||
event.sessionId.orEmpty(),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (ref != null) {
|
||||
// A failed turn must not read as success.
|
||||
val failure =
|
||||
replyFailureFromPayload(
|
||||
event.rawPayload,
|
||||
fallback = "Unknown gateway error",
|
||||
)
|
||||
val snippet =
|
||||
(failure?.details ?: event.text)
|
||||
.take(100)
|
||||
.replace("\n", " ")
|
||||
.ifBlank { getString(R.string.notif_new_message) }
|
||||
val body =
|
||||
if (failure != null) {
|
||||
getString(R.string.notif_session_failed, snippet)
|
||||
} else {
|
||||
getString(R.string.notif_session_complete, snippet)
|
||||
}
|
||||
showSessionCompleteNotification(ref, body)
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Session-complete notification skipped: no stored id for runtime ${event.sessionId}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ClarifyRequest -> {
|
||||
@@ -364,6 +513,35 @@ class ChatNotificationService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A completion from a session the phone did not submit (cron, another
|
||||
* client). Fires only when "notify on session completion" is on, with the
|
||||
* session's STORED id (tap opens the real transcript) and its title when
|
||||
* the gateway reported one. Plain alert, auto-cancel; the notification
|
||||
* uses the tag overload — per-session tag with one shared id — so tags
|
||||
* never collide and no session's alert can clobber a live reply
|
||||
* notification or tombstone the tracker.
|
||||
*/
|
||||
private fun showSessionCompleteNotification(
|
||||
ref: CompletedSessionRef,
|
||||
body: String,
|
||||
) {
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(this, CHAT_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(ref.title ?: getString(R.string.notif_title))
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(buildContentIntent(ref.storedId))
|
||||
.build()
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
|
||||
manager.notify(ref.storedId, SESSION_COMPLETE_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun buildContentIntent(sessionId: String?): PendingIntent {
|
||||
val intent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
@@ -383,15 +561,48 @@ class ChatNotificationService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildForegroundNotification(text: String): Notification =
|
||||
NotificationCompat
|
||||
.Builder(this, SERVICE_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(getString(R.string.notif_title))
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
private fun buildForegroundNotification(text: String): Notification {
|
||||
val openIntent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
setPackage(packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val builder =
|
||||
NotificationCompat
|
||||
.Builder(this, SERVICE_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(getString(R.string.notif_title))
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
openIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
|
||||
if (AuthManager.isKeepConnectedInBackground()) {
|
||||
val stopIntent =
|
||||
Intent(this, StopKeepConnectedReceiver::class.java).apply {
|
||||
action = StopKeepConnectedReceiver.ACTION_STOP_KEEP_CONNECTED
|
||||
setPackage(packageName)
|
||||
}
|
||||
builder.addAction(
|
||||
R.drawable.ic_notification,
|
||||
getString(R.string.notif_action_turn_off),
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
@@ -11,7 +11,9 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -25,6 +27,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
const val EXTRA_SESSION_ID = "extra_session_id"
|
||||
private const val REPLY_TIMEOUT_MS = 5_000L
|
||||
|
||||
/** Overall deadline for persisting and submitting one notification reply. */
|
||||
private const val PERSISTENCE_TIMEOUT_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
|
||||
* the probe sits on the reply path, so a slow or unreachable gateway must
|
||||
@@ -44,6 +49,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
internal open fun goAsyncCompat(): BroadcastReceiver.PendingResult = goAsync()
|
||||
|
||||
/** Overridable so tests can prove the timeout path without waiting 5s. */
|
||||
internal open val persistenceTimeoutMs: Long get() = PERSISTENCE_TIMEOUT_MS
|
||||
|
||||
/**
|
||||
* Test-friendly wrapper for notification creation. Override in tests to
|
||||
* avoid [NotificationCompat.Builder.build()] calling Android framework
|
||||
@@ -75,7 +83,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val pendingResult = goAsyncCompat()
|
||||
replyScope.launch {
|
||||
try {
|
||||
withTimeout(5000L) {
|
||||
withTimeout(persistenceTimeoutMs) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val db =
|
||||
com.m57.hermescontrol.data.local.HermesDatabase
|
||||
@@ -103,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val runtimeSessionId =
|
||||
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
||||
?: resumeSession(sessionId)
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to runtimeSessionId, "text" to replyText),
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
).await()
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.PROMPT_SUBMIT,
|
||||
params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText),
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val entity =
|
||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||
@@ -146,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
private suspend fun resumeSession(storedSessionId: String): String {
|
||||
val profile = AuthManager.activeProfileId.value
|
||||
val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
|
||||
val params =
|
||||
mutableMapOf<String, Any>(
|
||||
"session_id" to storedSessionId,
|
||||
"omit_messages" to true,
|
||||
SessionResumeParams(
|
||||
sessionId = storedSessionId,
|
||||
omitMessages = true,
|
||||
profile = profile,
|
||||
)
|
||||
if (!profile.isNullOrBlank()) {
|
||||
params["profile"] = profile
|
||||
}
|
||||
val result =
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.SESSION_RESUME,
|
||||
params,
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
).await() as? Map<*, *>
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.SESSION_RESUME,
|
||||
params = params,
|
||||
timeoutMs = REPLY_TIMEOUT_MS,
|
||||
)
|
||||
val runtimeSessionId =
|
||||
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
||||
result.sessionId?.takeIf { it.isNotBlank() }
|
||||
?: error("Resume returned no runtime session id")
|
||||
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
||||
return runtimeSessionId
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
|
||||
/**
|
||||
* Handles the "Turn off" action on the ongoing background notification:
|
||||
* disables "Stay connected in background" and lets the controller retire the
|
||||
* lease/service.
|
||||
*/
|
||||
class StopKeepConnectedReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
const val ACTION_STOP_KEEP_CONNECTED = "com.m57.hermescontrol.ACTION_STOP_KEEP_CONNECTED"
|
||||
}
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
if (intent.action != ACTION_STOP_KEEP_CONNECTED) return
|
||||
AuthManager.setKeepConnectedInBackground(false)
|
||||
BackgroundConnectionController.default.onKeepConnectedDisabled()
|
||||
}
|
||||
}
|
||||
@@ -91,3 +91,6 @@ fun parseProjectColor(raw: String?): Color? {
|
||||
if (h !in 0f..360f || s !in 0f..1f || l !in 0f..1f) return null
|
||||
return Color.hsl(h % 360f, s, l)
|
||||
}
|
||||
|
||||
/** Pure white glyph for filled accent controls (composer action button) when it is legible on the preset primary. */
|
||||
val GlyphWhite = Color(0xFFFFFFFF)
|
||||
@@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.luminance
|
||||
* Material You changes the *primary* / *surface* palette based on the user's
|
||||
* wallpaper, but success / warning / error / info colours must stay
|
||||
* semantically correct (green = good, red = bad) regardless of what the
|
||||
* wallpaper-derived palette would choose. This struct is provided via
|
||||
* [LocalHermesStatusColors] and should be used for all status indicators,
|
||||
* wallpaper-derived palette would choose.
|
||||
*
|
||||
* `onSuccess`/`onWarning`/`onError`/`onInfo` are foregrounds on the matching
|
||||
* status fills, not on their container colors. Reusable status badges use
|
||||
* these fill/on pairs; error-container content uses `onErrorContainer`.
|
||||
*
|
||||
* Use [LocalHermesStatusColors] for all status indicators,
|
||||
* badges, and semantic feedback colours.
|
||||
*/
|
||||
data class HermesStatusColors(
|
||||
|
||||
@@ -28,6 +28,9 @@ enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY }
|
||||
*
|
||||
* The Material error slots are derived from [status] — the theme author
|
||||
* defines semantic colors once and the scheme inherits them.
|
||||
* [primary] and the status error are also used directly for text on surfaces.
|
||||
* [outline] must contrast with [surface] and [surfaceContainerHighest], which
|
||||
* Material uses as the unchecked switch track.
|
||||
*/
|
||||
data class PaletteColors(
|
||||
val primary: Color,
|
||||
@@ -48,6 +51,20 @@ data class PaletteColors(
|
||||
val onSurface: Color,
|
||||
val surfaceVariant: Color,
|
||||
val onSurfaceVariant: Color,
|
||||
val surfaceDim: Color,
|
||||
val surfaceBright: Color,
|
||||
val primaryFixed: Color,
|
||||
val primaryFixedDim: Color,
|
||||
val onPrimaryFixed: Color,
|
||||
val onPrimaryFixedVariant: Color,
|
||||
val secondaryFixed: Color,
|
||||
val secondaryFixedDim: Color,
|
||||
val onSecondaryFixed: Color,
|
||||
val onSecondaryFixedVariant: Color,
|
||||
val tertiaryFixed: Color,
|
||||
val tertiaryFixedDim: Color,
|
||||
val onTertiaryFixed: Color,
|
||||
val onTertiaryFixedVariant: Color,
|
||||
val surfaceContainerLowest: Color,
|
||||
val surfaceContainerLow: Color,
|
||||
val surfaceContainer: Color,
|
||||
@@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme =
|
||||
surfaceVariant = c.surfaceVariant,
|
||||
onSurfaceVariant = c.onSurfaceVariant,
|
||||
surfaceTint = c.primary,
|
||||
surfaceDim = c.surfaceDim,
|
||||
surfaceBright = c.surfaceBright,
|
||||
primaryFixed = c.primaryFixed,
|
||||
primaryFixedDim = c.primaryFixedDim,
|
||||
onPrimaryFixed = c.onPrimaryFixed,
|
||||
onPrimaryFixedVariant = c.onPrimaryFixedVariant,
|
||||
secondaryFixed = c.secondaryFixed,
|
||||
secondaryFixedDim = c.secondaryFixedDim,
|
||||
onSecondaryFixed = c.onSecondaryFixed,
|
||||
onSecondaryFixedVariant = c.onSecondaryFixedVariant,
|
||||
tertiaryFixed = c.tertiaryFixed,
|
||||
tertiaryFixedDim = c.tertiaryFixedDim,
|
||||
onTertiaryFixed = c.onTertiaryFixed,
|
||||
onTertiaryFixedVariant = c.onTertiaryFixedVariant,
|
||||
surfaceContainerLowest = c.surfaceContainerLowest,
|
||||
surfaceContainerLow = c.surfaceContainerLow,
|
||||
surfaceContainer = c.surfaceContainer,
|
||||
@@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme =
|
||||
surfaceVariant = c.surfaceVariant,
|
||||
onSurfaceVariant = c.onSurfaceVariant,
|
||||
surfaceTint = c.primary,
|
||||
surfaceDim = c.surfaceDim,
|
||||
surfaceBright = c.surfaceBright,
|
||||
primaryFixed = c.primaryFixed,
|
||||
primaryFixedDim = c.primaryFixedDim,
|
||||
onPrimaryFixed = c.onPrimaryFixed,
|
||||
onPrimaryFixedVariant = c.onPrimaryFixedVariant,
|
||||
secondaryFixed = c.secondaryFixed,
|
||||
secondaryFixedDim = c.secondaryFixedDim,
|
||||
onSecondaryFixed = c.onSecondaryFixed,
|
||||
onSecondaryFixedVariant = c.onSecondaryFixedVariant,
|
||||
tertiaryFixed = c.tertiaryFixed,
|
||||
tertiaryFixedDim = c.tertiaryFixedDim,
|
||||
onTertiaryFixed = c.onTertiaryFixed,
|
||||
onTertiaryFixedVariant = c.onTertiaryFixedVariant,
|
||||
surfaceContainerLowest = c.surfaceContainerLowest,
|
||||
surfaceContainerLow = c.surfaceContainerLow,
|
||||
surfaceContainer = c.surfaceContainer,
|
||||
|
||||
@@ -12,7 +12,8 @@ All theme code is under:
|
||||
|
||||
```
|
||||
app/src/main/java/com/m57/hermescontrol/theme/
|
||||
├── Theme.kt # dispatcher — preset lookup + mode fallback
|
||||
├── Theme.kt # dispatcher — mode fallback + dynamic colors
|
||||
├── ThemeRegistry.kt # single preset/palette registry
|
||||
├── PaletteTemplate.kt # the template — the ONE shape every theme follows
|
||||
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
|
||||
├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*)
|
||||
@@ -24,7 +25,8 @@ app/src/main/java/com/m57/hermescontrol/theme/
|
||||
├── GruvboxScheme.kt
|
||||
├── CatppuccinScheme.kt
|
||||
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
|
||||
└── NordScheme.kt
|
||||
├── NordScheme.kt
|
||||
└── GarnetScheme.kt
|
||||
```
|
||||
|
||||
## The template — one shape for every theme
|
||||
@@ -55,7 +57,14 @@ val MyTheme =
|
||||
The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The
|
||||
Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`)
|
||||
are **derived** from the theme's status set — the theme author defines semantic
|
||||
colors once.
|
||||
colors once. `surfaceTint` comes from `primary`. Every other Material color role
|
||||
is required explicitly, including bright/dim surfaces and all fixed accent roles.
|
||||
No role may inherit Material baseline colors. Fixed accent and foreground roles
|
||||
stay identical across a preset’s shipped modes; Fixed/Dim may share an official
|
||||
swatch when the source palette has no accessible tonal ladder.
|
||||
|
||||
Prefer upstream swatches. Document every derived color beside its declaration
|
||||
with the official pairing’s measured contrast and why a derived role is needed.
|
||||
|
||||
## Preset conventions
|
||||
|
||||
@@ -69,8 +78,41 @@ colors once.
|
||||
light text would fail contrast.
|
||||
- **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/
|
||||
error/info by lightness only — consumers must pair with icons or labels.
|
||||
- Every shipped mode's error slot pairs are enforced at **>= 3:1 contrast** by
|
||||
`ThemePaletteTest` — a preset that breaks this fails the unit-test gate.
|
||||
- Normal-text foreground/background pairs, including accent containers, fixed
|
||||
accents, semantic status fills, errors, and the surface ladder, are enforced at **>= 4.5:1 contrast**
|
||||
by `ThemePaletteTest`. The separate full-bleed header guard remains >= 3:1.
|
||||
|
||||
Reusable `StatusBadge` and `SessionLiveStatusIndicator` use a shared
|
||||
`statusBadgeColors` mapping: status fills (`success`, `warning`, `error`, `info`)
|
||||
with their matching `on*` text/icons. `onSuccess` is not a foreground token for
|
||||
`successContainer`. Palette tests exercise this exact renderer mapping in every
|
||||
resolved mode, including fallback modes; neutral badges use Material tokens.
|
||||
|
||||
Enabled Material components also have a component-state contract:
|
||||
- Graphics/boundaries target >= 3:1: switch thumbs/tracks and borders, radio and
|
||||
checkbox states, slider active/inactive tracks, text-field borders, and segmented borders.
|
||||
- Primary/error text and field labels on surface/background target >= 4.5:1.
|
||||
- Segmented content uses the corresponding container foreground at >= 4.5:1.
|
||||
|
||||
`ThemeComponentContrastTest` checks the pinned Material role combinations in every
|
||||
resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose
|
||||
public color defaults and verifies rendered pixels for all fourteen preset/mode
|
||||
combinations, including focused/error fields. Disabled controls are deliberately
|
||||
excluded from the enabled contrast contract; Material uses opacity to mute them.
|
||||
Settings switches use Material defaults (`primary` track / `onPrimary` thumb),
|
||||
not the reversed `primaryContainer` track / `primary` thumb combination.
|
||||
|
||||
Outlines must contrast against both surface and the unchecked switch track
|
||||
(`surfaceContainerHighest`). Named palettes may need documented derived primary
|
||||
or error tones for direct text; fixed roles still preserve their upstream accents.
|
||||
|
||||
## Garnet
|
||||
|
||||
Garnet preserves Default's slate surface ladder, typography, and semantic status
|
||||
colors. Its seed is `#990000`, used as the light primary and dark primary
|
||||
container. Dark primary/text/icons use a lighter `#FFB4AB` for contrast; rose
|
||||
secondary and copper tertiary roles, their containers, fixed accents, inverse
|
||||
primary, and surface tint follow the new accent family. Both modes are shipped.
|
||||
|
||||
## Theme modes
|
||||
|
||||
@@ -87,37 +129,33 @@ doesn't ship — never to a sibling preset.
|
||||
|
||||
## The dispatcher
|
||||
|
||||
`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and
|
||||
a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme /
|
||||
status are read off it (with the mode fallback above):
|
||||
`ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution,
|
||||
UI selection, and palette tests use this registry. An exact enum/registry coverage
|
||||
test rejects missing, duplicate, or reordered entries. Android string resources
|
||||
stay in the UI’s exhaustive label mapping, outside the palette model.
|
||||
|
||||
```kotlin
|
||||
private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
|
||||
ThemePreset.DEFAULT -> DefaultTheme
|
||||
ThemePreset.MONOCHROME -> MonochromeTheme
|
||||
// …one line per preset…
|
||||
}
|
||||
```
|
||||
`Theme.kt` reads `preset.palette()` and applies the mode fallback above.
|
||||
|
||||
Dynamic (Material You) color on API 31+ can optionally override the preset scheme when
|
||||
`useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the
|
||||
active preset via `LocalHermesStatusColors`.
|
||||
active preset via `LocalHermesStatusColors`. The preset selector stays enabled
|
||||
and explains that it controls status colors while Material You supplies app colors.
|
||||
|
||||
## Adding a new theme
|
||||
|
||||
1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) —
|
||||
`buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly`
|
||||
for single-mode themes. Every status color must be bespoke — no aliasing.
|
||||
2. **Add 1 line** to `themeFor()` in `Theme.kt` + import the new val.
|
||||
2. **Register** the new palette in `ThemeRegistry.kt`.
|
||||
3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`.
|
||||
4. **Wire the UI**: add the label + selection in
|
||||
4. **Wire the UI**: add the exhaustive label mapping in
|
||||
`ui/settings/components/AppearanceSection.kt` (and any string resource).
|
||||
5. **Verify**:
|
||||
```bash
|
||||
./gradlew ktlintCheck testDebugUnitTest
|
||||
```
|
||||
`ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error
|
||||
slot pairs and the ThemeMode invariants — it must stay green.
|
||||
`ThemePaletteTest` checks text contrast, complete Material slot mapping,
|
||||
mode-independent fixed roles, registry coverage, and mode/fallback invariants.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -10,38 +10,19 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.m57.hermescontrol.theme.presets.AmoledTheme
|
||||
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
|
||||
import com.m57.hermescontrol.theme.presets.DefaultTheme
|
||||
import com.m57.hermescontrol.theme.presets.GruvboxTheme
|
||||
import com.m57.hermescontrol.theme.presets.MonochromeTheme
|
||||
import com.m57.hermescontrol.theme.presets.NordTheme
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class ThemePreference { SYSTEM, LIGHT, DARK }
|
||||
|
||||
@Serializable
|
||||
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD }
|
||||
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD, GARNET }
|
||||
|
||||
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
||||
val LocalChatFontScale = compositionLocalOf { 1.0f }
|
||||
|
||||
/**
|
||||
* The 6 preset themes — one file each, all built from the same
|
||||
* [PaletteTemplate] shape (see `PaletteTemplate.kt`).
|
||||
*/
|
||||
private fun themeFor(preset: ThemePreset): ThemePalette =
|
||||
when (preset) {
|
||||
ThemePreset.DEFAULT -> DefaultTheme
|
||||
ThemePreset.MONOCHROME -> MonochromeTheme
|
||||
ThemePreset.GRUVBOX -> GruvboxTheme
|
||||
ThemePreset.CATPPUCCIN -> CatppuccinTheme
|
||||
ThemePreset.AMOLED -> AmoledTheme
|
||||
ThemePreset.NORD -> NordTheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Material 3 [ColorScheme] for a preset + dark flag.
|
||||
*
|
||||
@@ -53,7 +34,7 @@ internal fun resolveColorScheme(
|
||||
preset: ThemePreset,
|
||||
darkTheme: Boolean,
|
||||
): ColorScheme {
|
||||
val theme = themeFor(preset)
|
||||
val theme = preset.palette()
|
||||
// DefaultTheme is FULL — its scheme is never null for either mode.
|
||||
return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme))
|
||||
}
|
||||
@@ -66,7 +47,7 @@ internal fun resolveStatusColors(
|
||||
preset: ThemePreset,
|
||||
darkTheme: Boolean,
|
||||
): HermesStatusColors {
|
||||
val theme = themeFor(preset)
|
||||
val theme = preset.palette()
|
||||
// DefaultTheme is FULL — its status colors are never null for either mode.
|
||||
return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import com.m57.hermescontrol.theme.presets.AmoledTheme
|
||||
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
|
||||
import com.m57.hermescontrol.theme.presets.DefaultTheme
|
||||
import com.m57.hermescontrol.theme.presets.GarnetTheme
|
||||
import com.m57.hermescontrol.theme.presets.GruvboxTheme
|
||||
import com.m57.hermescontrol.theme.presets.MonochromeTheme
|
||||
import com.m57.hermescontrol.theme.presets.NordTheme
|
||||
|
||||
internal data class ThemeDefinition(
|
||||
val preset: ThemePreset,
|
||||
val palette: ThemePalette,
|
||||
)
|
||||
|
||||
// Regression theme-contract: selection, resolution, and tests share this registry.
|
||||
internal val ThemeRegistry =
|
||||
listOf(
|
||||
ThemeDefinition(ThemePreset.DEFAULT, DefaultTheme),
|
||||
ThemeDefinition(ThemePreset.MONOCHROME, MonochromeTheme),
|
||||
ThemeDefinition(ThemePreset.GRUVBOX, GruvboxTheme),
|
||||
ThemeDefinition(ThemePreset.CATPPUCCIN, CatppuccinTheme),
|
||||
ThemeDefinition(ThemePreset.AMOLED, AmoledTheme),
|
||||
ThemeDefinition(ThemePreset.NORD, NordTheme),
|
||||
ThemeDefinition(ThemePreset.GARNET, GarnetTheme),
|
||||
)
|
||||
|
||||
internal fun ThemePreset.palette(): ThemePalette = ThemeRegistry.single { it.preset == this }.palette
|
||||
@@ -59,6 +59,20 @@ val AmoledTheme =
|
||||
onSurface = AmoledTextBright,
|
||||
surfaceVariant = AmoledContainerHigh,
|
||||
onSurfaceVariant = AmoledTextDim,
|
||||
surfaceDim = AmoledFloor,
|
||||
surfaceBright = AmoledContainerHighest,
|
||||
primaryFixed = AmoledTextBright,
|
||||
primaryFixedDim = AmoledTextBright,
|
||||
onPrimaryFixed = AmoledFloor,
|
||||
onPrimaryFixedVariant = AmoledFloor,
|
||||
secondaryFixed = AmoledSecondary,
|
||||
secondaryFixedDim = AmoledSecondary,
|
||||
onSecondaryFixed = AmoledFloor,
|
||||
onSecondaryFixedVariant = AmoledFloor,
|
||||
tertiaryFixed = AmoledTertiary,
|
||||
tertiaryFixedDim = AmoledTertiary,
|
||||
onTertiaryFixed = AmoledFloor,
|
||||
onTertiaryFixedVariant = AmoledFloor,
|
||||
surfaceContainerLowest = AmoledFloor,
|
||||
surfaceContainerLow = AmoledFloor,
|
||||
surfaceContainer = AmoledContainer,
|
||||
|
||||
@@ -19,7 +19,6 @@ private val MochaRed = Color(0xFFF38BA8)
|
||||
private val MochaText = Color(0xFFCDD6F4)
|
||||
private val MochaSubtext1 = Color(0xFFBAC2DE)
|
||||
private val MochaSubtext0 = Color(0xFFA6ADC8)
|
||||
private val MochaOverlay0 = Color(0xFF6C7086)
|
||||
private val MochaSurface1 = Color(0xFF45475A)
|
||||
private val MochaSurface0 = Color(0xFF313244)
|
||||
private val MochaBase = Color(0xFF1E1E2E)
|
||||
@@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private val LatteMauve = Color(0xFF8839EF)
|
||||
|
||||
// Derived role, not an upstream Catppuccin swatch: Latte Text/Pink is 3.02:1
|
||||
// and Text/Green is 2.39:1. Deeper ink reaches 5.88:1 and 4.65:1 respectively.
|
||||
private val LatteDeepInk = Color(0xFF202334)
|
||||
|
||||
// Derived role, not an upstream Catppuccin swatch: Latte Base/Blue is only 4.34:1.
|
||||
// White/Blue reaches 4.94:1 for normal text.
|
||||
private val LatteOnBlue = Color(0xFFFFFFFF)
|
||||
private val LatteBlue = Color(0xFF1E66F5)
|
||||
private val LattePink = Color(0xFFEA76CB)
|
||||
private val LatteGreen = Color(0xFF40A02B)
|
||||
private val LatteYellow = Color(0xFFDF8E1D)
|
||||
private val LatteRed = Color(0xFFD20F39)
|
||||
private val LatteText = Color(0xFF4C4F69)
|
||||
|
||||
// Official Latte Subtext0 keeps borders visible against Surface0 (3.20:1).
|
||||
private val LatteSubtext0 = Color(0xFF6C6F85)
|
||||
private val LatteOverlay0 = Color(0xFF9CA0B0)
|
||||
private val LatteText = Color(0xFF4C4F69)
|
||||
private val LatteSurface1 = Color(0xFFBCC0CC)
|
||||
private val LatteSurface0 = Color(0xFFCCD0DA)
|
||||
private val LatteBase = Color(0xFFEFF1F5)
|
||||
private val LatteMantle = Color(0xFFE6E9EF)
|
||||
private val LatteCrust = Color(0xFFDCE0E8)
|
||||
|
||||
// Bespoke tint used only for the light primary container — not part of
|
||||
// the official Catppuccin swatch set, kept local to this file.
|
||||
private val LattePrimaryContainer = Color(0xFFE6CAFA)
|
||||
private val LatteOnPrimaryContainer = Color(0xFF380075)
|
||||
// Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim
|
||||
// share official accents rather than inventing tonal steps; on-roles use Crust.
|
||||
// Latte surface containers stop at Surface0 so normal text stays >= 4.5:1.
|
||||
// Upstream: https://github.com/catppuccin/palette/blob/main/palette.json
|
||||
|
||||
/** Catppuccin theme — Mocha (dark) / Latte (light). */
|
||||
val CatppuccinTheme =
|
||||
@@ -73,6 +81,20 @@ val CatppuccinTheme =
|
||||
onSurface = MochaText,
|
||||
surfaceVariant = MochaSurface0,
|
||||
onSurfaceVariant = MochaSubtext0,
|
||||
surfaceDim = MochaBase,
|
||||
surfaceBright = MochaSurface1,
|
||||
primaryFixed = MochaMauve,
|
||||
primaryFixedDim = MochaMauve,
|
||||
onPrimaryFixed = MochaCrust,
|
||||
onPrimaryFixedVariant = MochaCrust,
|
||||
secondaryFixed = MochaBlue,
|
||||
secondaryFixedDim = MochaBlue,
|
||||
onSecondaryFixed = MochaCrust,
|
||||
onSecondaryFixedVariant = MochaCrust,
|
||||
tertiaryFixed = MochaPink,
|
||||
tertiaryFixedDim = MochaPink,
|
||||
onTertiaryFixed = MochaCrust,
|
||||
onTertiaryFixedVariant = MochaCrust,
|
||||
surfaceContainerLowest = MochaCrust,
|
||||
surfaceContainerLow = MochaMantle,
|
||||
surfaceContainer = MochaBase,
|
||||
@@ -81,7 +103,7 @@ val CatppuccinTheme =
|
||||
inverseSurface = MochaText,
|
||||
inverseOnSurface = MochaBase,
|
||||
inversePrimary = LatteMauve,
|
||||
outline = MochaOverlay0,
|
||||
outline = MochaSubtext0,
|
||||
outlineVariant = MochaSurface1,
|
||||
scrim = MochaCrust,
|
||||
status =
|
||||
@@ -105,48 +127,62 @@ val CatppuccinTheme =
|
||||
PaletteColors(
|
||||
primary = LatteMauve,
|
||||
onPrimary = LatteBase,
|
||||
primaryContainer = LattePrimaryContainer,
|
||||
onPrimaryContainer = LatteOnPrimaryContainer,
|
||||
primaryContainer = LatteBase,
|
||||
onPrimaryContainer = LatteMauve,
|
||||
secondary = LatteBlue,
|
||||
onSecondary = LatteBase,
|
||||
onSecondary = LatteOnBlue,
|
||||
secondaryContainer = LatteMantle,
|
||||
onSecondaryContainer = LatteText,
|
||||
tertiary = LattePink,
|
||||
onTertiary = LatteBase,
|
||||
tertiaryContainer = LatteSurface1,
|
||||
onTertiary = LatteDeepInk,
|
||||
tertiaryContainer = LatteSurface0,
|
||||
onTertiaryContainer = LatteText,
|
||||
background = LatteBase,
|
||||
onBackground = LatteText,
|
||||
surface = LatteBase,
|
||||
onSurface = LatteText,
|
||||
surfaceVariant = LatteSurface0,
|
||||
onSurfaceVariant = LatteSubtext0,
|
||||
onSurfaceVariant = LatteText,
|
||||
surfaceDim = LatteSurface0,
|
||||
surfaceBright = LatteBase,
|
||||
primaryFixed = MochaMauve,
|
||||
primaryFixedDim = MochaMauve,
|
||||
onPrimaryFixed = MochaCrust,
|
||||
onPrimaryFixedVariant = MochaCrust,
|
||||
secondaryFixed = MochaBlue,
|
||||
secondaryFixedDim = MochaBlue,
|
||||
onSecondaryFixed = MochaCrust,
|
||||
onSecondaryFixedVariant = MochaCrust,
|
||||
tertiaryFixed = MochaPink,
|
||||
tertiaryFixedDim = MochaPink,
|
||||
onTertiaryFixed = MochaCrust,
|
||||
onTertiaryFixedVariant = MochaCrust,
|
||||
surfaceContainerLowest = LatteCrust,
|
||||
surfaceContainerLow = LatteMantle,
|
||||
surfaceContainer = LatteBase,
|
||||
surfaceContainerHigh = LatteSurface0,
|
||||
surfaceContainerHighest = LatteSurface1,
|
||||
surfaceContainerHighest = LatteSurface0,
|
||||
inverseSurface = LatteText,
|
||||
inverseOnSurface = LatteBase,
|
||||
inversePrimary = MochaMauve,
|
||||
outline = LatteOverlay0,
|
||||
outline = LatteSubtext0,
|
||||
outlineVariant = LatteSurface1,
|
||||
scrim = LatteCrust,
|
||||
status =
|
||||
HermesStatusColors(
|
||||
success = LatteGreen,
|
||||
successContainer = LatteSurface0,
|
||||
onSuccess = LatteBase,
|
||||
onSuccess = LatteDeepInk,
|
||||
warning = LatteYellow,
|
||||
warningContainer = LatteSurface0,
|
||||
onWarning = LatteBase,
|
||||
onWarning = LatteDeepInk,
|
||||
error = LatteRed,
|
||||
errorContainer = LatteSurface0,
|
||||
onError = LatteBase,
|
||||
onErrorContainer = LatteRed,
|
||||
onErrorContainer = LatteText,
|
||||
info = LatteBlue,
|
||||
infoContainer = LatteSurface0,
|
||||
onInfo = LatteBase,
|
||||
onInfo = LatteOnBlue,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -73,6 +73,20 @@ val DefaultTheme =
|
||||
onSurface = SlateDarkFg1,
|
||||
surfaceVariant = SlateDarkBg1,
|
||||
onSurfaceVariant = SlateDarkFg3,
|
||||
surfaceDim = SlateDarkBg0,
|
||||
surfaceBright = SlateDarkBg4,
|
||||
primaryFixed = SlateBrightTeal,
|
||||
primaryFixedDim = SlateBrightTeal,
|
||||
onPrimaryFixed = SlateDarkBg0,
|
||||
onPrimaryFixedVariant = SlateDarkBg0,
|
||||
secondaryFixed = SlateBrightCyan,
|
||||
secondaryFixedDim = SlateBrightCyan,
|
||||
onSecondaryFixed = SlateDarkBg0,
|
||||
onSecondaryFixedVariant = SlateDarkBg0,
|
||||
tertiaryFixed = SlateBrightBlue,
|
||||
tertiaryFixedDim = SlateBrightBlue,
|
||||
onTertiaryFixed = SlateDarkBg0,
|
||||
onTertiaryFixedVariant = SlateDarkBg0,
|
||||
surfaceContainerLowest = SlateDarkBg0,
|
||||
surfaceContainerLow = SlateDarkBg1,
|
||||
surfaceContainer = SlateDarkBg2,
|
||||
@@ -121,6 +135,20 @@ val DefaultTheme =
|
||||
onSurface = SlateLightFg1,
|
||||
surfaceVariant = SlateLightBg1,
|
||||
onSurfaceVariant = SlateLightFg3,
|
||||
surfaceDim = SlateLightBg4,
|
||||
surfaceBright = SlateLightBg0,
|
||||
primaryFixed = SlateBrightTeal,
|
||||
primaryFixedDim = SlateBrightTeal,
|
||||
onPrimaryFixed = SlateDarkBg0,
|
||||
onPrimaryFixedVariant = SlateDarkBg0,
|
||||
secondaryFixed = SlateBrightCyan,
|
||||
secondaryFixedDim = SlateBrightCyan,
|
||||
onSecondaryFixed = SlateDarkBg0,
|
||||
onSecondaryFixedVariant = SlateDarkBg0,
|
||||
tertiaryFixed = SlateBrightBlue,
|
||||
tertiaryFixedDim = SlateBrightBlue,
|
||||
onTertiaryFixed = SlateDarkBg0,
|
||||
onTertiaryFixedVariant = SlateDarkBg0,
|
||||
surfaceContainerLowest = SlateLightBg0,
|
||||
surfaceContainerLow = SlateLightBg1,
|
||||
surfaceContainer = SlateLightBg2,
|
||||
@@ -129,7 +157,7 @@ val DefaultTheme =
|
||||
inverseSurface = SlateLightFg1,
|
||||
inverseOnSurface = SlateLightBg0,
|
||||
inversePrimary = SlateBrightTeal,
|
||||
outline = SlateGray,
|
||||
outline = SlateLightFg3,
|
||||
outlineVariant = SlateLightBg3,
|
||||
scrim = SlateLightBg0,
|
||||
status =
|
||||
|
||||
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user