release: promote dev to main for v2.0.0 (#1426)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Lx-204 <15377891490@163.com> Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
315 files changed
+65173
-7909
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: hy4ri
|
||||||
|
ko_fi: m_5_7
|
||||||
@@ -27,6 +27,7 @@ Fixes #
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] PR is focused on one feature or one fix (no unrelated changes)
|
||||||
- [ ] Branch rebased onto `dev` (`git rebase origin/dev`)
|
- [ ] Branch rebased onto `dev` (`git rebase origin/dev`)
|
||||||
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first)
|
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first)
|
||||||
- [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job)
|
- [ ] `./gradlew testDebugUnitTest` green (or CI unit-tests job)
|
||||||
|
|||||||
@@ -97,11 +97,8 @@ jobs:
|
|||||||
- name: Add local bin to PATH
|
- name: Add local bin to PATH
|
||||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
- name: Run ktlintCheck
|
- name: Run ktlintCheck + checkColorLiterals (hardcoded Color guard, issue #622)
|
||||||
run: ./gradlew ktlintCheck
|
run: ./gradlew ktlintCheck checkColorLiterals --continue
|
||||||
|
|
||||||
- name: Run checkColorLiterals (hardcoded Color guard, issue #622)
|
|
||||||
run: ./gradlew checkColorLiterals
|
|
||||||
|
|
||||||
- name: Upload ktlint reports
|
- name: Upload ktlint reports
|
||||||
if: always()
|
if: always()
|
||||||
@@ -185,6 +182,25 @@ jobs:
|
|||||||
- name: Run test
|
- name: Run test
|
||||||
run: ./gradlew 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
|
- name: Upload test reports
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
@@ -194,11 +210,11 @@ jobs:
|
|||||||
retention-days: 7
|
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:
|
build:
|
||||||
name: Build Debug APK
|
name: Build Debug APK
|
||||||
needs: [changes, ktlint, android-lint, unit-tests]
|
needs: [changes]
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -241,7 +257,7 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
release-compile:
|
release-compile:
|
||||||
name: Release Compile Check
|
name: Release Compile Check
|
||||||
needs: [changes, ktlint, android-lint, unit-tests]
|
needs: [changes]
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -339,7 +355,7 @@ jobs:
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
ci-summary:
|
ci-summary:
|
||||||
name: 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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
if: always()
|
if: always()
|
||||||
@@ -355,11 +371,12 @@ jobs:
|
|||||||
TEST_RESULT: ${{ needs.unit-tests.result }}
|
TEST_RESULT: ${{ needs.unit-tests.result }}
|
||||||
BUILD_RESULT: ${{ needs.build.result }}
|
BUILD_RESULT: ${{ needs.build.result }}
|
||||||
RELEASE_COMPILE_RESULT: ${{ needs.release-compile.result }}
|
RELEASE_COMPILE_RESULT: ${{ needs.release-compile.result }}
|
||||||
|
INSTRUMENTED_RESULT: ${{ needs.instrumented-tests.result }}
|
||||||
run: |
|
run: |
|
||||||
LABEL="ci-red"
|
LABEL="ci-red"
|
||||||
FAILED=0
|
FAILED=0
|
||||||
FAILED_JOBS=""
|
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%%:*}"
|
name="${entry%%:*}"
|
||||||
result="${entry#*:}"
|
result="${entry#*:}"
|
||||||
echo "::notice::$name = $result"
|
echo "::notice::$name = $result"
|
||||||
|
|||||||
@@ -54,10 +54,13 @@ jobs:
|
|||||||
# Strip leading 'v' for versionName
|
# Strip leading 'v' for versionName
|
||||||
VERSION="${TAG#v}"
|
VERSION="${TAG#v}"
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
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%%-*}"
|
BASE_VERSION="${VERSION%%-*}"
|
||||||
CODE=$(echo "$BASE_VERSION" | tr -d '.' | sed 's/^0*//')
|
IFS='.' read -r MAJOR MINOR PATCH _ <<< "$BASE_VERSION"
|
||||||
[ -z "$CODE" ] && CODE=0
|
MAJOR="${MAJOR:-0}"
|
||||||
|
MINOR="${MINOR:-0}"
|
||||||
|
PATCH="${PATCH:-0}"
|
||||||
|
CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||||
echo "code=$CODE" >> "$GITHUB_OUTPUT"
|
echo "code=$CODE" >> "$GITHUB_OUTPUT"
|
||||||
if [[ "$VERSION" == *"-"* ]]; then
|
if [[ "$VERSION" == *"-"* ]]; then
|
||||||
PRERELEASE="true"
|
PRERELEASE="true"
|
||||||
|
|||||||
@@ -137,6 +137,21 @@ explicitly disconnected on logout and reconnected after login:
|
|||||||
The singleton's `connect()` has a guard (`if connected → skip`) so it's safe to
|
The singleton's `connect()` has a guard (`if connected → skip`) so it's safe to
|
||||||
call unconditionally.
|
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
|
### Shared Components
|
||||||
|
|
||||||
- **`HermesScaffold`** — drawer-aware Scaffold + TopAppBar with refresh slot,
|
- **`HermesScaffold`** — drawer-aware Scaffold + TopAppBar with refresh slot,
|
||||||
@@ -197,8 +212,8 @@ for the current inset implementation.
|
|||||||
|
|
||||||
### Theme
|
### Theme
|
||||||
|
|
||||||
`Theme.kt` uses a preset-based theme system with 6 built-in presets: Default,
|
`Theme.kt` uses a preset-based theme system with 7 built-in presets: Default,
|
||||||
Monochrome, Gruvbox, Catppuccin, AMOLED, and Nord. Each preset provides light
|
Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, and Garnet. Each preset provides light
|
||||||
and dark color schemes plus matching semantic status colors (AMOLED is
|
and dark color schemes plus matching semantic status colors (AMOLED is
|
||||||
dark-only — light mode falls back to the default theme).
|
dark-only — light mode falls back to the default theme).
|
||||||
Every preset is a `ThemePalette` template fill (`PaletteTemplate.kt`): raw
|
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
|
(`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local
|
||||||
`val` at the composable scope first.
|
`val` at the composable scope first.
|
||||||
- Don't add new screens without checking if an existing one already covers the
|
- 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
|
- **⚠ 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
|
`main/` source code.** The CI `release-compile` job catches this, but save the
|
||||||
cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's
|
cycle. `okhttp3.logging.HttpLoggingInterceptor` is the classic example — it's
|
||||||
@@ -286,22 +301,25 @@ com.m57.hermescontrol/
|
|||||||
│ ├── local/ AuthManager, Room (ChatMessageEntity/Dao, HermesDatabase), AnalyticsCacheStore
|
│ ├── local/ AuthManager, Room (ChatMessageEntity/Dao, HermesDatabase), AnalyticsCacheStore
|
||||||
│ ├── model/ 40+ data classes for API responses + requests
|
│ ├── model/ 40+ data classes for API responses + requests
|
||||||
│ ├── remote/ ApiClient, Retrofit service, OkHttp provider, cookie management
|
│ ├── remote/ ApiClient, Retrofit service, OkHttp provider, cookie management
|
||||||
|
│ ├── repository/ KanbanRepository, KanbanSyncCoordinator, VoiceNoteRepository
|
||||||
│ ├── session/ ActiveSessionHolder
|
│ ├── session/ ActiveSessionHolder
|
||||||
|
│ ├── update/ AppUpdateChecker, AppUpdateCache, AppUpdateState
|
||||||
│ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository
|
│ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository
|
||||||
├── notification/ ChatNotificationService, NotificationReplyReceiver
|
├── notification/ ChatNotificationService, NotificationReplyReceiver
|
||||||
├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors
|
├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors
|
||||||
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord
|
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows
|
│ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows
|
||||||
│ └── 28 feature packages (achievements, analytics, authlogin, billing, channels,
|
│ └── 31 feature packages (achievements, analytics, authlogin, billing, bots,
|
||||||
│ chat, config, connect, cron, gateway, kanban, keys, landing, logs, mcp,
|
│ channels, chat, config, connect, connectors, cron, files, gateway, kanban,
|
||||||
│ model, pairing, plugins, process, profiles, providers, sessions, settings,
|
│ keys, landing, logs, mcp, memory, model, pairing, plugins, process,
|
||||||
│ skills, system, toolsets, webhooks)
|
│ profiles, providers, sessions, settings, skills, system, toolsets,
|
||||||
|
│ webhooks)
|
||||||
├── util/ CronExpressionFormatter, LocaleContextWrapper
|
├── util/ CronExpressionFormatter, LocaleContextWrapper
|
||||||
├── HermesControlApp.kt Application class
|
├── HermesControlApp.kt Application class
|
||||||
├── Navigation.kt Drawer + NavDisplay + entry wiring
|
├── Navigation.kt Drawer + NavDisplay + entry wiring
|
||||||
├── NavigationController.kt Central navigation guard (dedup)
|
├── 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
|
├── ScreenRegistry.kt entry<T> registrations for all NavKeys
|
||||||
└── MainActivity.kt
|
└── MainActivity.kt
|
||||||
```
|
```
|
||||||
@@ -311,6 +329,7 @@ com.m57.hermescontrol/
|
|||||||
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
|
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
|
||||||
- [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
|
- [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
|
- [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
|
- [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/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
|
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs
|
||||||
@@ -22,6 +22,8 @@ If an open PR already covers it, review or improve that one instead of opening a
|
|||||||
|
|
||||||
Every change must go through a pull request (PR) targeting the `dev` staging branch. Do not push directly to `main` or `dev`.
|
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.
|
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:
|
2. **Create a branch** off `dev` using the following naming convention:
|
||||||
- Features: `feat/short-description` (optionally `feat/issue-N-description`)
|
- 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.
|
- 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.
|
- 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
|
## Dependency & Build Hygiene
|
||||||
@@ -124,6 +135,7 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
|
|||||||
Before submitting your PR, please verify:
|
Before submitting your PR, please verify:
|
||||||
|
|
||||||
- [ ] I searched open/closed PRs + issues for duplicates (see *Before You Start*).
|
- [ ] 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`).
|
- [ ] Branch is rebased onto current `dev` (`git rebase origin/dev`).
|
||||||
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first — no hand edits).
|
- [ ] `./gradlew ktlintCheck` passes (ran `ktlintFormat` first — no hand edits).
|
||||||
- [ ] `checkColorLiterals` passes (no hardcoded Color literals outside theme/).
|
- [ ] `checkColorLiterals` passes (no hardcoded Color literals outside theme/).
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Android layout dimensions use `dp`; text sizes, line heights, and tracking use `
|
|||||||
|
|
||||||
| Concern | Authoritative source |
|
| 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) |
|
| 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/) |
|
| 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) |
|
| 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.
|
- **Core Principle**: Zero fluff. Fast render cycles, explicit states, flat surfaces, and zero visual ambiguity.
|
||||||
|
|
||||||
## Colors
|
## 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**:
|
1. **Surfaces**:
|
||||||
- `MaterialTheme.colorScheme.background`: Main canvas and full-bleed chat background.
|
- `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.
|
- **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)
|
[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
|
guards normal-text slot pairs, full-bleed prose/header contrast, complete Material
|
||||||
invariants. Those tests are useful but do not establish contrast or accessibility
|
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.
|
compliance for every rendered component, alpha blend, or dynamic palette.
|
||||||
Record device/emulator steps and observed results for changed UI; explicitly name
|
Record device/emulator steps and observed results for changed UI; explicitly name
|
||||||
any gate that could not be verified.
|
any gate that could not be verified.
|
||||||
@@ -30,13 +30,9 @@
|
|||||||
|
|
||||||
## Overview
|
## 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.
|
||||||
|
|
||||||
---
|
It connects to the Hermes dashboard (REST API and WebSocket gateway), so you need a running dashboard that your phone can reach.
|
||||||
|
|
||||||
## Support the project
|
|
||||||
|
|
||||||
If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi](https://ko-fi.com/m_5_7).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -61,79 +57,41 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-Time Chat:** Message your agent with Room-backed local database history and inline reply notifications.
|
- **Chat:** Talk to your agent. History is stored locally in Room, and notifications support inline replies.
|
||||||
- **System Config:** Manage active profiles, installed skills, plugins, toolsets, and LLM model/provider selections.
|
- **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.
|
- **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.
|
- **Gateway status:** Check the 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.
|
- **Productivity:** Manage tasks on Kanban boards, track agent milestones, and browse session history.
|
||||||
- **Analytics & Billing:** Usage analytics dashboard and billing/subscription management.
|
- **Analytics and billing:** See usage analytics and manage billing or subscriptions.
|
||||||
- **Theming:** 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord) plus Material You dynamic colors on supported devices.
|
- **Theming:** Seven built-in presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet), 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.
|
- **Material 3 design:** Pull-to-refresh, a scroll-aware top bar, and customizable bottom navigation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## 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).
|
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.
|
2. Start your Hermes dashboard on a host your phone can reach.
|
||||||
3. Follow [Authentication](#authentication) below to connect. Use a trusted network for plain HTTP/WS connections.
|
3. Open the app, tap **Sign in**, and follow [Authentication](#authentication).
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
### 1. Start the dashboard
|
||||||
|
|
||||||
On your host machine, start the dashboard:
|
On the host machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
hermes dashboard # loopback (127.0.0.1:9119) — no auth needed
|
hermes dashboard # loopback (127.0.0.1:9119) — no auth needed
|
||||||
hermes dashboard --host 0.0.0.0 # LAN — requires auth
|
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
|
```yaml
|
||||||
dashboard:
|
dashboard:
|
||||||
@@ -144,14 +102,17 @@ dashboard:
|
|||||||
|
|
||||||
### 2. Connect the app
|
### 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 the same machine, bound to loopback)
|
||||||
| -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **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 |
|
|
||||||
|
|
||||||
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
|
### 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
|
must accept the service token. These headers authenticate with Cloudflare; you still
|
||||||
need to complete Hermes authentication.
|
need to complete Hermes authentication.
|
||||||
|
|
||||||
You can add other custom header names and values in the same editor. To edit or
|
**Other headers.** You can add any custom header name and value in the same editor.
|
||||||
remove saved headers, open **Custom headers** from login or the saved connection's
|
To edit or remove saved headers, open **Custom headers** from login, or from the saved
|
||||||
edit dialog in Settings. Values are masked and stored in encrypted preferences.
|
connection's edit dialog in Settings. Values are masked and stored in encrypted
|
||||||
Saving an empty list removes the headers for that URL.
|
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,
|
**Where headers are sent.**
|
||||||
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
|
- Profiles with the same server URL share headers.
|
||||||
scope do not receive the credentials; WebSocket redirects are not followed.
|
- They apply to probes, login, API and media requests, and WebSocket handshakes.
|
||||||
The app manages `Authorization`, `Cookie`, and transport headers itself.
|
- 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
|
### 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.
|
Have multiple gateways? Switch between them in **Settings → Connection profiles**. Each profile stores its own host, port, and token.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## 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).
|
Copyright © 2026 M57 (Hy4ri).
|
||||||
|
|
||||||
This project is licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details.
|
Licensed under the Apache License, Version 2.0. See the [LICENSE](LICENSE) file for details.
|
||||||
+17
-2
@@ -85,18 +85,26 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
versionNameSuffix = "-dev"
|
||||||
|
|
||||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
|
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
signingConfig = signingConfigs["release"]
|
signingConfig = signingConfigs["release"]
|
||||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
|
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
|
||||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
@@ -309,6 +317,13 @@ tasks.named("check") {
|
|||||||
dependsOn("checkColorLiterals")
|
dependsOn("checkColorLiterals")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> {
|
val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
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 = 10, role = "assistant", content = JsonPrimitive("Done")),
|
||||||
SessionMessage(id = 52, 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("Older duplicate must stay unmapped", null, messages[0].completionId)
|
||||||
assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].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 androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.TranscriptUiState
|
||||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -61,6 +62,10 @@ class ChatScreenTest {
|
|||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).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
|
every { mockViewModel.connectionOperationState } returns
|
||||||
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||||
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
// ActionProgressDialog collects the controller's StateFlow — a relaxed
|
||||||
@@ -110,6 +115,10 @@ class ChatScreenTest {
|
|||||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).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
|
every { mockViewModel.connectionOperationState } returns
|
||||||
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||||
every { mockViewModel.actionProgress } returns
|
every { mockViewModel.actionProgress } returns
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class ChatSearchRenderingTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reasoningMatchExpandsCollapsedCardAndShowsTheHit() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
ReasoningCard(
|
||||||
|
reasoningText = "a hidden needle in the trace",
|
||||||
|
searchQuery = "needle",
|
||||||
|
isCurrentMatch = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("a hidden needle in the trace").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toolMatchRemainsVisibleInCollapsedHeader() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
ToolBubble(
|
||||||
|
message = ChatMessage(role = MessageRole.TOOL, content = "opaque", toolName = "terminal"),
|
||||||
|
searchQuery = "terminal",
|
||||||
|
isCurrentMatch = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("terminal").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -1,8 +1,10 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
@@ -30,4 +32,50 @@ class MarkdownHeadingRenderingTest {
|
|||||||
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
|
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
|
||||||
composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
|
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
|
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.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -32,4 +40,26 @@ class MarkdownTableRenderingTest {
|
|||||||
composeTestRule.onNodeWithText("**Name**").assertDoesNotExist()
|
composeTestRule.onNodeWithText("**Name**").assertDoesNotExist()
|
||||||
composeTestRule.onNodeWithText("`code`").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)
|
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
|
@Test
|
||||||
fun invalidSourceShowsRetryAndCanClose() {
|
fun invalidSourceShowsRetryAndCanClose() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
|
|||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import androidx.compose.ui.test.SemanticsMatcher
|
||||||
|
import androidx.compose.ui.test.assert
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsNotSelected
|
||||||
|
import androidx.compose.ui.test.assertIsSelected
|
||||||
|
import androidx.compose.ui.test.hasSetTextAction
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.ui.chat.ClarifyQuestionUi
|
||||||
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ClarifyBubbleTest {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clickingProvidedChoice_invokesOnRespondSingleWithExactChoice() {
|
||||||
|
var respondedSingle: String? = null
|
||||||
|
var respondedBatch: Map<String, String>? = null
|
||||||
|
var dismissed = false
|
||||||
|
|
||||||
|
val clarifyRequest =
|
||||||
|
ClarifyUi(
|
||||||
|
text = "Which environment should be targeted?",
|
||||||
|
options = listOf("Staging", "Production"),
|
||||||
|
clarifyId = "req-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
compose.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ClarifyBubble(
|
||||||
|
clarifyRequest = clarifyRequest,
|
||||||
|
onRespondSingle = { respondedSingle = it },
|
||||||
|
onRespondBatch = { respondedBatch = it },
|
||||||
|
onDismiss = { dismissed = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.onNodeWithText("Staging").assertIsDisplayed().performClick()
|
||||||
|
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("Staging", respondedSingle)
|
||||||
|
assertNull(respondedBatch)
|
||||||
|
assertEquals(false, dismissed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clickingDismiss_invokesOnDismissWithoutResponse() {
|
||||||
|
var respondedSingle: String? = null
|
||||||
|
var respondedBatch: Map<String, String>? = null
|
||||||
|
var dismissed = false
|
||||||
|
|
||||||
|
val dismissLabel = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.chat_dismiss)
|
||||||
|
|
||||||
|
val clarifyRequest =
|
||||||
|
ClarifyUi(
|
||||||
|
text = "Do you want to run database migrations?",
|
||||||
|
options = listOf("Yes", "No"),
|
||||||
|
clarifyId = "req-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
compose.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ClarifyBubble(
|
||||||
|
clarifyRequest = clarifyRequest,
|
||||||
|
onRespondSingle = { respondedSingle = it },
|
||||||
|
onRespondBatch = { respondedBatch = it },
|
||||||
|
onDismiss = { dismissed = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose.onNodeWithText(dismissLabel).assertIsDisplayed().performClick()
|
||||||
|
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals(true, dismissed)
|
||||||
|
assertNull(respondedSingle)
|
||||||
|
assertNull(respondedBatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun replacingRequestIdentity_resetsEnteredTextAndSelection() {
|
||||||
|
val sharedQuestion =
|
||||||
|
ClarifyQuestionUi(
|
||||||
|
qid = "q-common",
|
||||||
|
question = "Which build targets and extras should be enabled?",
|
||||||
|
choices = listOf("APK", "AAB", "Native Symbols"),
|
||||||
|
multiSelect = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
var currentRequest by mutableStateOf(
|
||||||
|
ClarifyUi(
|
||||||
|
text = sharedQuestion.question,
|
||||||
|
clarifyId = "req-1",
|
||||||
|
questions = listOf(sharedQuestion),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
compose.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ClarifyBubble(
|
||||||
|
clarifyRequest = currentRequest,
|
||||||
|
onRespondSingle = {},
|
||||||
|
onRespondBatch = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose
|
||||||
|
.onNodeWithText("APK")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.assertIsNotSelected()
|
||||||
|
.performClick()
|
||||||
|
compose.onNodeWithText("APK").assertIsSelected()
|
||||||
|
|
||||||
|
val textInputNode = compose.onNode(hasSetTextAction()).assertIsDisplayed()
|
||||||
|
textInputNode.performTextInput("--dry-run")
|
||||||
|
textInputNode.assert(hasEditableText("--dry-run"))
|
||||||
|
|
||||||
|
// Replace request identity while retaining identical qid, question, and options
|
||||||
|
compose.runOnIdle {
|
||||||
|
currentRequest =
|
||||||
|
ClarifyUi(
|
||||||
|
text = sharedQuestion.question,
|
||||||
|
clarifyId = "req-2",
|
||||||
|
questions = listOf(sharedQuestion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected choice and typed text must reset under new clarifyId
|
||||||
|
compose.onNodeWithText("APK").assertIsDisplayed().assertIsNotSelected()
|
||||||
|
compose.onNode(hasSetTextAction()).assertIsDisplayed().assert(hasEditableText(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field merges its placeholder label into Text, so assertTextEquals can
|
||||||
|
// never match; compare only the typed value.
|
||||||
|
private fun hasEditableText(value: String) =
|
||||||
|
SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString(value))
|
||||||
|
}
|
||||||
+26
-2
@@ -50,8 +50,8 @@ class ComposerReadinessTest {
|
|||||||
compose.runOnIdle { assertEquals(0, sends) }
|
compose.runOnIdle { assertEquals(0, sends) }
|
||||||
compose.runOnIdle { ready.value = true }
|
compose.runOnIdle { ready.value = true }
|
||||||
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
|
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
|
||||||
// Ready + typing = an interruptible generation: Stop replaces the
|
// Ready + typing = an interruptible generation: Send keeps the primary
|
||||||
// action glyph while queue-send stays available in the flat slot.
|
// action slot and Stop moves to the flat slot beside it.
|
||||||
compose.onNodeWithTag("stop_button").assertExists()
|
compose.onNodeWithTag("stop_button").assertExists()
|
||||||
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
|
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
|
||||||
compose.runOnIdle { assertEquals(1, sends) }
|
compose.runOnIdle { assertEquals(1, sends) }
|
||||||
@@ -60,4 +60,28 @@ class ComposerReadinessTest {
|
|||||||
compose.onNodeWithTag("send_button").assertIsNotEnabled()
|
compose.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||||
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
||||||
|
compose.setContent {
|
||||||
|
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
|
ChatInputBar(
|
||||||
|
inputFieldValue = input,
|
||||||
|
onInputChange = { input = it },
|
||||||
|
onSend = {},
|
||||||
|
onMicTap = {},
|
||||||
|
isListening = false,
|
||||||
|
isAgentTyping = true,
|
||||||
|
canInterrupt = true,
|
||||||
|
isConnected = true,
|
||||||
|
commandCatalog = CommandCatalog(),
|
||||||
|
isSessionReady = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("stop_button").assertExists()
|
||||||
|
compose.onNodeWithTag("send_button").assertDoesNotExist()
|
||||||
|
compose.onNodeWithTag("chat_input").performTextInput("next")
|
||||||
|
compose.onNodeWithTag("send_button").assertExists()
|
||||||
|
compose.onNodeWithTag("stop_button").assertExists()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class CopyFeedbackTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CopyFeedbackHarness(
|
||||||
|
tagPrefix: String,
|
||||||
|
stateReceiver: (MutableState<Boolean>) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val feedbackState = rememberCopyFeedback()
|
||||||
|
stateReceiver(feedbackState)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = if (feedbackState.value) "Copied" else "Idle",
|
||||||
|
modifier = Modifier.testTag("${tagPrefix}_status"),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Trigger",
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.testTag("${tagPrefix}_trigger")
|
||||||
|
.clickable { feedbackState.value = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initialStateIsFalse() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
CopyFeedbackHarness(tagPrefix = "feedback")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("feedback_status")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.assertTextEquals("Idle")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingTrueShowsFeedbackAndResetsAfter1500Ms() {
|
||||||
|
composeTestRule.mainClock.autoAdvance = false
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
CopyFeedbackHarness(tagPrefix = "feedback")
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||||
|
|
||||||
|
// Advance 1400ms - should still be true (100ms before 1500ms timeout)
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(1400L)
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||||
|
|
||||||
|
// Advance another 100ms to hit exactly 1500ms
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(100L)
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingTrueAgainWhileAlreadyTrueDoesNotRestartActiveTimer() {
|
||||||
|
composeTestRule.mainClock.autoAdvance = false
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
CopyFeedbackHarness(tagPrefix = "feedback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger at t = 0ms
|
||||||
|
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||||
|
|
||||||
|
// Advance to t = 1000ms
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(1000L)
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||||
|
|
||||||
|
// Re-trigger setting true at t = 1000ms while already true
|
||||||
|
composeTestRule.onNodeWithTag("feedback_trigger").performClick()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Copied")
|
||||||
|
|
||||||
|
// If the active timer is NOT restarted, it resets at t = 1500ms (500ms after the second click)
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(500L)
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("feedback_status").assertTextEquals("Idle")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun separateComposedHookInstancesAreIndependent() {
|
||||||
|
composeTestRule.mainClock.autoAdvance = false
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
Column {
|
||||||
|
CopyFeedbackHarness(tagPrefix = "instance_a")
|
||||||
|
CopyFeedbackHarness(tagPrefix = "instance_b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
|
||||||
|
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
|
||||||
|
|
||||||
|
// Trigger only instance A
|
||||||
|
composeTestRule.onNodeWithTag("instance_a_trigger").performClick()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
|
||||||
|
// Instance A should be active, instance B should remain idle
|
||||||
|
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
|
||||||
|
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
|
||||||
|
|
||||||
|
// Advance 1000ms
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(1000L)
|
||||||
|
|
||||||
|
// Trigger instance B at t = 1000ms
|
||||||
|
composeTestRule.onNodeWithTag("instance_b_trigger").performClick()
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
|
||||||
|
// Both should be Copied now
|
||||||
|
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Copied")
|
||||||
|
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
|
||||||
|
|
||||||
|
// Advance 500ms to t = 1500ms: Instance A should expire, Instance B should still have 1000ms remaining
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(500L)
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
|
||||||
|
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Copied")
|
||||||
|
|
||||||
|
// Advance remaining 1000ms for Instance B
|
||||||
|
composeTestRule.mainClock.advanceTimeBy(1000L)
|
||||||
|
composeTestRule.mainClock.advanceTimeByFrame()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("instance_a_status").assertTextEquals("Idle")
|
||||||
|
composeTestRule.onNodeWithTag("instance_b_status").assertTextEquals("Idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -219,6 +219,10 @@ class VoiceNoteGestureTest {
|
|||||||
|
|
||||||
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
composeTestRule.onNodeWithTag("mic_button").performTouchInput { down(center) }
|
||||||
composeTestRule.mainClock.advanceTimeBy(600)
|
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.onNodeWithTag("mic_button").performTouchInput { up() }
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
|||||||
+45
-18
@@ -12,7 +12,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
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.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
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.VaultSaveLoginPromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
import com.m57.hermescontrol.ui.chat.VaultUnlockPromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
import io.mockk.mockk
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.awaitCancellation
|
import kotlinx.coroutines.awaitCancellation
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -116,31 +116,58 @@ class FullBleedChatAnchorTest {
|
|||||||
val tailKeys =
|
val tailKeys =
|
||||||
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
|
listOf("agent_status", "clarify_bubble", "vault_unlock_card", "vault_save_login_card", "vault_code_card")
|
||||||
val listState = LazyListState(index, offset)
|
val listState = LazyListState(index, offset)
|
||||||
val viewModel = mockk<ChatViewModel>(relaxed = true)
|
|
||||||
lateinit var scope: CoroutineScope
|
lateinit var scope: CoroutineScope
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
scope = rememberCoroutineScope()
|
scope = rememberCoroutineScope()
|
||||||
val controller = rememberChatScrollController(listState, scope)
|
val controller = rememberChatScrollController(listState, scope)
|
||||||
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
|
Box(Modifier.size(width = 320.dp, height = if (tailKey == null) 400.dp else 24.dp)) {
|
||||||
FullBleedChatList(
|
FullBleedChatList(
|
||||||
messages = messages.value,
|
transcript =
|
||||||
streamingState = streaming.value,
|
TranscriptUiState.resolve(
|
||||||
isAgentTyping = tailKey != null,
|
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(),
|
searchState = ChatSearchState(),
|
||||||
typingEffectEnabled = false,
|
|
||||||
typingEffectDelayMs = 30,
|
|
||||||
isLoading = false,
|
|
||||||
isLoadingOlder = loading.value,
|
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollController = controller,
|
scrollController = controller,
|
||||||
lastAnimatedMessageId = null,
|
|
||||||
onLastAnimatedMessageIdChange = {},
|
|
||||||
viewModel = viewModel,
|
|
||||||
pagingSessionId = session.value,
|
|
||||||
clarifyRequest = if (tailKey != null) ClarifyUi("Choose an option", listOf("Yes")) else null,
|
|
||||||
vaultUnlockPrompt = if (tailKey != null) VaultUnlockPromptUi(null, null) else null,
|
|
||||||
vaultSaveLoginPrompt = if (tailKey != null) VaultSaveLoginPromptUi(null, null) else null,
|
|
||||||
vaultCodePrompt = if (tailKey != null) VaultCodePromptUi(null, null) else null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-34
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.ui.chat.fullbleed
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.test.assertCountEquals
|
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.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
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.compose.ui.unit.dp
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
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.MessageRole
|
||||||
|
import com.m57.hermescontrol.ui.chat.SearchTarget
|
||||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.ChatScrollController
|
||||||
import io.mockk.mockk
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import org.junit.Assert.assertFalse
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -72,44 +79,87 @@ class FullBleedChatListTest {
|
|||||||
showUserMessageTokens: Boolean = true,
|
showUserMessageTokens: Boolean = true,
|
||||||
showAssistantMessageTokens: Boolean = true,
|
showAssistantMessageTokens: Boolean = true,
|
||||||
showTokensPerSecond: Boolean = true,
|
showTokensPerSecond: Boolean = true,
|
||||||
|
searchState: ChatSearchState = ChatSearchState(),
|
||||||
|
onController: (ChatScrollController) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
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(
|
FullBleedChatList(
|
||||||
messages = messages,
|
transcript =
|
||||||
streamingState = streamingState,
|
TranscriptUiState.resolve(
|
||||||
isAgentTyping = isAgentTyping,
|
chat =
|
||||||
searchState = ChatSearchState(),
|
ChatUiState(
|
||||||
typingEffectEnabled = false,
|
messages = messages,
|
||||||
typingEffectDelayMs = 30,
|
isAgentTyping = isAgentTyping,
|
||||||
messageStatsEnabled = messageStatsEnabled,
|
typingEffectEnabled = false,
|
||||||
showUserMessageTokens = showUserMessageTokens,
|
typingEffectDelayMs = 30,
|
||||||
showAssistantMessageTokens = showAssistantMessageTokens,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showTokensPerSecond = showTokensPerSecond,
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
isLoading = false,
|
showAssistantMessageTokens = showAssistantMessageTokens,
|
||||||
isLoadingOlder = false,
|
showTokensPerSecond = showTokensPerSecond,
|
||||||
listState = listState,
|
clarifyRequest =
|
||||||
scrollController =
|
if (clarify) {
|
||||||
ChatScrollController(
|
com.m57.hermescontrol.ui.chat.ClarifyUi(
|
||||||
listState = listState,
|
text = "pick",
|
||||||
scope = CoroutineScope(Dispatchers.Main.immediate),
|
options = listOf("a"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
timeline = ChatTimelineState(),
|
||||||
|
streaming = streamingState,
|
||||||
|
savingAttachmentPath = null,
|
||||||
|
speakingMessageId = null,
|
||||||
),
|
),
|
||||||
lastAnimatedMessageId = null,
|
actions = testTranscriptActions(),
|
||||||
onLastAnimatedMessageIdChange = {},
|
searchState = searchState,
|
||||||
viewModel = mockk<ChatViewModel>(relaxed = true),
|
listState = listState,
|
||||||
clarifyRequest =
|
scrollController = scrollController,
|
||||||
if (clarify) {
|
|
||||||
com.m57.hermescontrol.ui.chat.ClarifyUi(
|
|
||||||
text = "pick",
|
|
||||||
options = listOf("a"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Test
|
||||||
fun userMessages_keepBubble_agentProse_isFullBleed() {
|
fun userMessages_keepBubble_agentProse_isFullBleed() {
|
||||||
render(
|
render(
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||||
|
|
||||||
|
/** No-op actions for transcript layout tests; callbacks are irrelevant to their assertions. */
|
||||||
|
internal fun testTranscriptActions() =
|
||||||
|
TranscriptActions(
|
||||||
|
onLoadOlder = {},
|
||||||
|
onOpenAttachment = {},
|
||||||
|
onSaveAttachment = {},
|
||||||
|
onImageClick = {},
|
||||||
|
onRespondApproval = {},
|
||||||
|
onRespondClarify = {},
|
||||||
|
onRespondClarifyBatch = {},
|
||||||
|
onDismissClarify = {},
|
||||||
|
onRespondVaultUnlock = {},
|
||||||
|
onDismissVaultUnlock = {},
|
||||||
|
onRespondVaultSaveLogin = { _, _ -> },
|
||||||
|
onDismissVaultSaveLogin = {},
|
||||||
|
onRespondVaultCode = {},
|
||||||
|
onDismissVaultCode = {},
|
||||||
|
onToggleSpeak = {},
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.m57.hermescontrol.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.toPixelMap
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.test.captureToImage
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||||
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
|
import com.m57.hermescontrol.theme.resolveStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class StatusBadgeColorTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun latteBadgesRenderStatusFillsAndMatchingOnColors() {
|
||||||
|
val types = StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.CATPPUCCIN) {
|
||||||
|
Column {
|
||||||
|
types.forEach { type ->
|
||||||
|
StatusBadge(type.name, type, Modifier.testTag("badge_$type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val colors = resolveStatusColors(ThemePreset.CATPPUCCIN, darkTheme = false)
|
||||||
|
types.forEach { type ->
|
||||||
|
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
|
||||||
|
assertRenderedPair("badge_$type", background.toArgb(), foreground.toArgb())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nordLiveIndicatorsRenderStatusFillsAndMatchingOnColors() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.NORD) {
|
||||||
|
Column {
|
||||||
|
SessionLiveStatusIndicator(SessionLiveStatus.WORKING, "working")
|
||||||
|
SessionLiveStatusIndicator(SessionLiveStatus.WAITING, "waiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val colors = resolveStatusColors(ThemePreset.NORD, darkTheme = false)
|
||||||
|
assertRenderedPair("session_live_status_working", colors.success.toArgb(), colors.onSuccess.toArgb())
|
||||||
|
assertRenderedPair("session_live_status_waiting", colors.warning.toArgb(), colors.onWarning.toArgb())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertRenderedPair(
|
||||||
|
tag: String,
|
||||||
|
background: Int,
|
||||||
|
foreground: Int,
|
||||||
|
) {
|
||||||
|
// PR #1417: verify pixels as well as the palette mapping, so a renderer
|
||||||
|
// switching back to accent-on-container cannot leave the contrast test green.
|
||||||
|
val pixels = composeTestRule.onNodeWithTag(tag).captureToImage().toPixelMap()
|
||||||
|
assertEquals("$tag must render the status fill", background, pixels[pixels.width / 2, 1].toArgb())
|
||||||
|
assertTrue(
|
||||||
|
"$tag must render the matching on-status text/icon",
|
||||||
|
(0 until pixels.height).any { y ->
|
||||||
|
(0 until pixels.width).any { x -> pixels[x, y].toArgb() == foreground }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
package com.m57.hermescontrol.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.RadioButtonDefaults
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.SliderDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.SwitchDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.graphics.toPixelMap
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.test.captureToImage
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
|
import com.m57.hermescontrol.theme.ThemePreset
|
||||||
|
import com.m57.hermescontrol.theme.ThemeRegistry
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.junit.runners.Parameterized
|
||||||
|
|
||||||
|
@RunWith(Parameterized::class)
|
||||||
|
@MediumTest
|
||||||
|
class ThemeComponentGalleryTest(
|
||||||
|
private val preset: ThemePreset,
|
||||||
|
private val dark: Boolean,
|
||||||
|
) {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private var samples = emptyList<Sample>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enabledControlsRenderTheirAccessibleDefaultStateColors() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme(
|
||||||
|
themePreset = preset,
|
||||||
|
themePreference = if (dark) ThemePreference.DARK else ThemePreference.LIGHT,
|
||||||
|
) {
|
||||||
|
Gallery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
// Assert the public defaults from the installed Compose version, not a copied token table.
|
||||||
|
assertTrue("gallery must publish its state contracts", samples.isNotEmpty())
|
||||||
|
samples.forEach { sample ->
|
||||||
|
val a = sample.foreground.luminance()
|
||||||
|
val b = sample.background.luminance()
|
||||||
|
val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f)
|
||||||
|
assertTrue("$preset dark=$dark ${sample.tag}: $ratio < ${sample.minimum}", ratio >= sample.minimum)
|
||||||
|
}
|
||||||
|
// Pixel evidence catches defaults that differ from the actual rendered state (including focus).
|
||||||
|
samples.groupBy { it.tag }.forEach { (tag, contracts) ->
|
||||||
|
val pixels =
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag(tag)
|
||||||
|
.performScrollTo()
|
||||||
|
.captureToImage()
|
||||||
|
.toPixelMap()
|
||||||
|
val rendered =
|
||||||
|
buildSet {
|
||||||
|
for (y in 0 until pixels.height) {
|
||||||
|
for (x in 0 until pixels.width) add(pixels[x, y].toArgb())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contracts.forEach { sample ->
|
||||||
|
assertTrue("$preset dark=$dark $tag must render its foreground", sample.foreground.toArgb() in rendered)
|
||||||
|
if (sample.checkBackground) {
|
||||||
|
assertTrue("$preset dark=$dark $tag must render its fill", sample.background.toArgb() in rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Gallery() {
|
||||||
|
val c = MaterialTheme.colorScheme
|
||||||
|
val switches = SwitchDefaults.colors()
|
||||||
|
val radios = RadioButtonDefaults.colors()
|
||||||
|
val checks = CheckboxDefaults.colors()
|
||||||
|
val sliders = SliderDefaults.colors()
|
||||||
|
val fields = OutlinedTextFieldDefaults.colors()
|
||||||
|
val segments = SegmentedButtonDefaults.colors()
|
||||||
|
val focus = remember { FocusRequester() }
|
||||||
|
SideEffect {
|
||||||
|
samples =
|
||||||
|
listOf(
|
||||||
|
Sample("switch_on", switches.checkedThumbColor, switches.checkedTrackColor, checkBackground = true),
|
||||||
|
Sample("switch_on", switches.checkedTrackColor, c.surface),
|
||||||
|
Sample(
|
||||||
|
"switch_off",
|
||||||
|
switches.uncheckedThumbColor,
|
||||||
|
switches.uncheckedTrackColor,
|
||||||
|
checkBackground = true,
|
||||||
|
),
|
||||||
|
Sample("switch_off", switches.uncheckedBorderColor, c.surface),
|
||||||
|
Sample("radio_on", radios.selectedColor, c.surface),
|
||||||
|
Sample("radio_off", radios.unselectedColor, c.surface),
|
||||||
|
Sample("check_on", checks.checkedCheckmarkColor, checks.checkedBoxColor, checkBackground = true),
|
||||||
|
Sample("check_on", checks.checkedBorderColor, c.surface),
|
||||||
|
Sample("check_off", checks.uncheckedBorderColor, c.surface),
|
||||||
|
Sample("slider", sliders.activeTrackColor, sliders.inactiveTrackColor, checkBackground = true),
|
||||||
|
Sample(
|
||||||
|
"field_normal",
|
||||||
|
fields.unfocusedIndicatorColor,
|
||||||
|
fields.unfocusedContainerColor.compositeOver(c.surface),
|
||||||
|
),
|
||||||
|
Sample("field_normal", fields.unfocusedLabelColor, c.surface, 4.5f),
|
||||||
|
Sample(
|
||||||
|
"field_focus",
|
||||||
|
fields.focusedIndicatorColor,
|
||||||
|
fields.focusedContainerColor.compositeOver(c.surface),
|
||||||
|
),
|
||||||
|
Sample("field_focus", fields.focusedLabelColor, c.surface, 4.5f),
|
||||||
|
Sample(
|
||||||
|
"field_error",
|
||||||
|
fields.errorIndicatorColor,
|
||||||
|
fields.errorContainerColor.compositeOver(c.surface),
|
||||||
|
),
|
||||||
|
Sample("field_error", fields.errorSupportingTextColor, c.surface, 4.5f),
|
||||||
|
Sample("segment_on", segments.activeContentColor, segments.activeContainerColor, 4.5f, true),
|
||||||
|
Sample("segment_on", segments.activeBorderColor, segments.activeContainerColor),
|
||||||
|
Sample(
|
||||||
|
"segment_off",
|
||||||
|
segments.inactiveContentColor,
|
||||||
|
segments.inactiveContainerColor.compositeOver(c.surface),
|
||||||
|
4.5f,
|
||||||
|
),
|
||||||
|
Sample("segment_off", segments.inactiveBorderColor, c.surface),
|
||||||
|
Sample("primary_text", c.primary, c.surface, 4.5f),
|
||||||
|
Sample("error_text", c.error, c.surface, 4.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Surface(color = c.surface, modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState()).padding(8.dp)) {
|
||||||
|
Text("$preset ${if (dark) "dark" else "light"}")
|
||||||
|
Row {
|
||||||
|
Switch(true, {}, Modifier.testTag("switch_on"))
|
||||||
|
Switch(false, {}, Modifier.testTag("switch_off"))
|
||||||
|
RadioButton(true, {}, Modifier.testTag("radio_on"))
|
||||||
|
RadioButton(false, {}, Modifier.testTag("radio_off"))
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
Checkbox(true, {}, Modifier.testTag("check_on"))
|
||||||
|
Checkbox(false, {}, Modifier.testTag("check_off"))
|
||||||
|
}
|
||||||
|
Slider(0.5f, {}, Modifier.testTag("slider"))
|
||||||
|
OutlinedTextField("Value", {
|
||||||
|
}, Modifier.fillMaxWidth().testTag("field_normal"), readOnly = true, label = { Text("Normal") })
|
||||||
|
OutlinedTextField(
|
||||||
|
"Value",
|
||||||
|
{
|
||||||
|
},
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(
|
||||||
|
focus,
|
||||||
|
).testTag("field_focus"),
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Focused") },
|
||||||
|
)
|
||||||
|
OutlinedTextField("Value", {
|
||||||
|
}, Modifier.fillMaxWidth().testTag("field_error"), readOnly = true, isError = true, label = {
|
||||||
|
Text("Error")
|
||||||
|
}, supportingText = { Text("Error text") })
|
||||||
|
SingleChoiceSegmentedButtonRow {
|
||||||
|
SegmentedButton(
|
||||||
|
true,
|
||||||
|
{},
|
||||||
|
SegmentedButtonDefaults.itemShape(0, 2),
|
||||||
|
Modifier.testTag("segment_on"),
|
||||||
|
) { Text("Selected") }
|
||||||
|
SegmentedButton(
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
},
|
||||||
|
SegmentedButtonDefaults.itemShape(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
Modifier.testTag("segment_off"),
|
||||||
|
) { Text("Unselected") }
|
||||||
|
}
|
||||||
|
Text("Primary foreground", Modifier.testTag("primary_text"), color = c.primary)
|
||||||
|
Text("Error foreground", Modifier.testTag("error_text"), color = c.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) { focus.requestFocus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Sample(
|
||||||
|
val tag: String,
|
||||||
|
val foreground: Color,
|
||||||
|
val background: Color,
|
||||||
|
val minimum: Float = 3f,
|
||||||
|
val checkBackground: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
@Parameterized.Parameters(name = "{0} dark={1}")
|
||||||
|
fun presets(): List<Array<Any>> =
|
||||||
|
ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf<Any>(preset, it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Hermes Dev</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Hermes Dev</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Hermes Dev</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Hermes Dev</string>
|
||||||
|
</resources>
|
||||||
@@ -71,6 +71,10 @@
|
|||||||
android:name=".notification.NotificationReplyReceiver"
|
android:name=".notification.NotificationReplyReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".notification.StopKeepConnectedReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -215,14 +215,22 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
var lastBackPressTime by remember { mutableLongStateOf(0L) }
|
var lastBackPressTime by remember { mutableLongStateOf(0L) }
|
||||||
val backToastText = stringResource(R.string.press_back_again_to_exit)
|
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) {
|
BackHandler(enabled = currentScreen == ChatScreen && backStack.size == 1) {
|
||||||
val now = System.currentTimeMillis()
|
val pendingReturn = NavigationController.chatReturnScreen
|
||||||
if (now - lastBackPressTime < 2000L) {
|
if (pendingReturn != null) {
|
||||||
(context as? Activity)?.finish()
|
NavigationController.consumeChatReturnScreen()
|
||||||
|
NavigationController.navigateTo(pendingReturn)
|
||||||
} else {
|
} else {
|
||||||
lastBackPressTime = now
|
val now = System.currentTimeMillis()
|
||||||
Toast.makeText(context, backToastText, Toast.LENGTH_SHORT).show()
|
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
|
private set
|
||||||
var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null)
|
var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null)
|
||||||
private set
|
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
|
val pendingSessionId: String? get() = pendingChatNavigation?.sessionId
|
||||||
|
|
||||||
private var nextChatNavigationRequestId = 0L
|
private var nextChatNavigationRequestId = 0L
|
||||||
@@ -45,8 +55,18 @@ object NavigationController {
|
|||||||
*/
|
*/
|
||||||
fun isPrimaryScreen(key: NavKey): Boolean = key == ChatScreen || ScreenRegistry.ALL_SCREENS.any { it.key == key }
|
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) {
|
fun navigateTo(key: NavKey) {
|
||||||
val stack = backStack ?: return
|
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 (stack.lastOrNull() == key) return
|
||||||
|
|
||||||
if (key == ChatScreen) {
|
if (key == ChatScreen) {
|
||||||
@@ -73,11 +93,16 @@ object NavigationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openChatSession(sessionId: String) {
|
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) {
|
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() {
|
fun openNewChat() {
|
||||||
@@ -91,8 +116,18 @@ object NavigationController {
|
|||||||
private fun queueChatNavigation(
|
private fun queueChatNavigation(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
scrollToBottom: Boolean,
|
scrollToBottom: Boolean,
|
||||||
|
returnToList: Boolean,
|
||||||
) {
|
) {
|
||||||
if (sessionId.isBlank()) return
|
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 =
|
||||||
PendingChatNavigation(
|
PendingChatNavigation(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -100,6 +135,8 @@ object NavigationController {
|
|||||||
requestId = ++nextChatNavigationRequestId,
|
requestId = ++nextChatNavigationRequestId,
|
||||||
)
|
)
|
||||||
navigateTo(ChatScreen)
|
navigateTo(ChatScreen)
|
||||||
|
// Set after navigateTo so the reset itself does not disarm the return.
|
||||||
|
chatReturnScreen = returnScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
fun consumePendingChatNavigation(): PendingChatNavigation? =
|
fun consumePendingChatNavigation(): PendingChatNavigation? =
|
||||||
@@ -113,6 +150,7 @@ object NavigationController {
|
|||||||
/** Clear the stack and navigate to the given screen atomically. */
|
/** Clear the stack and navigate to the given screen atomically. */
|
||||||
fun resetTo(screen: NavKey) {
|
fun resetTo(screen: NavKey) {
|
||||||
val stack = backStack ?: return
|
val stack = backStack ?: return
|
||||||
|
chatReturnScreen = null
|
||||||
stack.clear()
|
stack.clear()
|
||||||
stack.add(screen)
|
stack.add(screen)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ data class ServerStoreState(
|
|||||||
val showTokensPerSecond: Boolean = true,
|
val showTokensPerSecond: Boolean = true,
|
||||||
val showModelProvider: Boolean = false,
|
val showModelProvider: Boolean = false,
|
||||||
val keepConnectedInBackground: 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
|
// Opt-in update channel: when true the in-app update check also considers
|
||||||
// pre-release release-candidate GitHub releases, not just stable ones.
|
// pre-release release-candidate GitHub releases, not just stable ones.
|
||||||
val checkReleaseCandidateUpdates: Boolean = false,
|
val checkReleaseCandidateUpdates: Boolean = false,
|
||||||
|
|||||||
@@ -871,6 +871,12 @@ object AuthManager {
|
|||||||
serverStore.update { it.copy(keepConnectedInBackground = enabled) }
|
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) ───────────────────────────────────
|
// ── Chat Font Scale (issue #1004) ───────────────────────────────────
|
||||||
|
|
||||||
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
fun getChatFontScale(): Float = serverStore.getLatestState().chatFontScale
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ internal fun canonicalMessageOrder(
|
|||||||
id: String,
|
id: String,
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
): Long? {
|
): Long? {
|
||||||
val prefix = "rest-$sessionId-"
|
val prefix =
|
||||||
|
com.m57.hermescontrol.ui.chat.RestMessageId
|
||||||
|
.sessionPrefix(sessionId)
|
||||||
if (!id.startsWith(prefix)) return null
|
if (!id.startsWith(prefix)) return null
|
||||||
val suffix = id.removePrefix(prefix)
|
val suffix = id.removePrefix(prefix)
|
||||||
return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull()
|
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).
|
* file that lives on the *gateway host* (agent-delivered `MEDIA:` directive).
|
||||||
* Null for locally-picked attachments.
|
* Null for locally-picked attachments.
|
||||||
* @param source Where the attachment originated.
|
* @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
|
@Serializable
|
||||||
data class Attachment(
|
data class Attachment(
|
||||||
@@ -26,6 +29,7 @@ data class Attachment(
|
|||||||
val size: Long = 0,
|
val size: Long = 0,
|
||||||
val gatewayUrl: String? = null,
|
val gatewayUrl: String? = null,
|
||||||
val source: AttachmentSource = AttachmentSource.LOCAL,
|
val source: AttachmentSource = AttachmentSource.LOCAL,
|
||||||
|
val contentOffset: Int? = null,
|
||||||
) {
|
) {
|
||||||
val mediaKind: MediaKind
|
val mediaKind: MediaKind
|
||||||
get() = classifyMedia(mimeType = mimeType, name = name, uri = gatewayUrl ?: uri)
|
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 redacted: Boolean? = null,
|
||||||
val auto_delete_seconds: Long? = 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 approved_at: String? = null,
|
||||||
val executable: Boolean? = 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
|
package com.m57.hermescontrol.data.model
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class McpServersResponse(
|
data class McpServersResponse(
|
||||||
@@ -19,12 +22,21 @@ data class McpServer(
|
|||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val auth: String? = null,
|
val auth: String? = null,
|
||||||
val tools: List<String>? = null,
|
val tools: JsonElement? = null,
|
||||||
val source: String = "config",
|
val source: String = "config",
|
||||||
val plugin: String? = null,
|
val plugin: String? = null,
|
||||||
) {
|
) {
|
||||||
// #1283: plugin-provided servers are managed by their owning plugin.
|
// #1283: plugin-provided servers are managed by their owning plugin.
|
||||||
val isPluginOwned: Boolean get() = source == "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
|
@Serializable
|
||||||
@@ -71,13 +83,15 @@ data class McpCatalogEntry(
|
|||||||
val url: String? = null,
|
val url: String? = null,
|
||||||
val command: String? = null,
|
val command: String? = null,
|
||||||
val args: List<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
|
@Serializable
|
||||||
data class McpCatalogEnvVar(
|
data class McpCatalogEnvVar(
|
||||||
val key: String,
|
@SerialName("name") val key: String,
|
||||||
val label: String? = null,
|
@SerialName("prompt") val label: String? = null,
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
val required: Boolean = false,
|
val required: Boolean = false,
|
||||||
)
|
)
|
||||||
@@ -96,3 +110,9 @@ data class McpOAuthFlowResponse(
|
|||||||
val authorizationUrl: String? = null,
|
val authorizationUrl: String? = null,
|
||||||
val error: 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(
|
data class MoaModelSlot(
|
||||||
val provider: String,
|
val provider: String,
|
||||||
val model: String,
|
val model: String,
|
||||||
|
val reasoning_effort: String? = null,
|
||||||
|
val enabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MoaConfigPreset(
|
data class MoaConfigPreset(
|
||||||
val reference_models: List<MoaModelSlot>,
|
val reference_models: List<MoaModelSlot>,
|
||||||
val aggregator: MoaModelSlot,
|
val aggregator: MoaModelSlot,
|
||||||
val reference_temperature: Double = 0.7,
|
// null = omitted from API calls (provider default); the backend sends null when unset.
|
||||||
val aggregator_temperature: Double = 0.3,
|
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 max_tokens: Int = 4096,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
)
|
)
|
||||||
@@ -44,8 +50,12 @@ data class MoaConfigResponse(
|
|||||||
val presets: Map<String, MoaConfigPreset> = emptyMap(),
|
val presets: Map<String, MoaConfigPreset> = emptyMap(),
|
||||||
val reference_models: List<MoaModelSlot> = emptyList(),
|
val reference_models: List<MoaModelSlot> = emptyList(),
|
||||||
val aggregator: MoaModelSlot = MoaModelSlot("", ""),
|
val aggregator: MoaModelSlot = MoaModelSlot("", ""),
|
||||||
val reference_temperature: Double = 0.7,
|
// null = omitted from API calls (provider default); the backend sends null when unset.
|
||||||
val aggregator_temperature: Double = 0.3,
|
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 max_tokens: Int = 4096,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
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`.
|
* 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,
|
* id, name, flow ("pkce" | "device_code" | "external"), cli_command,
|
||||||
* docs_url, disconnect_hint, disconnect_command, disconnectable, status.
|
* 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
|
@Serializable
|
||||||
data class OAuthProvider(
|
data class OAuthProvider(
|
||||||
@@ -31,9 +42,13 @@ data class OAuthProviderStatus(
|
|||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
@SerialName("source_label") val sourceLabel: String? = null,
|
@SerialName("source_label") val sourceLabel: String? = null,
|
||||||
@SerialName("token_preview") val tokenPreview: 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("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,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,3 +113,25 @@ data class OAuthCancelResponse(
|
|||||||
@SerialName("session_id") val sessionId: String? = null,
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
val message: 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
|
@Serializable
|
||||||
data class BulkDeleteRequest(
|
data class BulkDeleteRequest(
|
||||||
val ids: List<String>,
|
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
|
@Serializable
|
||||||
@@ -134,5 +137,13 @@ data class BulkDeleteResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PruneRequest(
|
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,
|
val status: String? = null,
|
||||||
@SerialName("last_active")
|
@SerialName("last_active")
|
||||||
val lastActive: Double? = null,
|
val lastActive: Double? = null,
|
||||||
|
val title: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ data class SessionMessage(
|
|||||||
val timestampText: String?
|
val timestampText: String?
|
||||||
get() = (timestamp as? JsonPrimitive)?.content
|
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?
|
val tokenCount: Int?
|
||||||
get() {
|
get() {
|
||||||
val prim = token_count as? JsonPrimitive ?: return null
|
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.CreateProfileRequest
|
||||||
import com.m57.hermescontrol.data.model.CreateTaskBody
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.CreateWebhookRequest
|
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.CredentialPoolResponse
|
||||||
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
import com.m57.hermescontrol.data.model.CronBlueprintListResponse
|
||||||
import com.m57.hermescontrol.data.model.CronJob
|
import com.m57.hermescontrol.data.model.CronJob
|
||||||
import com.m57.hermescontrol.data.model.CuratorResponse
|
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.DebugShareResponse
|
||||||
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
import com.m57.hermescontrol.data.model.DeleteWebhookResponse
|
||||||
import com.m57.hermescontrol.data.model.DeliveryTargetsResponse
|
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.GatewayMigrationPlan
|
||||||
import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse
|
import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse
|
||||||
import com.m57.hermescontrol.data.model.HealthStatus
|
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.HookResponse
|
||||||
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
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.McpServer
|
||||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
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.McpServersResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest
|
import com.m57.hermescontrol.data.model.MemoryProviderConfigUpdateRequest
|
||||||
@@ -583,7 +588,7 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@PUT("api/mcp/servers/{name}")
|
@PUT("api/mcp/servers/{name}")
|
||||||
suspend fun updateMcpServer(
|
suspend fun updateMcpServer(
|
||||||
@Path("name") name: String,
|
@Path("name") name: String,
|
||||||
@Body body: Map<String, Any>,
|
@Body body: McpServerUpdateRequest,
|
||||||
): Response<McpServer>
|
): Response<McpServer>
|
||||||
|
|
||||||
@POST("api/mcp/servers/{name}/auth")
|
@POST("api/mcp/servers/{name}/auth")
|
||||||
@@ -602,7 +607,7 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@POST("api/mcp/catalog/install")
|
@POST("api/mcp/catalog/install")
|
||||||
suspend fun installMcpCatalogEntry(
|
suspend fun installMcpCatalogEntry(
|
||||||
@Body body: McpCatalogInstallRequest,
|
@Body body: McpCatalogInstallRequest,
|
||||||
): Response<Map<String, Any>>
|
): Response<ActionResponse>
|
||||||
|
|
||||||
@GET("api/webhooks")
|
@GET("api/webhooks")
|
||||||
suspend fun getWebhooks(): Response<WebhooksResponse>
|
suspend fun getWebhooks(): Response<WebhooksResponse>
|
||||||
@@ -881,7 +886,7 @@ interface HermesApiService : KanbanApiService {
|
|||||||
@PUT("api/curator/paused")
|
@PUT("api/curator/paused")
|
||||||
suspend fun setCuratorPaused(
|
suspend fun setCuratorPaused(
|
||||||
@Body body: Map<String, Boolean>,
|
@Body body: Map<String, Boolean>,
|
||||||
): Response<Map<String, Any>>
|
): Response<ActionResponse>
|
||||||
|
|
||||||
@POST("api/curator/run")
|
@POST("api/curator/run")
|
||||||
suspend fun runCurator(): Response<ActionResponse>
|
suspend fun runCurator(): Response<ActionResponse>
|
||||||
@@ -918,14 +923,14 @@ interface HermesApiService : KanbanApiService {
|
|||||||
|
|
||||||
@POST("api/credentials/pool")
|
@POST("api/credentials/pool")
|
||||||
suspend fun addCredentialPoolEntry(
|
suspend fun addCredentialPoolEntry(
|
||||||
@Body body: Map<String, String>,
|
@Body body: CredentialPoolAddRequest,
|
||||||
): Response<Map<String, Any>>
|
): Response<ActionResponse>
|
||||||
|
|
||||||
@DELETE("api/credentials/pool/{provider}/{index}")
|
@DELETE("api/credentials/pool/{provider}/{index}")
|
||||||
suspend fun removeCredentialPoolEntry(
|
suspend fun removeCredentialPoolEntry(
|
||||||
@Path("provider") provider: String,
|
@Path("provider") provider: String,
|
||||||
@Path("index") index: Int,
|
@Path("index") index: Int,
|
||||||
): Response<Map<String, Any>>
|
): Response<ActionResponse>
|
||||||
|
|
||||||
// ── Admin: Operations ─────────────────────────────────────────────
|
// ── Admin: Operations ─────────────────────────────────────────────
|
||||||
@POST("api/ops/security-audit")
|
@POST("api/ops/security-audit")
|
||||||
@@ -968,7 +973,7 @@ interface HermesApiService : KanbanApiService {
|
|||||||
// ── Admin: Debug share ────────────────────────────────────────────
|
// ── Admin: Debug share ────────────────────────────────────────────
|
||||||
@POST("api/ops/debug-share")
|
@POST("api/ops/debug-share")
|
||||||
suspend fun runDebugShare(
|
suspend fun runDebugShare(
|
||||||
@Body body: Map<String, Any>,
|
@Body body: DebugShareRequest,
|
||||||
): Response<DebugShareResponse>
|
): Response<DebugShareResponse>
|
||||||
|
|
||||||
// ── Admin: Checkpoints ────────────────────────────────────────────
|
// ── Admin: Checkpoints ────────────────────────────────────────────
|
||||||
@@ -984,13 +989,13 @@ interface HermesApiService : KanbanApiService {
|
|||||||
|
|
||||||
@POST("api/ops/hooks")
|
@POST("api/ops/hooks")
|
||||||
suspend fun createHook(
|
suspend fun createHook(
|
||||||
@Body body: Map<String, Any>,
|
@Body body: HookCreateRequest,
|
||||||
): Response<Map<String, Any>>
|
): Response<ActionResponse>
|
||||||
|
|
||||||
@DELETE("api/ops/hooks")
|
@HTTP(method = "DELETE", path = "api/ops/hooks", hasBody = true)
|
||||||
suspend fun deleteHook(
|
suspend fun deleteHook(
|
||||||
@Body body: Map<String, String>,
|
@Body body: HookDeleteRequest,
|
||||||
): Response<Map<String, Any>>
|
): Response<ActionResponse>
|
||||||
|
|
||||||
// ── Admin: Action status (log viewer) ────────────────────────────
|
// ── Admin: Action status (log viewer) ────────────────────────────
|
||||||
@GET("api/actions/{name}/status")
|
@GET("api/actions/{name}/status")
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ object ProfileScopeInterceptor : Interceptor {
|
|||||||
// multi-profile host transcribes under the launch profile
|
// multi-profile host transcribes under the launch profile
|
||||||
// (review, PR #1250).
|
// (review, PR #1250).
|
||||||
"/api/audio",
|
"/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/config",
|
||||||
"/api/credentials/pool",
|
"/api/credentials/pool",
|
||||||
"/api/cron",
|
"/api/cron",
|
||||||
|
|||||||
@@ -222,7 +222,10 @@ open class AppUpdateChecker(
|
|||||||
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
|
val url = NEXT_LINK.find(link)?.groupValues?.get(1) ?: return null
|
||||||
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
|
val next = url.toHttpUrlOrNull() ?: throw IOException("GitHub returned an invalid pagination URL")
|
||||||
val base = apiBaseUrl.toHttpUrlOrNull() ?: throw IOException("Invalid GitHub API base 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")
|
throw IOException("GitHub returned an untrusted pagination URL")
|
||||||
}
|
}
|
||||||
return next.toString()
|
return next.toString()
|
||||||
@@ -340,6 +343,7 @@ open class AppUpdateChecker(
|
|||||||
|
|
||||||
const val RELEASE_LIST_PAGE_SIZE = 100
|
const val RELEASE_LIST_PAGE_SIZE = 100
|
||||||
val NEXT_LINK = Regex("<([^>]+)>;\\s*rel=\"next\"")
|
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.ConnectorError
|
||||||
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
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.coroutines.CancellationException
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -48,10 +62,9 @@ interface AccountConnectorRepository {
|
|||||||
expectedRevision: String,
|
expectedRevision: String,
|
||||||
): AccountConnectorResult<ConnectorPolicy>
|
): AccountConnectorResult<ConnectorPolicy>
|
||||||
|
|
||||||
suspend fun operationRequest(
|
suspend fun operationRespond(params: ConnectionRespondParams): Any?
|
||||||
method: String,
|
|
||||||
params: Map<String, Any>,
|
suspend fun operationWake(params: ConnectorsOperationStatusParams): Any?
|
||||||
): Any?
|
|
||||||
|
|
||||||
suspend fun operationStatus(
|
suspend fun operationStatus(
|
||||||
opId: String,
|
opId: String,
|
||||||
@@ -61,25 +74,8 @@ interface AccountConnectorRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class HermesAccountConnectorRepository(
|
class HermesAccountConnectorRepository(
|
||||||
private val rpc: suspend (String, Map<String, Any>) -> Any? = { method, params ->
|
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||||
val deferred = HermesWsClient.request(method, params)
|
|
||||||
try {
|
|
||||||
deferred.await()
|
|
||||||
} catch (
|
|
||||||
e: CancellationException,
|
|
||||||
) {
|
|
||||||
deferred.cancel(e)
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
if (!deferred.isCompleted) deferred.cancel()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) : AccountConnectorRepository {
|
) : 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> =
|
private suspend fun <T> guarded(block: suspend () -> T): AccountConnectorResult<T> =
|
||||||
try {
|
try {
|
||||||
AccountConnectorResult.Success(block())
|
AccountConnectorResult.Success(block())
|
||||||
@@ -97,12 +93,6 @@ class HermesAccountConnectorRepository(
|
|||||||
AccountConnectorResult.Failure(ConnectorError.NetworkError("Failed to communicate with gateway."))
|
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 obj(value: Any?): JsonObject = ConnectorParser.toJsonElement(value).jsonObject
|
||||||
|
|
||||||
private fun JsonObject.str(k: String) = this[k]?.jsonPrimitive?.contentOrNull.orEmpty()
|
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 =
|
override suspend fun listConnectors(): com.m57.hermescontrol.data.model.ConnectorListResult =
|
||||||
try {
|
try {
|
||||||
ConnectorParser.parseListResult(
|
ConnectorParser.parseListResult(
|
||||||
rpc(
|
caller.call(
|
||||||
WsMethods.CONNECTORS_LIST,
|
RpcMethods.CONNECTORS_LIST,
|
||||||
accountParams(
|
ConnectorsListParams(
|
||||||
mapOf("owner" to mapOf("type" to "account")),
|
owner = ConnectorOwner.account(),
|
||||||
|
profile = AuthManager.activeProfileId.value,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -146,27 +137,48 @@ class HermesAccountConnectorRepository(
|
|||||||
) = guarded {
|
) = guarded {
|
||||||
require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug))
|
require(slugs.isNotEmpty() && slugs.all(ConnectorRepository::isValidSlug))
|
||||||
parseOperation(
|
parseOperation(
|
||||||
request(
|
caller.call(
|
||||||
WsMethods.CONNECTORS_CONNECT,
|
RpcMethods.CONNECTORS_CONNECT,
|
||||||
accountParams(
|
ConnectorsConnectParams(
|
||||||
mapOf(
|
owner = ConnectorOwner.account(),
|
||||||
"owner" to mapOf("type" to "account"),
|
connectors = slugs,
|
||||||
"connectors" to slugs,
|
reconnect = reconnect,
|
||||||
"reconnect" to reconnect,
|
profile = AuthManager.activeProfileId.value,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun operationRequest(
|
override suspend fun operationRespond(params: ConnectionRespondParams): Any? =
|
||||||
method: String,
|
caller.call(
|
||||||
params: Map<String, Any>,
|
RpcMethods.CONNECTION_RESPOND,
|
||||||
): Any? = request(method, accountParams(params + ("owner" to mapOf("type" to "account"))))
|
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) =
|
override suspend fun operationStatus(opId: String) =
|
||||||
guarded {
|
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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -180,7 +192,14 @@ class HermesAccountConnectorRepository(
|
|||||||
|
|
||||||
override suspend fun catalog() =
|
override suspend fun catalog() =
|
||||||
guarded {
|
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
|
val row = it.jsonObject
|
||||||
ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category"))
|
ConnectorCatalogEntry(row.str("slug"), row.str("name"), row.str("description"), row.str("category"))
|
||||||
}
|
}
|
||||||
@@ -188,7 +207,14 @@ class HermesAccountConnectorRepository(
|
|||||||
|
|
||||||
override suspend fun accounts() =
|
override suspend fun accounts() =
|
||||||
guarded {
|
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
|
val r = e.jsonObject
|
||||||
ConnectorAccount(
|
ConnectorAccount(
|
||||||
r.str("connection_id"),
|
r.str("connection_id"),
|
||||||
@@ -209,10 +235,11 @@ class HermesAccountConnectorRepository(
|
|||||||
require(connectionId.isNotBlank())
|
require(connectionId.isNotBlank())
|
||||||
val response =
|
val response =
|
||||||
obj(
|
obj(
|
||||||
request(
|
caller.call(
|
||||||
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
RpcMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||||
accountParams(
|
ConnectorsAccountsRemoveParams(
|
||||||
mapOf("connection_id" to connectionId),
|
connectionId = connectionId,
|
||||||
|
profile = AuthManager.activeProfileId.value,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -225,12 +252,16 @@ class HermesAccountConnectorRepository(
|
|||||||
refresh: Boolean,
|
refresh: Boolean,
|
||||||
) = guarded {
|
) = guarded {
|
||||||
require(ConnectorRepository.isValidSlug(slug))
|
require(ConnectorRepository.isValidSlug(slug))
|
||||||
val params =
|
val response =
|
||||||
buildMap<String, Any> {
|
caller.call(
|
||||||
put("slug", slug)
|
RpcMethods.CONNECTORS_TOOLS,
|
||||||
if (refresh) put("refresh", true)
|
ConnectorsToolsParams(
|
||||||
}
|
slug = slug,
|
||||||
obj(request(WsMethods.CONNECTORS_TOOLS, accountParams(params)))["tools"]!!.jsonArray.map { e ->
|
refresh = if (refresh) true else null,
|
||||||
|
profile = AuthManager.activeProfileId.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
obj(response)["tools"]!!.jsonArray.map { e ->
|
||||||
val r = e.jsonObject
|
val r = e.jsonObject
|
||||||
ConnectorTool(
|
ConnectorTool(
|
||||||
r.str("slug"),
|
r.str("slug"),
|
||||||
@@ -245,7 +276,15 @@ class HermesAccountConnectorRepository(
|
|||||||
|
|
||||||
override suspend fun policy() =
|
override suspend fun policy() =
|
||||||
guarded {
|
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 layers = root.array("layers").map { it.jsonObject }
|
||||||
val member = layers.firstOrNull { it.str("kind") == "member" }
|
val member = layers.firstOrNull { it.str("kind") == "member" }
|
||||||
parsePolicy(root["effective"]!!.jsonObject).copy(
|
parsePolicy(root["effective"]!!.jsonObject).copy(
|
||||||
@@ -293,13 +332,12 @@ class HermesAccountConnectorRepository(
|
|||||||
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
|
check(scope == AuthManager.currentDataScope()) { "Account scope changed" }
|
||||||
val result =
|
val result =
|
||||||
obj(
|
obj(
|
||||||
request(
|
caller.call(
|
||||||
WsMethods.CONNECTORS_POLICY_SET,
|
RpcMethods.CONNECTORS_POLICY_SET,
|
||||||
accountParams(
|
ConnectorsPolicySetParams(
|
||||||
mapOf(
|
change = ConnectorParser.toJsonElement(change) as JsonObject,
|
||||||
"change" to change,
|
expectedRevision = expectedRevision,
|
||||||
"expected_revision" to 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.SubscriptionUpgradeResponse
|
||||||
import com.m57.hermescontrol.data.model.UsageBarsResponse
|
import com.m57.hermescontrol.data.model.UsageBarsResponse
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
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.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
@@ -90,50 +95,47 @@ object BillingRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSubscriptionState(): SubscriptionStateResponse? {
|
suspend fun getSubscriptionState(): SubscriptionStateResponse? {
|
||||||
val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_STATE).await()
|
val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_STATE, EmptyParams)
|
||||||
return decode(result)
|
return decode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getUsageBars(): UsageBarsResponse? {
|
suspend fun getUsageBars(): UsageBarsResponse? {
|
||||||
val result = HermesWsClient.request(WsMethods.USAGE_BARS).await()
|
val result = HermesWsClient.call(RpcMethods.USAGE_BARS, EmptyParams)
|
||||||
return decode(result)
|
return decode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? {
|
suspend fun previewSubscription(subscriptionTypeId: String): SubscriptionPreviewResponse? {
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
RpcMethods.SUBSCRIPTION_PREVIEW,
|
||||||
WsMethods.SUBSCRIPTION_PREVIEW,
|
SubscriptionPreviewParams(subscriptionTypeId = subscriptionTypeId),
|
||||||
mapOf("subscription_type_id" to subscriptionTypeId),
|
)
|
||||||
).await()
|
|
||||||
return decode(result)
|
return decode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? {
|
suspend fun changeSubscription(request: SubscriptionChangeRequest): SubscriptionChangeResponse? {
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
RpcMethods.SUBSCRIPTION_CHANGE,
|
||||||
WsMethods.SUBSCRIPTION_CHANGE,
|
SubscriptionChangeParams(
|
||||||
buildMap {
|
subscriptionTypeId = request.subscription_type_id,
|
||||||
request.subscription_type_id?.let { put("subscription_type_id", it) }
|
cancel = request.cancel,
|
||||||
request.cancel?.let { put("cancel", it) }
|
),
|
||||||
},
|
)
|
||||||
).await()
|
|
||||||
return decode(result)
|
return decode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resumeSubscription(): SubscriptionResumeResponse? {
|
suspend fun resumeSubscription(): SubscriptionResumeResponse? {
|
||||||
val result = HermesWsClient.request(WsMethods.SUBSCRIPTION_RESUME).await()
|
val result = HermesWsClient.call(RpcMethods.SUBSCRIPTION_RESUME, EmptyParams)
|
||||||
return decode(result)
|
return decode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? {
|
suspend fun upgradeSubscription(subscriptionTypeId: String): SubscriptionUpgradeResponse? {
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
RpcMethods.SUBSCRIPTION_UPGRADE,
|
||||||
WsMethods.SUBSCRIPTION_UPGRADE,
|
SubscriptionUpgradeParams(subscriptionTypeId = subscriptionTypeId),
|
||||||
mapOf("subscription_type_id" to subscriptionTypeId),
|
)
|
||||||
).await()
|
|
||||||
return decode(result)
|
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.ConnectorConnectResult
|
||||||
import com.m57.hermescontrol.data.model.ConnectorError
|
import com.m57.hermescontrol.data.model.ConnectorError
|
||||||
import com.m57.hermescontrol.data.model.ConnectorListResult
|
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
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,19 +48,7 @@ interface ConnectorRepository {
|
|||||||
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
|
* Default implementation of [ConnectorRepository] backed by [HermesWsClient].
|
||||||
*/
|
*/
|
||||||
class HermesConnectorRepository(
|
class HermesConnectorRepository(
|
||||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||||
val deferred = HermesWsClient.request(method, params)
|
|
||||||
try {
|
|
||||||
deferred.await()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
deferred.cancel(e)
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
if (!deferred.isCompleted) {
|
|
||||||
deferred.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) : ConnectorRepository {
|
) : ConnectorRepository {
|
||||||
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
|
override suspend fun listConnectors(sessionId: String): ConnectorListResult {
|
||||||
if (sessionId.isBlank()) {
|
if (sessionId.isBlank()) {
|
||||||
@@ -64,8 +58,8 @@ class HermesConnectorRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val params = mapOf("owner" to ConnectorRepository.sessionOwner(sessionId))
|
val params = ConnectorsListParams(owner = ConnectorOwner.session(sessionId))
|
||||||
val result = rpcRequest(WsMethods.CONNECTORS_LIST, params)
|
val result = caller.call(RpcMethods.CONNECTORS_LIST, params)
|
||||||
ConnectorParser.parseListResult(result)
|
ConnectorParser.parseListResult(result)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
@@ -107,12 +101,12 @@ class HermesConnectorRepository(
|
|||||||
|
|
||||||
return try {
|
return try {
|
||||||
val params =
|
val params =
|
||||||
mapOf(
|
ConnectorsConnectParams(
|
||||||
"owner" to ConnectorRepository.sessionOwner(sessionId),
|
owner = ConnectorOwner.session(sessionId),
|
||||||
"connectors" to connectors,
|
connectors = connectors,
|
||||||
"reconnect" to reconnect,
|
reconnect = reconnect,
|
||||||
)
|
)
|
||||||
val result = rpcRequest(WsMethods.CONNECTORS_CONNECT, params)
|
val result = caller.call(RpcMethods.CONNECTORS_CONNECT, params)
|
||||||
ConnectorParser.parseConnectResult(result)
|
ConnectorParser.parseConnectResult(result)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.data.model.parseMessageReactions
|
||||||
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
import com.m57.hermescontrol.ui.chat.extractTodosFromMap
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
@@ -333,6 +334,20 @@ object EventParser {
|
|||||||
WsEvent.ReactionEvent(kind)
|
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" -> {
|
"approval.request" -> {
|
||||||
val command = payload?.get("command") as? String
|
val command = payload?.get("command") as? String
|
||||||
val description = payload?.get("description") 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.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
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.CancellationException
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
@@ -16,13 +18,8 @@ class ModelOptionsRepository(
|
|||||||
private val connected: () -> Boolean = {
|
private val connected: () -> Boolean = {
|
||||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||||
},
|
},
|
||||||
private val request: suspend (Map<String, Any>) -> Any? = { params ->
|
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
||||||
val deferred = HermesWsClient.request(WsMethods.MODEL_OPTIONS, params)
|
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
|
||||||
try {
|
|
||||||
deferred.await()
|
|
||||||
} finally {
|
|
||||||
if (!deferred.isCompleted) deferred.cancel()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||||
@@ -32,9 +29,7 @@ class ModelOptionsRepository(
|
|||||||
currentCoroutineContext().ensureActive()
|
currentCoroutineContext().ensureActive()
|
||||||
if (connected()) {
|
if (connected()) {
|
||||||
try {
|
try {
|
||||||
val result = request(mapOf("refresh" to refresh, "include_unconfigured" to false))
|
val json = request(ModelOptionsParams(refresh = refresh, includeUnconfigured = false))
|
||||||
// request() returns raw JsonElement, unlike the pushed-event Map representation.
|
|
||||||
val json = result as? JsonElement ?: error("Invalid model.options result")
|
|
||||||
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
|
return NetworkResult.Success(OkHttpProvider.json.decodeFromJsonElement<ModelOptionsResponse>(json))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
// A disconnected socket may cancel the independent RPC deferred. Only fall back
|
// A disconnected socket may cancel the independent RPC deferred. Only fall back
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import kotlinx.coroutines.CancellationException
|
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||||
import kotlinx.serialization.json.Json
|
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.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
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.
|
* Manages plugin settings and inspection through the gateway's canonical `plugins.manage` RPC.
|
||||||
*/
|
*/
|
||||||
class PluginManageRepository(
|
class PluginManageRepository(
|
||||||
private val rpcRequest: suspend (String, Map<String, Any?>) -> Any? = { method, params ->
|
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||||
val deferred = HermesWsClient.request(method, params.filterValues { it != null }.mapValues { it.value ?: "" })
|
|
||||||
try {
|
|
||||||
deferred.await()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
deferred.cancel(e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
suspend fun listPlugins(profile: String? = null): List<AgentPluginRow> {
|
suspend fun listPlugins(profile: String? = null): List<AgentPluginRow> {
|
||||||
val params = mutableMapOf<String, Any?>("action" to "list")
|
val element =
|
||||||
if (!profile.isNullOrBlank()) {
|
caller.call(
|
||||||
params["profile"] = profile
|
RpcMethods.PLUGINS_MANAGE,
|
||||||
}
|
PluginsManageParams(action = "list", profile = profile.orNullIfBlank()),
|
||||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
|
)
|
||||||
val element = result.toJsonElement()
|
|
||||||
val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList()
|
val pluginsElement = (element as? JsonObject)?.get("plugins") ?: return emptyList()
|
||||||
return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement)
|
return OkHttpProvider.json.decodeFromJsonElement<List<AgentPluginRow>>(pluginsElement)
|
||||||
}
|
}
|
||||||
@@ -38,15 +31,15 @@ class PluginManageRepository(
|
|||||||
profile: String? = null,
|
profile: String? = null,
|
||||||
): PluginsManageSettingsResult {
|
): PluginsManageSettingsResult {
|
||||||
val params =
|
val params =
|
||||||
mutableMapOf<String, Any?>(
|
PluginsManageParams(
|
||||||
"action" to "settings",
|
action = "settings",
|
||||||
"key" to key,
|
key = key,
|
||||||
"values" to JsonObject(values),
|
values = JsonObject(values),
|
||||||
|
profile = profile.orNullIfBlank(),
|
||||||
)
|
)
|
||||||
if (!profile.isNullOrBlank()) {
|
val result = caller.call(RpcMethods.PLUGINS_MANAGE, params)
|
||||||
params["profile"] = profile
|
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result)
|
||||||
}
|
|
||||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, params)
|
|
||||||
return OkHttpProvider.json.decodeFromJsonElement<PluginsManageSettingsResult>(result.toJsonElement())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
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.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
@@ -14,18 +17,10 @@ data class PluginRemovalResult(
|
|||||||
|
|
||||||
/** Removes a user-installed plugin through the gateway's canonical plugin manager. */
|
/** Removes a user-installed plugin through the gateway's canonical plugin manager. */
|
||||||
class PluginRemovalRepository(
|
class PluginRemovalRepository(
|
||||||
private val rpcRequest: suspend (String, Map<String, Any>) -> Any? = { method, params ->
|
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||||
val deferred = HermesWsClient.request(method, params)
|
|
||||||
try {
|
|
||||||
deferred.await()
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
deferred.cancel(e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
suspend fun remove(name: String): PluginRemovalResult {
|
suspend fun remove(name: String): PluginRemovalResult {
|
||||||
val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name))
|
val result = caller.call(RpcMethods.PLUGINS_MANAGE, PluginsManageParams(action = "remove", name = name))
|
||||||
return Json.decodeFromJsonElement<PluginRemovalResult>(result.toJsonElement())
|
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.ProjectInfo
|
||||||
import com.m57.hermescontrol.data.model.ProjectsListResponse
|
import com.m57.hermescontrol.data.model.ProjectsListResponse
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
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.coroutines.CancellationException
|
||||||
import kotlinx.serialization.json.JsonObject
|
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
|
// 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.
|
// every refresh. The view model refetches when the connection comes back.
|
||||||
private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED },
|
private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED },
|
||||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
private val caller: TypedRpcCaller = HermesRpcCaller,
|
||||||
HermesWsClient.request(method, params).await()
|
|
||||||
},
|
|
||||||
) : ProjectsSource {
|
) : ProjectsSource {
|
||||||
override suspend fun fetchProjects(): List<ProjectInfo>? {
|
override suspend fun fetchProjects(): List<ProjectInfo>? {
|
||||||
if (!isConnected()) return null
|
if (!isConnected()) return null
|
||||||
return try {
|
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")) {
|
if (element is JsonObject && element.containsKey("projects")) {
|
||||||
OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects
|
OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects
|
||||||
} else {
|
} 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.ActiveSessionsResponse
|
||||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
||||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
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.CancellationException
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -148,8 +150,8 @@ object SessionLiveStatusDecoder {
|
|||||||
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
|
* Production implementation of [SessionLiveStatusSource] backed by [HermesWsClient].
|
||||||
*/
|
*/
|
||||||
class HermesSessionLiveStatusSource(
|
class HermesSessionLiveStatusSource(
|
||||||
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
|
private val rpcRequest: suspend () -> Any? = {
|
||||||
HermesWsClient.request(method, params).await()
|
HermesWsClient.call(RpcMethods.SESSION_ACTIVE_LIST, SessionActiveListParams())
|
||||||
},
|
},
|
||||||
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
|
eventsProvider: () -> Flow<WsEvent> = { HermesWsClient.events },
|
||||||
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
|
connectionStatusProvider: () -> StateFlow<ConnectionStatus> = { HermesWsClient.connectionStatus },
|
||||||
@@ -159,7 +161,7 @@ class HermesSessionLiveStatusSource(
|
|||||||
|
|
||||||
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
|
override suspend fun fetchActiveSessionsSnapshot(): LiveSessionSnapshot? =
|
||||||
try {
|
try {
|
||||||
val raw = rpcRequest(WsMethods.SESSION_ACTIVE_LIST, emptyMap())
|
val raw = rpcRequest()
|
||||||
SessionLiveStatusDecoder.decodeSnapshot(raw)
|
SessionLiveStatusDecoder.decodeSnapshot(raw)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.m57.hermescontrol.data.ws
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.m57.hermescontrol.data.model.SubagentListResponse
|
import com.m57.hermescontrol.data.model.SubagentListResponse
|
||||||
import com.m57.hermescontrol.data.model.SubagentTailResponse
|
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.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
@@ -33,12 +36,11 @@ object SubagentRepository {
|
|||||||
if (sessionId.isBlank()) return null
|
if (sessionId.isBlank()) return null
|
||||||
return try {
|
return try {
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
RpcMethods.SUBAGENT_LIST,
|
||||||
WsMethods.SUBAGENT_LIST,
|
SessionIdParams(sessionId),
|
||||||
mapOf("session_id" to sessionId),
|
suppressErrorEvent = true,
|
||||||
suppressErrorEvent = true,
|
)
|
||||||
).await()
|
|
||||||
decode<SubagentListResponse>(result)
|
decode<SubagentListResponse>(result)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
|
Log.w(TAG, "subagent.list request failed for session $sessionId: ${e.message}")
|
||||||
@@ -55,24 +57,15 @@ object SubagentRepository {
|
|||||||
suspend fun tailSubagent(
|
suspend fun tailSubagent(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
subagentId: String,
|
subagentId: String,
|
||||||
maxBytes: Int = 16384,
|
|
||||||
): SubagentTailResponse? {
|
): SubagentTailResponse? {
|
||||||
if (subagentId.isBlank()) return null
|
// Contract (SubagentIdParams): session_id and subagent_id are required, extra keys forbidden (#1379).
|
||||||
val params =
|
if (subagentId.isBlank() || sessionId.isBlank()) return null
|
||||||
mutableMapOf<String, Any>(
|
|
||||||
"subagent_id" to subagentId,
|
|
||||||
"max_bytes" to maxBytes,
|
|
||||||
)
|
|
||||||
if (sessionId.isNotBlank()) {
|
|
||||||
params["session_id"] = sessionId
|
|
||||||
}
|
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
RpcMethods.SUBAGENT_TAIL,
|
||||||
WsMethods.SUBAGENT_TAIL,
|
SubagentTailParams(sessionId, subagentId),
|
||||||
params,
|
suppressErrorEvent = true,
|
||||||
suppressErrorEvent = true,
|
)
|
||||||
).await()
|
|
||||||
return decode<SubagentTailResponse>(result)
|
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.VaultSourcesResponse
|
||||||
import com.m57.hermescontrol.data.model.VaultUnlockResponse
|
import com.m57.hermescontrol.data.model.VaultUnlockResponse
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
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.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
@@ -48,7 +53,7 @@ object VaultRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getSources(): List<VaultSource> {
|
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()
|
return decode<VaultSourcesResponse>(result)?.sources ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +62,10 @@ object VaultRepository {
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
RpcMethods.VAULT_SOURCE_SET,
|
||||||
WsMethods.VAULT_SOURCE_SET,
|
VaultSourceSetParams(name = name, enabled = enabled),
|
||||||
mapOf("name" to name, "enabled" to enabled),
|
)
|
||||||
).await()
|
|
||||||
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
|
return decode<VaultSourceSetResponse>(result)?.enabled == enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,22 +74,21 @@ object VaultRepository {
|
|||||||
password: String,
|
password: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
RpcMethods.VAULT_UNLOCK,
|
||||||
WsMethods.VAULT_UNLOCK,
|
VaultUnlockParams(name = name, password = password),
|
||||||
mapOf("name" to name, "password" to password),
|
)
|
||||||
).await()
|
|
||||||
return decode<VaultUnlockResponse>(result)?.unlocked == true
|
return decode<VaultUnlockResponse>(result)?.unlocked == true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun lockSource(name: String? = null): Boolean {
|
suspend fun lockSource(name: String? = null): Boolean {
|
||||||
val params = if (name != null) mapOf("name" to name) else emptyMap<String, Any>()
|
val params = VaultLockParams(name = name)
|
||||||
val result = HermesWsClient.request(WsMethods.VAULT_LOCK, params).await()
|
val result = HermesWsClient.call(RpcMethods.VAULT_LOCK, params)
|
||||||
return decode<VaultLockResponse>(result)?.locked == true
|
return decode<VaultLockResponse>(result)?.locked == true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listItems(): List<VaultItem> {
|
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()
|
return decode<VaultListResponse>(result)?.items ?: emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +445,18 @@ sealed class WsEvent {
|
|||||||
val kind: String = "",
|
val kind: String = "",
|
||||||
) : WsEvent()
|
) : 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) ──────────────────────────────────────────
|
// ── Replay resync (internal) ──────────────────────────────────────────
|
||||||
|
|
||||||
/** Internal: replay could not cover the reconnect gap (truncated or epoch change) — UI must refetch history. */
|
/** 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). */
|
/** Kill a single background process (scoped to the active session). */
|
||||||
const val PROCESS_KILL = "process.kill"
|
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) ───────────────────────────────────────────
|
// ── Subagents (issue #1089) ───────────────────────────────────────────
|
||||||
|
|
||||||
/** List active subagents and delegations for a session. */
|
/** List active subagents and delegations for a session. */
|
||||||
|
|||||||
@@ -0,0 +1,912 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.m57.hermescontrol.BuildConfig
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.CookieManager
|
||||||
|
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import com.m57.hermescontrol.data.remote.await
|
||||||
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.utf8Size
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket lifecycle, authentication, outbound queueing, and network transport management.
|
||||||
|
*
|
||||||
|
* Extracted from [HermesWsClient] (issue #1376).
|
||||||
|
*/
|
||||||
|
internal class WsTransport(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val outboundLock: Any,
|
||||||
|
private val reconnectPolicy: ReconnectPolicy,
|
||||||
|
private val connectionHealth: ConnectionHealth,
|
||||||
|
private val replayTracker: ReplayTracker,
|
||||||
|
private val rpcChannel: RpcChannel,
|
||||||
|
private val onRawMessage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HermesWsClient"
|
||||||
|
private const val MAX_OUTBOUND_MESSAGE_BYTES = 16 * 1024 * 1024
|
||||||
|
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 60_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connectionGeneration = AtomicInteger(0)
|
||||||
|
private val connected = AtomicBoolean(false)
|
||||||
|
private val intentionalClose = AtomicBoolean(false)
|
||||||
|
private val acceptQueuedMessages = AtomicBoolean(true)
|
||||||
|
private val appInForeground = AtomicBoolean(true)
|
||||||
|
private val externalActivityConnectionLease = AtomicBoolean(false)
|
||||||
|
private val backgroundConnectionLease = AtomicBoolean(false)
|
||||||
|
private val messageQueue = ConcurrentLinkedQueue<String>()
|
||||||
|
private val queuedMessagesById = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var webSocket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closingSocket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var outboundDrainJob: Job? = null
|
||||||
|
|
||||||
|
private val networkCollectorStarted = AtomicBoolean(false)
|
||||||
|
|
||||||
|
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||||
|
val connectionStatus: StateFlow<ConnectionStatus> = _connectionStatus.asStateFlow()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pendingReply: Boolean = false
|
||||||
|
internal set
|
||||||
|
|
||||||
|
private val pendingPromptSubmits = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
|
val isConnected: Boolean get() = connected.get()
|
||||||
|
|
||||||
|
fun setAppForeground(foreground: Boolean) {
|
||||||
|
appInForeground.set(foreground)
|
||||||
|
if (!foreground) {
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectionHealth.probeLivenessOnWake()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acquireExternalActivityConnectionLease() {
|
||||||
|
externalActivityConnectionLease.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseExternalActivityConnectionLease() {
|
||||||
|
externalActivityConnectionLease.set(false)
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acquireBackgroundConnectionLease() {
|
||||||
|
backgroundConnectionLease.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseBackgroundConnectionLease() {
|
||||||
|
backgroundConnectionLease.set(false)
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasBackgroundConnectionLease(): Boolean = backgroundConnectionLease.get()
|
||||||
|
|
||||||
|
fun disconnectIfIdleInBackground() {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!appInForeground.get() &&
|
||||||
|
!externalActivityConnectionLease.get() &&
|
||||||
|
!backgroundConnectionLease.get() &&
|
||||||
|
!pendingReply &&
|
||||||
|
!rpcChannel.hasPendingCalls() &&
|
||||||
|
messageQueue.isEmpty()
|
||||||
|
) {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
if (networkCollectorStarted.compareAndSet(false, true)) {
|
||||||
|
scope.launch {
|
||||||
|
NetworkMonitor.networkChanges.collect { networkAvailable ->
|
||||||
|
reconnectForNetworkChange(networkAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
NetworkMonitor.transportChanges.collect {
|
||||||
|
connectionHealth.probeLivenessOnTransportChange(
|
||||||
|
onFailureAction = { cancelSocket() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var socketToCancel: WebSocket? = null
|
||||||
|
val shouldOpen =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
acceptQueuedMessages.set(true)
|
||||||
|
if (_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
socketToCancel = webSocket
|
||||||
|
webSocket = null
|
||||||
|
closingSocket = null
|
||||||
|
connected.set(false)
|
||||||
|
connectionHealth.stop()
|
||||||
|
}
|
||||||
|
if (connected.get()) {
|
||||||
|
Log.d(TAG, "Already connected — skipping")
|
||||||
|
return@synchronized false
|
||||||
|
}
|
||||||
|
if (_connectionStatus.value == ConnectionStatus.CONNECTING ||
|
||||||
|
_connectionStatus.value == ConnectionStatus.RECONNECTING
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "Connection already in flight (${_connectionStatus.value}) — skipping")
|
||||||
|
return@synchronized false
|
||||||
|
}
|
||||||
|
// An explicit connect follows successful authentication and is
|
||||||
|
// therefore allowed to leave the terminal AUTH_EXPIRED state.
|
||||||
|
intentionalClose.set(false)
|
||||||
|
_connectionStatus.value = ConnectionStatus.CONNECTING
|
||||||
|
reconnectPolicy.reset()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
if (socketToCancel != null) {
|
||||||
|
rpcChannel.rejectAllPending()
|
||||||
|
socketToCancel.cancel()
|
||||||
|
}
|
||||||
|
if (shouldOpen) openSocket()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconnectForNetworkChange(
|
||||||
|
networkAvailable: Boolean = NetworkMonitor.isConnected.value,
|
||||||
|
openReplacement: () -> Unit = ::openSocket,
|
||||||
|
) {
|
||||||
|
val socketToCancel: WebSocket?
|
||||||
|
val shouldOpen: Boolean
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (intentionalClose.get() || _connectionStatus.value == ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Default network changed — reconnecting WebSocket")
|
||||||
|
reconnectPolicy.reset()
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
socketToCancel = webSocket
|
||||||
|
webSocket = null
|
||||||
|
closingSocket = null
|
||||||
|
connected.set(false)
|
||||||
|
connectionHealth.stop()
|
||||||
|
shouldOpen = networkAvailable && AuthManager.isAutoReconnect()
|
||||||
|
_connectionStatus.value =
|
||||||
|
when {
|
||||||
|
!networkAvailable -> ConnectionStatus.NO_NETWORK
|
||||||
|
shouldOpen -> ConnectionStatus.RECONNECTING
|
||||||
|
else -> ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
rpcChannel.rejectAllPending()
|
||||||
|
}
|
||||||
|
socketToCancel?.cancel()
|
||||||
|
if (shouldOpen) openReplacement()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
|
||||||
|
val isGated =
|
||||||
|
try {
|
||||||
|
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// serverStore not initialized yet (transient early call); treat
|
||||||
|
// as loopback so a stale-token refresh still runs. Log so a real
|
||||||
|
// misconfiguration isn't silently swallowed.
|
||||||
|
Log.w(TAG, "serverStore uninitialized during WS handshake; assuming non-gated")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!isGated) {
|
||||||
|
// The loopback dashboard token is regenerated on every server
|
||||||
|
// restart. Refresh it before each WebSocket handshake so automatic
|
||||||
|
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
|
||||||
|
val token =
|
||||||
|
runCatching {
|
||||||
|
DashboardSessionTokenRefresher.refreshAsync()
|
||||||
|
}.getOrNull()
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (isCurrentGeneration(generation) && token != null) {
|
||||||
|
runCatching { AuthManager.setToken(token) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val ticketResult = requestWsTicket()
|
||||||
|
if (!ticketResult.ticket.isNullOrBlank()) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrentGeneration(generation)) return false
|
||||||
|
AuthManager.setToken(ticketResult.ticket)
|
||||||
|
}
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val status =
|
||||||
|
when {
|
||||||
|
ticketResult.exception -> ConnectionStatus.RECONNECTING
|
||||||
|
ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED
|
||||||
|
ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING
|
||||||
|
ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING
|
||||||
|
else -> ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
return handleWsTicketRefreshFailure(status, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mintWsTicket(): String? {
|
||||||
|
val isGated =
|
||||||
|
try {
|
||||||
|
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!isGated) {
|
||||||
|
DashboardSessionTokenRefresher.refreshAsync()
|
||||||
|
return AuthManager.getToken()
|
||||||
|
}
|
||||||
|
return requestWsTicket().ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TicketRequestResult(
|
||||||
|
val ticket: String?,
|
||||||
|
val httpCode: Int?,
|
||||||
|
val exception: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun requestWsTicket(): TicketRequestResult {
|
||||||
|
try {
|
||||||
|
val client = OkHttpProvider.probe
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString())
|
||||||
|
.post("{}".toRequestBody())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val (code, body) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (CookieManager.isInitialized()) {
|
||||||
|
CookieManager.useStore(CookieManager.cookieJar.currentServer())
|
||||||
|
}
|
||||||
|
client.newCall(request).await().use { resp ->
|
||||||
|
resp.code to resp.body.string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code in 200..299) {
|
||||||
|
// Real JSON decode — the ticket is base64url and may contain
|
||||||
|
// characters a naive [^"]+ regex cannot extract (see
|
||||||
|
// testGatedMode_parsesEscapedTicketFromRealJsonShape).
|
||||||
|
val parsed =
|
||||||
|
runCatching {
|
||||||
|
OkHttpProvider.json.decodeFromString<WsTicketResponse>(body)
|
||||||
|
}.getOrNull()
|
||||||
|
val ticket = parsed?.ticket
|
||||||
|
if (!ticket.isNullOrBlank()) {
|
||||||
|
return TicketRequestResult(ticket, null)
|
||||||
|
}
|
||||||
|
Log.w(TAG, "WS ticket mint failed: unparseable ticket response")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS ticket mint failed: HTTP $code")
|
||||||
|
return TicketRequestResult(null, code)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Include the stack: the exception class alone (e.g.
|
||||||
|
// NetworkOnMainThreadException) can't show WHICH caller on what
|
||||||
|
// thread tripped the mint (main-scope kanban events connect vs
|
||||||
|
// the WS reconnect path).
|
||||||
|
Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}", e)
|
||||||
|
return TicketRequestResult(null, null, exception = true)
|
||||||
|
}
|
||||||
|
return TicketRequestResult(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWsTicketRefreshFailure(
|
||||||
|
status: ConnectionStatus,
|
||||||
|
generation: Int,
|
||||||
|
): Boolean {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrentGeneration(generation)) return false
|
||||||
|
_connectionStatus.value = status
|
||||||
|
if (status == ConnectionStatus.RECONNECTING) scheduleReconnect()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect(clearPendingMessages: Boolean = false) {
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
intentionalClose.set(true)
|
||||||
|
acceptQueuedMessages.set(!clearPendingMessages)
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
connectionHealth.cancelForegroundProbe()
|
||||||
|
connectionHealth.stop()
|
||||||
|
webSocket?.close(1000, "Client closed")
|
||||||
|
webSocket = null
|
||||||
|
closingSocket = null
|
||||||
|
rpcChannel.clearCapabilityRequests()
|
||||||
|
if (clearPendingMessages) {
|
||||||
|
backgroundConnectionLease.set(false)
|
||||||
|
messageQueue.clear()
|
||||||
|
queuedMessagesById.clear()
|
||||||
|
pendingPromptSubmits.clear()
|
||||||
|
pendingReply = false
|
||||||
|
replayTracker.clear()
|
||||||
|
}
|
||||||
|
connected.set(false)
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelSocket() {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (connected.get()) webSocket?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendDirectFrame(frameJson: String): Boolean =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
val ws = webSocket
|
||||||
|
if (ws == null || !connected.get()) false else ws.send(frameJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(
|
||||||
|
request: JsonRpcRequest,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
queueIfDisconnected: Boolean = true,
|
||||||
|
): String {
|
||||||
|
val id = request.id
|
||||||
|
val method = request.method
|
||||||
|
val json = rpcChannel.encodeRequest(request)
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, formatSafeOutgoingFrameLog(id, method, request.params.keys, json.length, queued = false))
|
||||||
|
}
|
||||||
|
var reconnect = false
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (method == WsMethods.PROMPT_SUBMIT) {
|
||||||
|
pendingPromptSubmits.add(id)
|
||||||
|
pendingReply = true
|
||||||
|
}
|
||||||
|
val ws = webSocket
|
||||||
|
if (ws != null && connected.get()) {
|
||||||
|
// Never replay send(true): the server may have executed it even if its response is lost.
|
||||||
|
if (ws.send(json)) {
|
||||||
|
onSent?.invoke(id)
|
||||||
|
} else {
|
||||||
|
if (webSocket !== ws || !acceptQueuedMessages.get()) return@synchronized
|
||||||
|
if (queueIfDisconnected && isRetryableMessage(json)) {
|
||||||
|
onSent?.invoke(id)
|
||||||
|
Log.w(TAG, "WS rejected outgoing message — queuing for reconnect")
|
||||||
|
queueMessage(id, json)
|
||||||
|
recoverRejectedSocket(ws)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS rejected oversized outgoing message — not retrying")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (acceptQueuedMessages.get() && queueIfDisconnected) {
|
||||||
|
if (isRetryableMessage(json)) {
|
||||||
|
onSent?.invoke(id)
|
||||||
|
Log.d(TAG, "WS disconnected — queuing message")
|
||||||
|
queueMessage(id, json)
|
||||||
|
reconnect = true
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reconnect) connect()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeQueuedMessage(id: String) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
queuedMessagesById.remove(id)?.let(messageQueue::remove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRpcResult(id: String) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
pendingPromptSubmits.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRpcError(id: String) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (pendingPromptSubmits.remove(id) && pendingPromptSubmits.isEmpty()) {
|
||||||
|
pendingReply = false
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeEvent(event: WsEvent) {
|
||||||
|
when (event) {
|
||||||
|
is WsEvent.MessageStart,
|
||||||
|
is WsEvent.MessageToken,
|
||||||
|
is WsEvent.ThinkingDelta,
|
||||||
|
is WsEvent.ReasoningDelta,
|
||||||
|
is WsEvent.ToolStart,
|
||||||
|
-> {
|
||||||
|
pendingReply = true
|
||||||
|
}
|
||||||
|
|
||||||
|
is WsEvent.MessageComplete -> {
|
||||||
|
pendingReply = false
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isRetryableMessage(json: String): Boolean {
|
||||||
|
if (json.length > MAX_OUTBOUND_MESSAGE_BYTES) return false
|
||||||
|
if (json.length <= MAX_OUTBOUND_MESSAGE_BYTES / 4) return true
|
||||||
|
return json.utf8Size() <= MAX_OUTBOUND_MESSAGE_BYTES.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queueMessage(
|
||||||
|
id: String,
|
||||||
|
json: String,
|
||||||
|
) {
|
||||||
|
if (queuedMessagesById.putIfAbsent(id, json) == null) messageQueue.add(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markQueuedMessageSent(json: String) {
|
||||||
|
queuedMessagesById.entries.removeIf { it.value == json }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoverRejectedSocket(ws: WebSocket) {
|
||||||
|
connected.set(false)
|
||||||
|
if (closingSocket === ws) return
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
if (ws.queueSize() == 0L) {
|
||||||
|
ws.cancel()
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (intentionalClose.get()) return
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(OUTBOUND_DRAIN_TIMEOUT_MS)
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!connected.get() && !intentionalClose.get() && webSocket === ws) {
|
||||||
|
ws.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(
|
||||||
|
sessionId: String,
|
||||||
|
text: String,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
queued: Boolean = false,
|
||||||
|
): String =
|
||||||
|
send(
|
||||||
|
rpcChannel.createRequest(
|
||||||
|
method = RpcMethods.PROMPT_SUBMIT.name,
|
||||||
|
params =
|
||||||
|
PromptSubmitParams(
|
||||||
|
sessionId = sessionId,
|
||||||
|
text = text,
|
||||||
|
queued = queued.takeIf { it },
|
||||||
|
).let { rpcChannel.encodeParams(RpcMethods.PROMPT_SUBMIT, it) },
|
||||||
|
),
|
||||||
|
onSent = onSent,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun sendRedirect(
|
||||||
|
sessionId: String,
|
||||||
|
text: String,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
): String =
|
||||||
|
send(
|
||||||
|
rpcChannel.createRequest(
|
||||||
|
method = RpcMethods.SESSION_REDIRECT.name,
|
||||||
|
params =
|
||||||
|
SessionCorrectionParams(
|
||||||
|
sessionId = sessionId,
|
||||||
|
text = text,
|
||||||
|
).let { rpcChannel.encodeParams(RpcMethods.SESSION_REDIRECT, it) },
|
||||||
|
),
|
||||||
|
onSent = onSent,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun openSocket() {
|
||||||
|
val generation =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (intentionalClose.get()) return
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
}
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
if (!refreshWsTicketIfNeeded(generation)) {
|
||||||
|
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (!isCurrentGeneration(generation)) return@launch
|
||||||
|
val url = AuthManager.wsUrl()
|
||||||
|
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||||
|
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
|
||||||
|
webSocket = newSocket
|
||||||
|
} else {
|
||||||
|
newSocket.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCurrentGeneration(generation: Int): Boolean =
|
||||||
|
connectionGeneration.get() == generation && !intentionalClose.get()
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (intentionalClose.get() ||
|
||||||
|
_connectionStatus.value == ConnectionStatus.AUTH_EXPIRED ||
|
||||||
|
reconnectJob?.isActive == true
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!AuthManager.isAutoReconnect()) {
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Auto-reconnect disabled")
|
||||||
|
_connectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!NetworkMonitor.isConnected.value) {
|
||||||
|
Log.d(TAG, "No network available — delaying reconnect scheduling")
|
||||||
|
_connectionStatus.value = ConnectionStatus.NO_NETWORK
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val reconnectDelay = reconnectPolicy.nextDelayMs()
|
||||||
|
reconnectPolicy.advance()
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Reconnecting in ${reconnectDelay}ms …")
|
||||||
|
|
||||||
|
reconnectJob =
|
||||||
|
scope.launch {
|
||||||
|
delay(reconnectDelay)
|
||||||
|
val owner = currentCoroutineContext()[Job]
|
||||||
|
val shouldOpen =
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (reconnectJob !== owner) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
reconnectJob = null
|
||||||
|
!intentionalClose.get() &&
|
||||||
|
!connected.get() &&
|
||||||
|
_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldOpen) openSocket()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTerminalAuthClose(
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
): Boolean =
|
||||||
|
code == 4401 || code == 4403 ||
|
||||||
|
reason.contains("unauthorized", ignoreCase = true) ||
|
||||||
|
reason.startsWith("auth:", ignoreCase = true)
|
||||||
|
|
||||||
|
private inner class WsListenerImpl(
|
||||||
|
private val generation: Int,
|
||||||
|
) : WebSocketListener() {
|
||||||
|
private fun isCurrent(): Boolean = isCurrentGeneration(generation)
|
||||||
|
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: Response,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) {
|
||||||
|
webSocket.close(1000, "Superseded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this@WsTransport.webSocket = webSocket
|
||||||
|
closingSocket = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
Log.i(TAG, "WebSocket opened")
|
||||||
|
connected.set(true)
|
||||||
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
|
reconnectPolicy.reset()
|
||||||
|
connectionHealth.start()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val msg = messageQueue.peek() ?: break
|
||||||
|
if (!isRetryableMessage(msg)) {
|
||||||
|
Log.w(TAG, "Dropping oversized queued message")
|
||||||
|
messageQueue.poll()
|
||||||
|
markQueuedMessageSent(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeQueuedFrameLog(msg))
|
||||||
|
if (!webSocket.send(msg)) {
|
||||||
|
recoverRejectedSocket(webSocket)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
messageQueue.poll()
|
||||||
|
markQueuedMessageSent(msg)
|
||||||
|
}
|
||||||
|
replayTracker.triggerReplay()
|
||||||
|
}
|
||||||
|
disconnectIfIdleInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
if (!isCurrent() || this@WsTransport.webSocket !== webSocket) return
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, formatSafeIncomingFrameLog(text))
|
||||||
|
connectionHealth.onInboundFrame()
|
||||||
|
onRawMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) {
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closingSocket = webSocket
|
||||||
|
// Do NOT log [reason] — it may carry server-side context.
|
||||||
|
Log.d(TAG, "WebSocket closing: $code")
|
||||||
|
if (isTerminalAuthClose(code, reason)) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
}
|
||||||
|
webSocket.close(code, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) return
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
closingSocket = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
|
||||||
|
// Do NOT log [reason] — it may carry server-side context. The
|
||||||
|
// reason is still inspected internally to detect auth failures.
|
||||||
|
Log.i(TAG, "WebSocket closed: $code")
|
||||||
|
connected.set(false)
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
connectionHealth.stop()
|
||||||
|
rpcChannel.clearCapabilityRequests()
|
||||||
|
if (isTerminalAuthClose(code, reason)) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
t: Throwable,
|
||||||
|
response: Response?,
|
||||||
|
) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
if (!isCurrent()) return
|
||||||
|
connectionGeneration.incrementAndGet()
|
||||||
|
closingSocket = null
|
||||||
|
outboundDrainJob?.cancel()
|
||||||
|
outboundDrainJob = null
|
||||||
|
if (this@WsTransport.webSocket === webSocket) this@WsTransport.webSocket = null
|
||||||
|
// Log the exception class only — [Throwable.message] can leak URLs
|
||||||
|
// or headers. The message is still inspected internally for auth
|
||||||
|
// detection.
|
||||||
|
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
|
||||||
|
connected.set(false)
|
||||||
|
ActiveSessionHolder.clear()
|
||||||
|
connectionHealth.stop()
|
||||||
|
rpcChannel.clearCapabilityRequests()
|
||||||
|
val code = response?.code ?: 0
|
||||||
|
if (code == 401 || code == 4401 || code == 4403 ||
|
||||||
|
t.message?.contains("401") == true ||
|
||||||
|
t.message?.contains("4401") == true ||
|
||||||
|
t.message?.contains("4403") == true ||
|
||||||
|
t.message?.contains("unauthorized", ignoreCase = true) == true
|
||||||
|
) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.AUTH_EXPIRED
|
||||||
|
} else if (_connectionStatus.value != ConnectionStatus.AUTH_EXPIRED) {
|
||||||
|
_connectionStatus.value = ConnectionStatus.RECONNECTING
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test seams (issue #1376 extraction support) ──────────────────────
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val connectedForTest: AtomicBoolean
|
||||||
|
get() = connected
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val intentionalCloseForTest: AtomicBoolean
|
||||||
|
get() = intentionalClose
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val connectionGenerationForTest: AtomicInteger
|
||||||
|
get() = connectionGeneration
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val messageQueueForTest: ConcurrentLinkedQueue<String>
|
||||||
|
get() = messageQueue
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val outboundLockForTest: Any
|
||||||
|
get() = outboundLock
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val acceptQueuedMessagesForTest: AtomicBoolean
|
||||||
|
get() = acceptQueuedMessages
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal val connectionStatusForTest: MutableStateFlow<ConnectionStatus>
|
||||||
|
get() = _connectionStatus
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var webSocketForTest: WebSocket?
|
||||||
|
get() = webSocket
|
||||||
|
set(value) {
|
||||||
|
webSocket = value
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var currentBackoffForTest: Long
|
||||||
|
get() = synchronized(outboundLock) { reconnectPolicy.currentBackoff }
|
||||||
|
set(value) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
reconnectPolicy.currentBackoff = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var reconnectJobForTest: Job?
|
||||||
|
get() = reconnectJob
|
||||||
|
set(value) {
|
||||||
|
reconnectJob = value
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal var outboundDrainJobForTest: Job?
|
||||||
|
get() = outboundDrainJob
|
||||||
|
set(value) {
|
||||||
|
outboundDrainJob = value
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setReconnectBackoffForTest(initialMillis: Long) {
|
||||||
|
synchronized(outboundLock) {
|
||||||
|
reconnectPolicy.setBackoffForTest(initialMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun scheduleReconnectForTest() {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun createListenerForTest(generation: Int): WebSocketListener = WsListenerImpl(generation)
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun setConnectionStatusForTest(status: ConnectionStatus) {
|
||||||
|
_connectionStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun formatSafeOutgoingFrameLog(
|
||||||
|
id: String,
|
||||||
|
method: String,
|
||||||
|
paramsKeys: Collection<String>?,
|
||||||
|
byteLength: Int,
|
||||||
|
queued: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val prefix = if (queued) "→ (queued)" else "→"
|
||||||
|
val keys = paramsKeys?.joinToString(",", prefix = "[", postfix = "]") ?: "[]"
|
||||||
|
return "$prefix id=$id method=$method paramsKeys=$keys bytes=$byteLength"
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun formatSafeQueuedFrameLog(msg: String): String =
|
||||||
|
try {
|
||||||
|
val req = OkHttpProvider.json.decodeFromString<JsonRpcRequest>(msg)
|
||||||
|
formatSafeOutgoingFrameLog(req.id, req.method, req.params.keys, msg.length, queued = true)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
"→ (queued) frame bytes=${msg.length}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun formatSafeIncomingFrameLog(text: String): String =
|
||||||
|
try {
|
||||||
|
val element = OkHttpProvider.json.parseToJsonElement(text)
|
||||||
|
if (element is JsonObject) {
|
||||||
|
val id = (element["id"] as? JsonPrimitive)?.content
|
||||||
|
val method = (element["method"] as? JsonPrimitive)?.content
|
||||||
|
val hasResult = element.containsKey("result")
|
||||||
|
val resultKeys =
|
||||||
|
(element["result"] as? JsonObject)?.keys?.joinToString(
|
||||||
|
",",
|
||||||
|
prefix = "[",
|
||||||
|
postfix = "]",
|
||||||
|
)
|
||||||
|
val errorObj = element["error"] as? JsonObject
|
||||||
|
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.content
|
||||||
|
val sb = StringBuilder("← ")
|
||||||
|
if (id != null) sb.append("id=").append(id).append(" ")
|
||||||
|
if (method != null) sb.append("method=").append(method).append(" ")
|
||||||
|
if (hasResult) sb.append("result=").append(resultKeys ?: "present").append(" ")
|
||||||
|
if (errorCode != null) sb.append("errorCode=").append(errorCode).append(" ")
|
||||||
|
sb.append("bytes=").append(text.length).toString()
|
||||||
|
} else {
|
||||||
|
"← non-object frame bytes=${text.length}"
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
"← frame bytes=${text.length}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApprovalPendingParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApprovalReceivedParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("request_id") val requestId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ApprovalRespondParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("choice") val choice: String? = null,
|
||||||
|
@SerialName("all") val all: Boolean? = null,
|
||||||
|
@SerialName("request_id") val requestId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionActiveListParams(
|
||||||
|
@SerialName("current_session_id") val currentSessionId: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object EmptyParams
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SubscriptionPreviewParams(
|
||||||
|
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SubscriptionChangeParams(
|
||||||
|
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
|
||||||
|
@SerialName("cancel") val cancel: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SubscriptionUpgradeParams(
|
||||||
|
@SerialName("subscription_type_id") val subscriptionTypeId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VaultSourceSetParams(
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
@SerialName("enabled") val enabled: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VaultUnlockParams(
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
@SerialName("password") val password: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VaultLockParams(
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `commands.catalog` is sent without explicit params; the active profile is injected by WsProfileParams. */
|
||||||
|
@Serializable
|
||||||
|
data object CommandsCatalogParams
|
||||||
|
|
||||||
|
/** `arg` is sent whenever non-null, including an empty string, exactly like the old map payload. */
|
||||||
|
@Serializable
|
||||||
|
data class CommandDispatchParams(
|
||||||
|
@SerialName("name") val name: String,
|
||||||
|
@SerialName("arg") val arg: String? = null,
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SlashExecParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("command") val command: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `data_url` carries the bytes for an uploaded file; `path` is for gateway-visible files. */
|
||||||
|
@Serializable
|
||||||
|
data class FileAttachParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("path") val path: String? = null,
|
||||||
|
@SerialName("data_url") val dataUrl: String? = null,
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The mobile client always sends the payload as `content_base64`; the `data` alias is not modelled. */
|
||||||
|
@Serializable
|
||||||
|
data class ImageAttachBytesParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("content_base64") val contentBase64: String? = null,
|
||||||
|
@SerialName("filename") val filename: String? = null,
|
||||||
|
@SerialName("ext") val ext: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `config.set` params. [key] and [value] are always encoded (no defaults); the rest are omitted
|
||||||
|
* when null. `profile` is injected by WsProfileParams, so it is not a field here.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ConfigSetParams(
|
||||||
|
@SerialName("key") val key: String,
|
||||||
|
@SerialName("value") val value: String,
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
@SerialName("scope") val scope: String? = null,
|
||||||
|
@SerialName("confirm_expensive_model") val confirmExpensiveModel: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConfigGetParams(
|
||||||
|
@SerialName("key") val key: String,
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
@SerialName("cwd") val cwd: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `model.options` params. Flags are `Boolean?` so an explicit `false` is still sent while an
|
||||||
|
* unset flag is omitted (the client Json has `encodeDefaults = false`).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ModelOptionsParams(
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
@SerialName("explicit_only") val explicitOnly: Boolean? = null,
|
||||||
|
@SerialName("include_unconfigured") val includeUnconfigured: Boolean? = null,
|
||||||
|
@SerialName("refresh") val refresh: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ClientCapabilitiesParams(
|
||||||
|
@SerialName("server_requests") val serverRequests: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectionAnswerTarget(
|
||||||
|
@SerialName("name") val name: String,
|
||||||
|
@SerialName("status") val status: String,
|
||||||
|
@SerialName("detail") val detail: String? = null,
|
||||||
|
@SerialName("env") val env: Map<String, String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectionAnswer(
|
||||||
|
@SerialName("targets") val targets: List<ConnectionAnswerTarget>? = null,
|
||||||
|
@SerialName("settled_by") val settledBy: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectionRespondParams(
|
||||||
|
@SerialName("owner") val owner: ConnectorOwner,
|
||||||
|
@SerialName("op_id") val opId: String,
|
||||||
|
@SerialName("result") val result: ConnectionAnswer,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorOwner(
|
||||||
|
@SerialName("type") val type: String,
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun account() = ConnectorOwner("account")
|
||||||
|
|
||||||
|
fun session(sessionId: String) = ConnectorOwner("session", sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsListParams(
|
||||||
|
@SerialName("owner") val owner: ConnectorOwner,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsConnectParams(
|
||||||
|
@SerialName("owner") val owner: ConnectorOwner,
|
||||||
|
@SerialName("connectors") val connectors: List<String>,
|
||||||
|
@SerialName("reconnect") val reconnect: Boolean? = null,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsOperationStatusParams(
|
||||||
|
@SerialName("owner") val owner: ConnectorOwner,
|
||||||
|
@SerialName("op_id") val opId: String,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsCatalogParams(
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsAccountsParams(
|
||||||
|
@SerialName("connector") val connector: String? = null,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsAccountsRemoveParams(
|
||||||
|
@SerialName("connection_id") val connectionId: String,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsPolicyGetParams(
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsPolicySetParams(
|
||||||
|
@SerialName("change") val change: JsonObject,
|
||||||
|
@SerialName("expected_revision") val expectedRevision: String,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ConnectorsToolsParams(
|
||||||
|
@SerialName("slug") val slug: String,
|
||||||
|
@SerialName("refresh") val refresh: Boolean? = null,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `plugins.manage` params. [action] has no default so it is always encoded; every other field is
|
||||||
|
* omitted when null. [profile] is an explicit override only; otherwise WsProfileParams injects it.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PluginsManageParams(
|
||||||
|
@SerialName("action") val action: String,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
@SerialName("key") val key: String? = null,
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
@SerialName("enable") val enable: Boolean? = null,
|
||||||
|
@SerialName("identifier") val identifier: String? = null,
|
||||||
|
@SerialName("repo") val repo: String? = null,
|
||||||
|
@SerialName("catalog_name") val catalogName: String? = null,
|
||||||
|
@SerialName("force") val force: Boolean? = null,
|
||||||
|
@SerialName("ref") val ref: String? = null,
|
||||||
|
@SerialName("accept_capabilities") val acceptCapabilities: Boolean? = null,
|
||||||
|
@SerialName("values") val values: JsonObject? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProcessKillParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("process_id") val processId: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `process.stop` kills every background process in the gateway registry (the desktop `/stop` second half). Desktop
|
||||||
|
* sends `{}`: the backend call is global, so no `session_id` or `profile` override is sent.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data object ProcessStopParams
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ProcessStopResult(
|
||||||
|
val killed: Int? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `profiles.configure` params for the sections the app writes: `ui_meta` and the bot `soul`.
|
||||||
|
* `profile` is not a field: WsProfileParams injects it when the method is profile scoped.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ProfilesConfigureParams(
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
@SerialName("ui_meta") val uiMeta: JsonObject? = null,
|
||||||
|
@SerialName("soul") val soul: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `profiles.list` takes no explicit params here; the active profile is injected by WsProfileParams. */
|
||||||
|
@Serializable
|
||||||
|
data object ProfilesListParams
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `projects.list` takes no explicit params; the active profile is injected by WsProfileParams. */
|
||||||
|
@Serializable
|
||||||
|
data object ProjectsListParams
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.WsMethods
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
class RpcMethod<P, R>(
|
||||||
|
val name: String,
|
||||||
|
val params: KSerializer<P>,
|
||||||
|
val result: KSerializer<R>,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface TypedRpcSender {
|
||||||
|
fun <P> send(
|
||||||
|
method: RpcMethod<P, *>,
|
||||||
|
params: P,
|
||||||
|
onSent: ((String) -> Unit)? = null,
|
||||||
|
): String
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypedRpcCaller {
|
||||||
|
suspend fun <P, R> call(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
params: P,
|
||||||
|
): R
|
||||||
|
}
|
||||||
|
|
||||||
|
object HermesRpcCaller : TypedRpcCaller {
|
||||||
|
override suspend fun <P, R> call(
|
||||||
|
method: RpcMethod<P, R>,
|
||||||
|
params: P,
|
||||||
|
): R = HermesWsClient.call(method, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
object RpcMethods {
|
||||||
|
val SESSION_EVENTS_SINCE: RpcMethod<SessionEventsSinceParams, SessionEventsSinceResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_EVENTS_SINCE,
|
||||||
|
SessionEventsSinceParams.serializer(),
|
||||||
|
SessionEventsSinceResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_CREATE: RpcMethod<SessionCreateParams, SessionCreateResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_CREATE,
|
||||||
|
SessionCreateParams.serializer(),
|
||||||
|
SessionCreateResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_RESUME: RpcMethod<SessionResumeParams, SessionResumeResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_RESUME,
|
||||||
|
SessionResumeParams.serializer(),
|
||||||
|
SessionResumeResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val PROMPT_SUBMIT: RpcMethod<PromptSubmitParams, PromptSubmitResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROMPT_SUBMIT,
|
||||||
|
PromptSubmitParams.serializer(),
|
||||||
|
PromptSubmitResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_INTERRUPT: RpcMethod<SessionInterruptParams, SessionInterruptResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_INTERRUPT,
|
||||||
|
SessionInterruptParams.serializer(),
|
||||||
|
SessionInterruptResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_STEER: RpcMethod<SessionCorrectionParams, SessionCorrectionResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_STEER,
|
||||||
|
SessionCorrectionParams.serializer(),
|
||||||
|
SessionCorrectionResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_REDIRECT: RpcMethod<SessionCorrectionParams, SessionCorrectionResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_REDIRECT,
|
||||||
|
SessionCorrectionParams.serializer(),
|
||||||
|
SessionCorrectionResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val SESSION_LIST: RpcMethod<SessionListParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_LIST,
|
||||||
|
SessionListParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_BRANCH: RpcMethod<SessionBranchParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_BRANCH,
|
||||||
|
SessionBranchParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_BRANCH_WHOLE: RpcMethod<SessionBranchWholeParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_BRANCH_WHOLE,
|
||||||
|
SessionBranchWholeParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_COMPRESS: RpcMethod<SessionCompressParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_COMPRESS,
|
||||||
|
SessionCompressParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_CONTEXT_BREAKDOWN: RpcMethod<SessionIdParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_CONTEXT_BREAKDOWN,
|
||||||
|
SessionIdParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SESSION_USAGE: RpcMethod<SessionIdParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_USAGE,
|
||||||
|
SessionIdParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val PROMPT_BTW: RpcMethod<PromptBtwParams, PromptBtwResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROMPT_BTW,
|
||||||
|
PromptBtwParams.serializer(),
|
||||||
|
PromptBtwResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val APPROVAL_PENDING: RpcMethod<ApprovalPendingParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.APPROVAL_PENDING,
|
||||||
|
ApprovalPendingParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val APPROVAL_RECEIVED: RpcMethod<ApprovalReceivedParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.APPROVAL_RECEIVED,
|
||||||
|
ApprovalReceivedParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val APPROVAL_RESPOND: RpcMethod<ApprovalRespondParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.APPROVAL_RESPOND,
|
||||||
|
ApprovalRespondParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val SESSION_ACTIVE_LIST: RpcMethod<SessionActiveListParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SESSION_ACTIVE_LIST,
|
||||||
|
SessionActiveListParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val SUBSCRIPTION_STATE: RpcMethod<EmptyParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SUBSCRIPTION_STATE,
|
||||||
|
EmptyParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SUBSCRIPTION_PREVIEW: RpcMethod<SubscriptionPreviewParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SUBSCRIPTION_PREVIEW,
|
||||||
|
SubscriptionPreviewParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SUBSCRIPTION_CHANGE: RpcMethod<SubscriptionChangeParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SUBSCRIPTION_CHANGE,
|
||||||
|
SubscriptionChangeParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SUBSCRIPTION_UPGRADE: RpcMethod<SubscriptionUpgradeParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SUBSCRIPTION_UPGRADE,
|
||||||
|
SubscriptionUpgradeParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SUBSCRIPTION_RESUME: RpcMethod<EmptyParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SUBSCRIPTION_RESUME,
|
||||||
|
EmptyParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val USAGE_BARS: RpcMethod<EmptyParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.USAGE_BARS,
|
||||||
|
EmptyParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val VAULT_LIST: RpcMethod<EmptyParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.VAULT_LIST,
|
||||||
|
EmptyParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val VAULT_SOURCES: RpcMethod<EmptyParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.VAULT_SOURCES,
|
||||||
|
EmptyParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val VAULT_SOURCE_SET: RpcMethod<VaultSourceSetParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.VAULT_SOURCE_SET,
|
||||||
|
VaultSourceSetParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val VAULT_UNLOCK: RpcMethod<VaultUnlockParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.VAULT_UNLOCK,
|
||||||
|
VaultUnlockParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val VAULT_LOCK: RpcMethod<VaultLockParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.VAULT_LOCK,
|
||||||
|
VaultLockParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_LIST: RpcMethod<ConnectorsListParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_LIST,
|
||||||
|
ConnectorsListParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_CONNECT: RpcMethod<ConnectorsConnectParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_CONNECT,
|
||||||
|
ConnectorsConnectParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_OPERATION_STATUS: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_OPERATION_STATUS,
|
||||||
|
ConnectorsOperationStatusParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_CATALOG: RpcMethod<ConnectorsCatalogParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_CATALOG,
|
||||||
|
ConnectorsCatalogParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_ACCOUNTS: RpcMethod<ConnectorsAccountsParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_ACCOUNTS,
|
||||||
|
ConnectorsAccountsParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_ACCOUNTS_REMOVE: RpcMethod<ConnectorsAccountsRemoveParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||||
|
ConnectorsAccountsRemoveParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_POLICY_GET: RpcMethod<ConnectorsPolicyGetParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_POLICY_GET,
|
||||||
|
ConnectorsPolicyGetParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_POLICY_SET: RpcMethod<ConnectorsPolicySetParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_POLICY_SET,
|
||||||
|
ConnectorsPolicySetParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_TOOLS: RpcMethod<ConnectorsToolsParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_TOOLS,
|
||||||
|
ConnectorsToolsParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTION_RESPOND: RpcMethod<ConnectionRespondParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTION_RESPOND,
|
||||||
|
ConnectionRespondParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy parsers.
|
||||||
|
val CONNECTORS_OPERATION_WAKE: RpcMethod<ConnectorsOperationStatusParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONNECTORS_OPERATION_WAKE,
|
||||||
|
ConnectorsOperationStatusParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val SUBAGENT_LIST: RpcMethod<SessionIdParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SUBAGENT_LIST,
|
||||||
|
SessionIdParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val SUBAGENT_TAIL: RpcMethod<SubagentTailParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SUBAGENT_TAIL,
|
||||||
|
SubagentTailParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val PROCESS_LIST: RpcMethod<SessionIdParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROCESS_LIST,
|
||||||
|
SessionIdParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val PROCESS_KILL: RpcMethod<ProcessKillParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROCESS_KILL,
|
||||||
|
ProcessKillParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val PROCESS_STOP: RpcMethod<ProcessStopParams, ProcessStopResult> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROCESS_STOP,
|
||||||
|
ProcessStopParams.serializer(),
|
||||||
|
ProcessStopResult.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val PLUGINS_MANAGE: RpcMethod<PluginsManageParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PLUGINS_MANAGE,
|
||||||
|
PluginsManageParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val PROJECTS_LIST: RpcMethod<ProjectsListParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROJECTS_LIST,
|
||||||
|
ProjectsListParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val PROFILES_LIST: RpcMethod<ProfilesListParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROFILES_LIST,
|
||||||
|
ProfilesListParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val PROFILES_CONFIGURE: RpcMethod<ProfilesConfigureParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.PROFILES_CONFIGURE,
|
||||||
|
ProfilesConfigureParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val CONFIG_SET: RpcMethod<ConfigSetParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONFIG_SET,
|
||||||
|
ConfigSetParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val CONFIG_GET: RpcMethod<ConfigGetParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CONFIG_GET,
|
||||||
|
ConfigGetParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val MODEL_OPTIONS: RpcMethod<ModelOptionsParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.MODEL_OPTIONS,
|
||||||
|
ModelOptionsParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val CLIENT_CAPABILITIES: RpcMethod<ClientCapabilitiesParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.CLIENT_CAPABILITIES,
|
||||||
|
ClientCapabilitiesParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val COMMANDS_CATALOG: RpcMethod<CommandsCatalogParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.COMMANDS_CATALOG,
|
||||||
|
CommandsCatalogParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val COMMAND_DISPATCH: RpcMethod<CommandDispatchParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.COMMAND_DISPATCH,
|
||||||
|
CommandDispatchParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val SLASH_EXEC: RpcMethod<SlashExecParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.SLASH_EXEC,
|
||||||
|
SlashExecParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val FILE_ATTACH: RpcMethod<FileAttachParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.FILE_ATTACH,
|
||||||
|
FileAttachParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Passthrough JsonElement results: callers keep their existing legacy decoders.
|
||||||
|
val IMAGE_ATTACH_BYTES: RpcMethod<ImageAttachBytesParams, JsonElement> =
|
||||||
|
RpcMethod(
|
||||||
|
WsMethods.IMAGE_ATTACH_BYTES,
|
||||||
|
ImageAttachBytesParams.serializer(),
|
||||||
|
JsonElement.serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
|
||||||
|
val all: List<RpcMethod<*, *>> =
|
||||||
|
listOf(
|
||||||
|
SESSION_EVENTS_SINCE,
|
||||||
|
SESSION_CREATE,
|
||||||
|
SESSION_RESUME,
|
||||||
|
PROMPT_SUBMIT,
|
||||||
|
SESSION_INTERRUPT,
|
||||||
|
SESSION_STEER,
|
||||||
|
SESSION_REDIRECT,
|
||||||
|
SESSION_LIST,
|
||||||
|
SESSION_BRANCH,
|
||||||
|
SESSION_BRANCH_WHOLE,
|
||||||
|
SESSION_COMPRESS,
|
||||||
|
SESSION_CONTEXT_BREAKDOWN,
|
||||||
|
SESSION_USAGE,
|
||||||
|
PROMPT_BTW,
|
||||||
|
APPROVAL_PENDING,
|
||||||
|
APPROVAL_RECEIVED,
|
||||||
|
APPROVAL_RESPOND,
|
||||||
|
SESSION_ACTIVE_LIST,
|
||||||
|
SUBSCRIPTION_STATE,
|
||||||
|
SUBSCRIPTION_PREVIEW,
|
||||||
|
SUBSCRIPTION_CHANGE,
|
||||||
|
SUBSCRIPTION_UPGRADE,
|
||||||
|
SUBSCRIPTION_RESUME,
|
||||||
|
USAGE_BARS,
|
||||||
|
VAULT_LIST,
|
||||||
|
VAULT_SOURCES,
|
||||||
|
VAULT_SOURCE_SET,
|
||||||
|
VAULT_UNLOCK,
|
||||||
|
VAULT_LOCK,
|
||||||
|
CONNECTORS_LIST,
|
||||||
|
CONNECTORS_CONNECT,
|
||||||
|
CONNECTORS_OPERATION_STATUS,
|
||||||
|
CONNECTORS_CATALOG,
|
||||||
|
CONNECTORS_ACCOUNTS,
|
||||||
|
CONNECTORS_ACCOUNTS_REMOVE,
|
||||||
|
CONNECTORS_POLICY_GET,
|
||||||
|
CONNECTORS_POLICY_SET,
|
||||||
|
CONNECTORS_TOOLS,
|
||||||
|
CONNECTION_RESPOND,
|
||||||
|
CONNECTORS_OPERATION_WAKE,
|
||||||
|
SUBAGENT_LIST,
|
||||||
|
SUBAGENT_TAIL,
|
||||||
|
PROCESS_LIST,
|
||||||
|
PROCESS_KILL,
|
||||||
|
PROCESS_STOP,
|
||||||
|
PLUGINS_MANAGE,
|
||||||
|
PROJECTS_LIST,
|
||||||
|
PROFILES_LIST,
|
||||||
|
PROFILES_CONFIGURE,
|
||||||
|
CONFIG_SET,
|
||||||
|
CONFIG_GET,
|
||||||
|
MODEL_OPTIONS,
|
||||||
|
CLIENT_CAPABILITIES,
|
||||||
|
COMMANDS_CATALOG,
|
||||||
|
COMMAND_DISPATCH,
|
||||||
|
SLASH_EXEC,
|
||||||
|
FILE_ATTACH,
|
||||||
|
IMAGE_ATTACH_BYTES,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionInterruptParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionCorrectionParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("text") val text: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionInterruptResult(
|
||||||
|
val status: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionCorrectionResult(
|
||||||
|
val status: String? = null,
|
||||||
|
val text: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionEventsSinceParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("last_seen") val lastSeen: Int? = null,
|
||||||
|
) // NO profile field: profile is injected by WsProfileParams only.
|
||||||
|
|
||||||
|
// Tolerant reader: every field nullable/defaulted, mirrors what the old untyped parser tolerated.
|
||||||
|
@Serializable
|
||||||
|
data class SessionEventsSinceResult(
|
||||||
|
val events: List<JsonElement>? = null,
|
||||||
|
@SerialName("latest_seq") val latestSeq: Int? = null,
|
||||||
|
val truncated: Boolean? = null,
|
||||||
|
val epoch: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object SessionListParams
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionIdParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionBranchParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionBranchWholeParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("name") val name: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionCompressParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("focus_topic") val focusTopic: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PromptBtwParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("text") val text: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PromptBtwResult(
|
||||||
|
@SerialName("task_id") val taskId: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionCreateParams(
|
||||||
|
@SerialName("source") val source: String? = null,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
@SerialName("title") val title: String? = null,
|
||||||
|
@SerialName("hidden") val hidden: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionResumeParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||||
|
@SerialName("profile") val profile: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PromptSubmitParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("text") val text: String,
|
||||||
|
@SerialName("queued") val queued: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// tolerant readers: all nullable with null defaults, only fields callers read
|
||||||
|
@Serializable
|
||||||
|
data class SessionCreateResult(
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
@SerialName("stored_session_id") val storedSessionId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionResumeResult(
|
||||||
|
@SerialName("session_id") val sessionId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PromptSubmitResult(
|
||||||
|
val status: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws.contract
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SubagentTailParams(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("subagent_id") val subagentId: String,
|
||||||
|
)
|
||||||
+12
@@ -51,6 +51,7 @@ class BackgroundConnectionController(
|
|||||||
status = status,
|
status = status,
|
||||||
isAutoReconnect = AuthManager.isAutoReconnect(),
|
isAutoReconnect = AuthManager.isAutoReconnect(),
|
||||||
hasActiveNetwork = NetworkMonitor.isConnected.value,
|
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() {
|
fun reconcileState() {
|
||||||
val snapshot = snapshotProvider(false)
|
val snapshot = snapshotProvider(false)
|
||||||
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
|
val decision = BackgroundConnectionPolicy.evaluate(snapshot)
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ data class BackgroundConnectionSnapshot(
|
|||||||
val status: ConnectionStatus,
|
val status: ConnectionStatus,
|
||||||
val isAutoReconnect: Boolean = true,
|
val isAutoReconnect: Boolean = true,
|
||||||
val hasActiveNetwork: 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 {
|
companion object {
|
||||||
fun ineligible(
|
fun ineligible(
|
||||||
@@ -77,8 +82,12 @@ object BackgroundConnectionPolicy {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demand check: need either a pending reply or persistent opt-in
|
// Demand check: need a pending reply or a persistent opt-in. The
|
||||||
val hasDemand = snapshot.pendingReply || snapshot.keepConnectedOptIn
|
// 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) {
|
if (!hasDemand) {
|
||||||
return BackgroundConnectionDecision(
|
return BackgroundConnectionDecision(
|
||||||
shouldHoldService = false,
|
shouldHoldService = false,
|
||||||
@@ -134,7 +143,7 @@ object BackgroundConnectionPolicy {
|
|||||||
|
|
||||||
return BackgroundConnectionDecision(
|
return BackgroundConnectionDecision(
|
||||||
shouldHoldService = true,
|
shouldHoldService = true,
|
||||||
shouldHoldPersistentLease = snapshot.keepConnectedOptIn,
|
shouldHoldPersistentLease = keepConnectedLease,
|
||||||
notificationState = notificationState,
|
notificationState = notificationState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,21 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.RemoteInput
|
import androidx.core.app.RemoteInput
|
||||||
import com.m57.hermescontrol.MainActivity
|
import com.m57.hermescontrol.MainActivity
|
||||||
import com.m57.hermescontrol.R
|
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.remote.NetworkMonitor
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
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 com.m57.hermescontrol.ui.chat.replyFailureFromPayload
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -26,6 +32,9 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.collect
|
import kotlinx.coroutines.flow.collect
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
internal data class MessageCompleteNotificationPlan(
|
internal data class MessageCompleteNotificationPlan(
|
||||||
@@ -37,6 +46,87 @@ internal data class MessageCompleteNotificationPlan(
|
|||||||
val allowInlineReply: Boolean,
|
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(
|
internal fun messageCompleteNotificationPlan(
|
||||||
event: WsEvent.MessageComplete,
|
event: WsEvent.MessageComplete,
|
||||||
targetSessionId: String?,
|
targetSessionId: String?,
|
||||||
@@ -106,6 +196,9 @@ class ChatNotificationService : Service() {
|
|||||||
internal const val NOTIFICATION_ID = 1
|
internal const val NOTIFICATION_ID = 1
|
||||||
internal const val PENDING_NOTIFICATION_ID = 2
|
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)
|
private val isAppInForeground = AtomicBoolean(false)
|
||||||
internal val lifecycle = ForegroundServiceLifecycle()
|
internal val lifecycle = ForegroundServiceLifecycle()
|
||||||
|
|
||||||
@@ -152,43 +245,99 @@ class ChatNotificationService : Service() {
|
|||||||
if (!isAppInForeground.get()) {
|
if (!isAppInForeground.get()) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is WsEvent.MessageComplete -> {
|
is WsEvent.MessageComplete -> {
|
||||||
val targetSessionId =
|
when (
|
||||||
event.storedSessionId
|
val route =
|
||||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
messageCompleteRoute(
|
||||||
val plan =
|
holderSessionId = ActiveSessionHolder.activeSessionId.value,
|
||||||
messageCompleteNotificationPlan(
|
toggleOn = AuthManager.isNotifySessionCompletions(),
|
||||||
event = event,
|
eventSessionId = event.sessionId,
|
||||||
targetSessionId = targetSessionId,
|
)
|
||||||
newMessageText = getString(R.string.notif_new_message),
|
) {
|
||||||
failureText = getString(R.string.chat_reply_failed_title),
|
MessageCompleteRoute.Reply -> {
|
||||||
)
|
val targetSessionId =
|
||||||
showReplyNotification(
|
event.storedSessionId
|
||||||
text = plan.text,
|
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
||||||
sessionId = plan.sessionId,
|
val plan =
|
||||||
isReplyMessage = plan.isReplyMessage,
|
messageCompleteNotificationPlan(
|
||||||
completionId = plan.completionId,
|
event = event,
|
||||||
allowInlineReply = plan.allowInlineReply,
|
targetSessionId = targetSessionId,
|
||||||
// The durable REST row for this turn, when the
|
newMessageText = getString(R.string.notif_new_message),
|
||||||
// boundary armed before the prompt was submitted
|
failureText = getString(R.string.chat_reply_failed_title),
|
||||||
// still lets us name it unambiguously. Null is a
|
)
|
||||||
// normal, safe outcome: the notification is then
|
showReplyNotification(
|
||||||
// never auto-dismissed from REST hydration, which
|
text = plan.text,
|
||||||
// is strictly better than dismissing the wrong
|
sessionId = plan.sessionId,
|
||||||
// duplicate reply.
|
isReplyMessage = plan.isReplyMessage,
|
||||||
serverMessageId =
|
completionId = plan.completionId,
|
||||||
plan.correlationText?.let {
|
allowInlineReply = plan.allowInlineReply,
|
||||||
coalesceTurnRow(plan.sessionId, it)
|
// 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
|
||||||
// The wait is over — retire the foreground
|
// normal, safe outcome: the notification is then
|
||||||
// service. The reply notification above
|
// never auto-dismissed from REST hydration.
|
||||||
// replaces the persistent "waiting" one,
|
serverMessageId =
|
||||||
// and the pendingReply flag is cleared by
|
plan.correlationText?.let {
|
||||||
// HermesWsClient's own collector, so the
|
coalesceTurnRow(plan.sessionId, it)
|
||||||
// service is not restarted on the next
|
},
|
||||||
// ON_STOP (issue #794).
|
)
|
||||||
// A delayed completion must not retire a newer turn/start.
|
// The wait is over: retire the foreground service
|
||||||
BackgroundConnectionController.default.onReplyCompleted(generation)
|
// (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 -> {
|
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 {
|
private fun buildContentIntent(sessionId: String?): PendingIntent {
|
||||||
val intent =
|
val intent =
|
||||||
Intent(this, MainActivity::class.java).apply {
|
Intent(this, MainActivity::class.java).apply {
|
||||||
@@ -383,15 +561,48 @@ class ChatNotificationService : Service() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildForegroundNotification(text: String): Notification =
|
private fun buildForegroundNotification(text: String): Notification {
|
||||||
NotificationCompat
|
val openIntent =
|
||||||
.Builder(this, SERVICE_CHANNEL_ID)
|
Intent(this, MainActivity::class.java).apply {
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
setPackage(packageName)
|
||||||
.setContentTitle(getString(R.string.notif_title))
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
.setContentText(text)
|
}
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
val builder =
|
||||||
.setOngoing(true)
|
NotificationCompat
|
||||||
.build()
|
.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() {
|
private fun createNotificationChannels() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
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.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -25,6 +27,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
const val EXTRA_SESSION_ID = "extra_session_id"
|
const val EXTRA_SESSION_ID = "extra_session_id"
|
||||||
private const val REPLY_TIMEOUT_MS = 5_000L
|
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:
|
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
|
||||||
* the probe sits on the reply path, so a slow or unreachable gateway must
|
* 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()
|
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
|
* Test-friendly wrapper for notification creation. Override in tests to
|
||||||
* avoid [NotificationCompat.Builder.build()] calling Android framework
|
* avoid [NotificationCompat.Builder.build()] calling Android framework
|
||||||
@@ -75,7 +83,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
val pendingResult = goAsyncCompat()
|
val pendingResult = goAsyncCompat()
|
||||||
replyScope.launch {
|
replyScope.launch {
|
||||||
try {
|
try {
|
||||||
withTimeout(5000L) {
|
withTimeout(persistenceTimeoutMs) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val db =
|
val db =
|
||||||
com.m57.hermescontrol.data.local.HermesDatabase
|
com.m57.hermescontrol.data.local.HermesDatabase
|
||||||
@@ -103,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
val runtimeSessionId =
|
val runtimeSessionId =
|
||||||
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
||||||
?: resumeSession(sessionId)
|
?: resumeSession(sessionId)
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
method = RpcMethods.PROMPT_SUBMIT,
|
||||||
WsMethods.PROMPT_SUBMIT,
|
params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText),
|
||||||
mapOf("session_id" to runtimeSessionId, "text" to replyText),
|
timeoutMs = REPLY_TIMEOUT_MS,
|
||||||
timeoutMs = REPLY_TIMEOUT_MS,
|
)
|
||||||
).await()
|
|
||||||
|
|
||||||
val entity =
|
val entity =
|
||||||
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
com.m57.hermescontrol.data.local.ChatMessageEntity(
|
||||||
@@ -146,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun resumeSession(storedSessionId: String): String {
|
private suspend fun resumeSession(storedSessionId: String): String {
|
||||||
val profile = AuthManager.activeProfileId.value
|
val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
|
||||||
val params =
|
val params =
|
||||||
mutableMapOf<String, Any>(
|
SessionResumeParams(
|
||||||
"session_id" to storedSessionId,
|
sessionId = storedSessionId,
|
||||||
"omit_messages" to true,
|
omitMessages = true,
|
||||||
|
profile = profile,
|
||||||
)
|
)
|
||||||
if (!profile.isNullOrBlank()) {
|
|
||||||
params["profile"] = profile
|
|
||||||
}
|
|
||||||
val result =
|
val result =
|
||||||
HermesWsClient
|
HermesWsClient.call(
|
||||||
.request(
|
method = RpcMethods.SESSION_RESUME,
|
||||||
WsMethods.SESSION_RESUME,
|
params = params,
|
||||||
params,
|
timeoutMs = REPLY_TIMEOUT_MS,
|
||||||
timeoutMs = REPLY_TIMEOUT_MS,
|
)
|
||||||
).await() as? Map<*, *>
|
|
||||||
val runtimeSessionId =
|
val runtimeSessionId =
|
||||||
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
result.sessionId?.takeIf { it.isNotBlank() }
|
||||||
?: error("Resume returned no runtime session id")
|
?: error("Resume returned no runtime session id")
|
||||||
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
|
||||||
return runtimeSessionId
|
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
|
if (h !in 0f..360f || s !in 0f..1f || l !in 0f..1f) return null
|
||||||
return Color.hsl(h % 360f, s, l)
|
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
|
* Material You changes the *primary* / *surface* palette based on the user's
|
||||||
* wallpaper, but success / warning / error / info colours must stay
|
* wallpaper, but success / warning / error / info colours must stay
|
||||||
* semantically correct (green = good, red = bad) regardless of what the
|
* semantically correct (green = good, red = bad) regardless of what the
|
||||||
* wallpaper-derived palette would choose. This struct is provided via
|
* wallpaper-derived palette would choose.
|
||||||
* [LocalHermesStatusColors] and should be used for all status indicators,
|
*
|
||||||
|
* `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.
|
* badges, and semantic feedback colours.
|
||||||
*/
|
*/
|
||||||
data class HermesStatusColors(
|
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
|
* The Material error slots are derived from [status] — the theme author
|
||||||
* defines semantic colors once and the scheme inherits them.
|
* 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(
|
data class PaletteColors(
|
||||||
val primary: Color,
|
val primary: Color,
|
||||||
@@ -48,6 +51,20 @@ data class PaletteColors(
|
|||||||
val onSurface: Color,
|
val onSurface: Color,
|
||||||
val surfaceVariant: Color,
|
val surfaceVariant: Color,
|
||||||
val onSurfaceVariant: 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 surfaceContainerLowest: Color,
|
||||||
val surfaceContainerLow: Color,
|
val surfaceContainerLow: Color,
|
||||||
val surfaceContainer: Color,
|
val surfaceContainer: Color,
|
||||||
@@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme =
|
|||||||
surfaceVariant = c.surfaceVariant,
|
surfaceVariant = c.surfaceVariant,
|
||||||
onSurfaceVariant = c.onSurfaceVariant,
|
onSurfaceVariant = c.onSurfaceVariant,
|
||||||
surfaceTint = c.primary,
|
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,
|
surfaceContainerLowest = c.surfaceContainerLowest,
|
||||||
surfaceContainerLow = c.surfaceContainerLow,
|
surfaceContainerLow = c.surfaceContainerLow,
|
||||||
surfaceContainer = c.surfaceContainer,
|
surfaceContainer = c.surfaceContainer,
|
||||||
@@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme =
|
|||||||
surfaceVariant = c.surfaceVariant,
|
surfaceVariant = c.surfaceVariant,
|
||||||
onSurfaceVariant = c.onSurfaceVariant,
|
onSurfaceVariant = c.onSurfaceVariant,
|
||||||
surfaceTint = c.primary,
|
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,
|
surfaceContainerLowest = c.surfaceContainerLowest,
|
||||||
surfaceContainerLow = c.surfaceContainerLow,
|
surfaceContainerLow = c.surfaceContainerLow,
|
||||||
surfaceContainer = c.surfaceContainer,
|
surfaceContainer = c.surfaceContainer,
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ All theme code is under:
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/src/main/java/com/m57/hermescontrol/theme/
|
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
|
├── PaletteTemplate.kt # the template — the ONE shape every theme follows
|
||||||
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
|
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
|
||||||
├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*)
|
├── 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
|
├── GruvboxScheme.kt
|
||||||
├── CatppuccinScheme.kt
|
├── CatppuccinScheme.kt
|
||||||
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
|
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
|
||||||
└── NordScheme.kt
|
├── NordScheme.kt
|
||||||
|
└── GarnetScheme.kt
|
||||||
```
|
```
|
||||||
|
|
||||||
## The template — one shape for every theme
|
## The template — one shape for every theme
|
||||||
@@ -55,7 +57,14 @@ val MyTheme =
|
|||||||
The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The
|
The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The
|
||||||
Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`)
|
Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`)
|
||||||
are **derived** from the theme's status set — the theme author defines semantic
|
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
|
## Preset conventions
|
||||||
|
|
||||||
@@ -69,8 +78,41 @@ colors once.
|
|||||||
light text would fail contrast.
|
light text would fail contrast.
|
||||||
- **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/
|
- **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/
|
||||||
error/info by lightness only — consumers must pair with icons or labels.
|
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
|
- Normal-text foreground/background pairs, including accent containers, fixed
|
||||||
`ThemePaletteTest` — a preset that breaks this fails the unit-test gate.
|
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
|
## Theme modes
|
||||||
|
|
||||||
@@ -87,37 +129,33 @@ doesn't ship — never to a sibling preset.
|
|||||||
|
|
||||||
## The dispatcher
|
## The dispatcher
|
||||||
|
|
||||||
`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and
|
`ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution,
|
||||||
a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme /
|
UI selection, and palette tests use this registry. An exact enum/registry coverage
|
||||||
status are read off it (with the mode fallback above):
|
test rejects missing, duplicate, or reordered entries. Android string resources
|
||||||
|
stay in the UI’s exhaustive label mapping, outside the palette model.
|
||||||
|
|
||||||
```kotlin
|
`Theme.kt` reads `preset.palette()` and applies the mode fallback above.
|
||||||
private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
|
|
||||||
ThemePreset.DEFAULT -> DefaultTheme
|
|
||||||
ThemePreset.MONOCHROME -> MonochromeTheme
|
|
||||||
// …one line per preset…
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Dynamic (Material You) color on API 31+ can optionally override the preset scheme when
|
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
|
`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
|
## Adding a new theme
|
||||||
|
|
||||||
1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) —
|
1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) —
|
||||||
`buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly`
|
`buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly`
|
||||||
for single-mode themes. Every status color must be bespoke — no aliasing.
|
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`.
|
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).
|
`ui/settings/components/AppearanceSection.kt` (and any string resource).
|
||||||
5. **Verify**:
|
5. **Verify**:
|
||||||
```bash
|
```bash
|
||||||
./gradlew ktlintCheck testDebugUnitTest
|
./gradlew ktlintCheck testDebugUnitTest
|
||||||
```
|
```
|
||||||
`ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error
|
`ThemePaletteTest` checks text contrast, complete Material slot mapping,
|
||||||
slot pairs and the ThemeMode invariants — it must stay green.
|
mode-independent fixed roles, registry coverage, and mode/fallback invariants.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -10,38 +10,19 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.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
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class ThemePreference { SYSTEM, LIGHT, DARK }
|
enum class ThemePreference { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
@Serializable
|
@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 LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||||
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
||||||
val LocalChatFontScale = compositionLocalOf { 1.0f }
|
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.
|
* Resolve the Material 3 [ColorScheme] for a preset + dark flag.
|
||||||
*
|
*
|
||||||
@@ -53,7 +34,7 @@ internal fun resolveColorScheme(
|
|||||||
preset: ThemePreset,
|
preset: ThemePreset,
|
||||||
darkTheme: Boolean,
|
darkTheme: Boolean,
|
||||||
): ColorScheme {
|
): ColorScheme {
|
||||||
val theme = themeFor(preset)
|
val theme = preset.palette()
|
||||||
// DefaultTheme is FULL — its scheme is never null for either mode.
|
// DefaultTheme is FULL — its scheme is never null for either mode.
|
||||||
return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme))
|
return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme))
|
||||||
}
|
}
|
||||||
@@ -66,7 +47,7 @@ internal fun resolveStatusColors(
|
|||||||
preset: ThemePreset,
|
preset: ThemePreset,
|
||||||
darkTheme: Boolean,
|
darkTheme: Boolean,
|
||||||
): HermesStatusColors {
|
): HermesStatusColors {
|
||||||
val theme = themeFor(preset)
|
val theme = preset.palette()
|
||||||
// DefaultTheme is FULL — its status colors are never null for either mode.
|
// DefaultTheme is FULL — its status colors are never null for either mode.
|
||||||
return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme))
|
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,
|
onSurface = AmoledTextBright,
|
||||||
surfaceVariant = AmoledContainerHigh,
|
surfaceVariant = AmoledContainerHigh,
|
||||||
onSurfaceVariant = AmoledTextDim,
|
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,
|
surfaceContainerLowest = AmoledFloor,
|
||||||
surfaceContainerLow = AmoledFloor,
|
surfaceContainerLow = AmoledFloor,
|
||||||
surfaceContainer = AmoledContainer,
|
surfaceContainer = AmoledContainer,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ private val MochaRed = Color(0xFFF38BA8)
|
|||||||
private val MochaText = Color(0xFFCDD6F4)
|
private val MochaText = Color(0xFFCDD6F4)
|
||||||
private val MochaSubtext1 = Color(0xFFBAC2DE)
|
private val MochaSubtext1 = Color(0xFFBAC2DE)
|
||||||
private val MochaSubtext0 = Color(0xFFA6ADC8)
|
private val MochaSubtext0 = Color(0xFFA6ADC8)
|
||||||
private val MochaOverlay0 = Color(0xFF6C7086)
|
|
||||||
private val MochaSurface1 = Color(0xFF45475A)
|
private val MochaSurface1 = Color(0xFF45475A)
|
||||||
private val MochaSurface0 = Color(0xFF313244)
|
private val MochaSurface0 = Color(0xFF313244)
|
||||||
private val MochaBase = Color(0xFF1E1E2E)
|
private val MochaBase = Color(0xFF1E1E2E)
|
||||||
@@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B)
|
|||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
private val LatteMauve = Color(0xFF8839EF)
|
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 LatteBlue = Color(0xFF1E66F5)
|
||||||
private val LattePink = Color(0xFFEA76CB)
|
private val LattePink = Color(0xFFEA76CB)
|
||||||
private val LatteGreen = Color(0xFF40A02B)
|
private val LatteGreen = Color(0xFF40A02B)
|
||||||
private val LatteYellow = Color(0xFFDF8E1D)
|
private val LatteYellow = Color(0xFFDF8E1D)
|
||||||
private val LatteRed = Color(0xFFD20F39)
|
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 LatteSubtext0 = Color(0xFF6C6F85)
|
||||||
private val LatteOverlay0 = Color(0xFF9CA0B0)
|
private val LatteText = Color(0xFF4C4F69)
|
||||||
private val LatteSurface1 = Color(0xFFBCC0CC)
|
private val LatteSurface1 = Color(0xFFBCC0CC)
|
||||||
private val LatteSurface0 = Color(0xFFCCD0DA)
|
private val LatteSurface0 = Color(0xFFCCD0DA)
|
||||||
private val LatteBase = Color(0xFFEFF1F5)
|
private val LatteBase = Color(0xFFEFF1F5)
|
||||||
private val LatteMantle = Color(0xFFE6E9EF)
|
private val LatteMantle = Color(0xFFE6E9EF)
|
||||||
private val LatteCrust = Color(0xFFDCE0E8)
|
private val LatteCrust = Color(0xFFDCE0E8)
|
||||||
|
|
||||||
// Bespoke tint used only for the light primary container — not part of
|
// Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim
|
||||||
// the official Catppuccin swatch set, kept local to this file.
|
// share official accents rather than inventing tonal steps; on-roles use Crust.
|
||||||
private val LattePrimaryContainer = Color(0xFFE6CAFA)
|
// Latte surface containers stop at Surface0 so normal text stays >= 4.5:1.
|
||||||
private val LatteOnPrimaryContainer = Color(0xFF380075)
|
// Upstream: https://github.com/catppuccin/palette/blob/main/palette.json
|
||||||
|
|
||||||
/** Catppuccin theme — Mocha (dark) / Latte (light). */
|
/** Catppuccin theme — Mocha (dark) / Latte (light). */
|
||||||
val CatppuccinTheme =
|
val CatppuccinTheme =
|
||||||
@@ -73,6 +81,20 @@ val CatppuccinTheme =
|
|||||||
onSurface = MochaText,
|
onSurface = MochaText,
|
||||||
surfaceVariant = MochaSurface0,
|
surfaceVariant = MochaSurface0,
|
||||||
onSurfaceVariant = MochaSubtext0,
|
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,
|
surfaceContainerLowest = MochaCrust,
|
||||||
surfaceContainerLow = MochaMantle,
|
surfaceContainerLow = MochaMantle,
|
||||||
surfaceContainer = MochaBase,
|
surfaceContainer = MochaBase,
|
||||||
@@ -81,7 +103,7 @@ val CatppuccinTheme =
|
|||||||
inverseSurface = MochaText,
|
inverseSurface = MochaText,
|
||||||
inverseOnSurface = MochaBase,
|
inverseOnSurface = MochaBase,
|
||||||
inversePrimary = LatteMauve,
|
inversePrimary = LatteMauve,
|
||||||
outline = MochaOverlay0,
|
outline = MochaSubtext0,
|
||||||
outlineVariant = MochaSurface1,
|
outlineVariant = MochaSurface1,
|
||||||
scrim = MochaCrust,
|
scrim = MochaCrust,
|
||||||
status =
|
status =
|
||||||
@@ -105,48 +127,62 @@ val CatppuccinTheme =
|
|||||||
PaletteColors(
|
PaletteColors(
|
||||||
primary = LatteMauve,
|
primary = LatteMauve,
|
||||||
onPrimary = LatteBase,
|
onPrimary = LatteBase,
|
||||||
primaryContainer = LattePrimaryContainer,
|
primaryContainer = LatteBase,
|
||||||
onPrimaryContainer = LatteOnPrimaryContainer,
|
onPrimaryContainer = LatteMauve,
|
||||||
secondary = LatteBlue,
|
secondary = LatteBlue,
|
||||||
onSecondary = LatteBase,
|
onSecondary = LatteOnBlue,
|
||||||
secondaryContainer = LatteMantle,
|
secondaryContainer = LatteMantle,
|
||||||
onSecondaryContainer = LatteText,
|
onSecondaryContainer = LatteText,
|
||||||
tertiary = LattePink,
|
tertiary = LattePink,
|
||||||
onTertiary = LatteBase,
|
onTertiary = LatteDeepInk,
|
||||||
tertiaryContainer = LatteSurface1,
|
tertiaryContainer = LatteSurface0,
|
||||||
onTertiaryContainer = LatteText,
|
onTertiaryContainer = LatteText,
|
||||||
background = LatteBase,
|
background = LatteBase,
|
||||||
onBackground = LatteText,
|
onBackground = LatteText,
|
||||||
surface = LatteBase,
|
surface = LatteBase,
|
||||||
onSurface = LatteText,
|
onSurface = LatteText,
|
||||||
surfaceVariant = LatteSurface0,
|
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,
|
surfaceContainerLowest = LatteCrust,
|
||||||
surfaceContainerLow = LatteMantle,
|
surfaceContainerLow = LatteMantle,
|
||||||
surfaceContainer = LatteBase,
|
surfaceContainer = LatteBase,
|
||||||
surfaceContainerHigh = LatteSurface0,
|
surfaceContainerHigh = LatteSurface0,
|
||||||
surfaceContainerHighest = LatteSurface1,
|
surfaceContainerHighest = LatteSurface0,
|
||||||
inverseSurface = LatteText,
|
inverseSurface = LatteText,
|
||||||
inverseOnSurface = LatteBase,
|
inverseOnSurface = LatteBase,
|
||||||
inversePrimary = MochaMauve,
|
inversePrimary = MochaMauve,
|
||||||
outline = LatteOverlay0,
|
outline = LatteSubtext0,
|
||||||
outlineVariant = LatteSurface1,
|
outlineVariant = LatteSurface1,
|
||||||
scrim = LatteCrust,
|
scrim = LatteCrust,
|
||||||
status =
|
status =
|
||||||
HermesStatusColors(
|
HermesStatusColors(
|
||||||
success = LatteGreen,
|
success = LatteGreen,
|
||||||
successContainer = LatteSurface0,
|
successContainer = LatteSurface0,
|
||||||
onSuccess = LatteBase,
|
onSuccess = LatteDeepInk,
|
||||||
warning = LatteYellow,
|
warning = LatteYellow,
|
||||||
warningContainer = LatteSurface0,
|
warningContainer = LatteSurface0,
|
||||||
onWarning = LatteBase,
|
onWarning = LatteDeepInk,
|
||||||
error = LatteRed,
|
error = LatteRed,
|
||||||
errorContainer = LatteSurface0,
|
errorContainer = LatteSurface0,
|
||||||
onError = LatteBase,
|
onError = LatteBase,
|
||||||
onErrorContainer = LatteRed,
|
onErrorContainer = LatteText,
|
||||||
info = LatteBlue,
|
info = LatteBlue,
|
||||||
infoContainer = LatteSurface0,
|
infoContainer = LatteSurface0,
|
||||||
onInfo = LatteBase,
|
onInfo = LatteOnBlue,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -73,6 +73,20 @@ val DefaultTheme =
|
|||||||
onSurface = SlateDarkFg1,
|
onSurface = SlateDarkFg1,
|
||||||
surfaceVariant = SlateDarkBg1,
|
surfaceVariant = SlateDarkBg1,
|
||||||
onSurfaceVariant = SlateDarkFg3,
|
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,
|
surfaceContainerLowest = SlateDarkBg0,
|
||||||
surfaceContainerLow = SlateDarkBg1,
|
surfaceContainerLow = SlateDarkBg1,
|
||||||
surfaceContainer = SlateDarkBg2,
|
surfaceContainer = SlateDarkBg2,
|
||||||
@@ -121,6 +135,20 @@ val DefaultTheme =
|
|||||||
onSurface = SlateLightFg1,
|
onSurface = SlateLightFg1,
|
||||||
surfaceVariant = SlateLightBg1,
|
surfaceVariant = SlateLightBg1,
|
||||||
onSurfaceVariant = SlateLightFg3,
|
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,
|
surfaceContainerLowest = SlateLightBg0,
|
||||||
surfaceContainerLow = SlateLightBg1,
|
surfaceContainerLow = SlateLightBg1,
|
||||||
surfaceContainer = SlateLightBg2,
|
surfaceContainer = SlateLightBg2,
|
||||||
@@ -129,7 +157,7 @@ val DefaultTheme =
|
|||||||
inverseSurface = SlateLightFg1,
|
inverseSurface = SlateLightFg1,
|
||||||
inverseOnSurface = SlateLightBg0,
|
inverseOnSurface = SlateLightBg0,
|
||||||
inversePrimary = SlateBrightTeal,
|
inversePrimary = SlateBrightTeal,
|
||||||
outline = SlateGray,
|
outline = SlateLightFg3,
|
||||||
outlineVariant = SlateLightBg3,
|
outlineVariant = SlateLightBg3,
|
||||||
scrim = SlateLightBg0,
|
scrim = SlateLightBg0,
|
||||||
status =
|
status =
|
||||||
|
|||||||
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user