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:
authored and GitHub committed 2026-10-01 11:19:09 +03:00
1 parent ed915d0816
commit 728daa9acd
315 files changed
+65173 -7909

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: hy4ri
ko_fi: m_5_7
+1
View File
@@ -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)
+27 -10
View File
@@ -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"
+6 -3
View File
@@ -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"
+28 -9
View File
@@ -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
+12
View File
@@ -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/).
+7 -4
View File
@@ -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.
+108 -111
View File
@@ -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
View File
@@ -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()
}
}
@@ -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 {
@@ -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))
}
@@ -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()
}
}
@@ -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")
}
}
@@ -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()
@@ -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,
)
}
}
@@ -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(
@@ -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 }
},
)
}
}
@@ -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) } }
}
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hermes Dev</string>
</resources>
+4
View File
@@ -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,
)
@@ -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