Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a03c9ed7 | ||
|
|
36546c2712 | ||
|
|
fbe3fc3e05 | ||
|
|
a77cebec43 | ||
|
|
8167f93705 | ||
|
|
52dc46072e | ||
|
|
c18ab4cce8 | ||
|
|
52b28e5b48 | ||
|
|
575fd82c59 | ||
|
|
ead3f5fd4f | ||
|
|
36a922be64 | ||
|
|
530a1c9591 | ||
|
|
353faa9da5 | ||
|
|
76fdbcfd70 | ||
|
|
5365e22fff | ||
|
|
1e3974fd88 | ||
|
|
72854d58b1 | ||
|
|
b63e0e726d | ||
|
|
fff3ba8d91 | ||
|
|
7c155e3693 | ||
|
|
d15d3b594c | ||
|
|
915b2ebe54 | ||
|
|
cf3634ee2b | ||
|
|
88b11856d3 | ||
|
|
aede6c8cb3 | ||
|
|
c1187f0f2f | ||
|
|
6ff473820c | ||
|
|
847a21cc0c | ||
|
|
8ebd97643d | ||
|
|
8aded16da9 | ||
|
|
a35c0b34f8 | ||
|
|
877cfad88a | ||
|
|
fb0b8deef7 | ||
|
|
1a710f071c | ||
|
|
89b1461431 | ||
|
|
ce72f7790e | ||
|
|
51f4d29ebb | ||
|
|
97a2dac96d | ||
|
|
a569361d43 | ||
|
|
35dfc8c051 | ||
|
|
4a1ece7ad0 | ||
|
|
41b6fb4f84 | ||
|
|
a15b247d1a | ||
|
|
2d486dd395 | ||
|
|
0fcb833c83 | ||
|
|
4507b2270a | ||
|
|
1cd3da5ac6 | ||
|
|
afe2be9304 | ||
|
|
60c14b5db1 | ||
|
|
e3d6dd9509 | ||
|
|
91d05c982e | ||
|
|
85bbbd004d | ||
|
|
920e7d58f0 | ||
|
|
c9ddc2ef8d | ||
|
|
68f8b58a0b | ||
|
|
2bfe21eaff |
@@ -38,10 +38,11 @@ on:
|
||||
- ".github/workflows/approve-release-android.yml"
|
||||
- ".github/workflows/release-android.yml"
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR, but let main and dev finish
|
||||
# Cancel superseded PR and dev runs. Never cancel main: every release-branch
|
||||
# commit must finish its independent validation.
|
||||
concurrency:
|
||||
group: ci-android-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
+10
-9
@@ -6,15 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
|
||||
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
|
||||
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Server 1.4.3] - 2026-07-22
|
||||
@@ -29,14 +22,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
|
||||
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
|
||||
|
||||
## [Android 1.5.0] - 2026-07-22
|
||||
## [Android 1.5.0] - 2026-07-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
|
||||
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
|
||||
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **Chat shows richer upstream state and media.** One-turn model selection, approval policies, advisor progress, queued-recovery and project labels, collapsible attachments, persisted images, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
|
||||
- **The Agent Passport makes the active agent controllable.** The chat drawer now combines live connection and session context with profile switching, personality, model, reasoning, approval, and speed controls in one focused surface.
|
||||
- **Android onboarding finishes with a permission setup step.** After connecting, users can enable background chat alerts with one deliberate Android prompt, review optional feature permissions individually, or continue immediately without granting phone access.
|
||||
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; native Gateway lifecycle events remain authoritative and Relay remains optional.
|
||||
- **Background work stays actionable.** User-started turns remain protected until every active session settles, while privacy-safe notifications reopen the correct conversation for approvals, questions, elevated permissions, and secure responses.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -45,6 +42,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
|
||||
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
|
||||
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
|
||||
- **Dashboard sign-in is secure and route-aware.** Browser-based authorization is scoped and serialized to the selected host, while cold start no longer activates a temporary localhost API fallback or reports a missing key before stored connection state is ready.
|
||||
- **Background and promoted voice work retain their owning chat rows.** Completing an initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running task, and concurrent turns remain reachable without requiring an always-on idle connection.
|
||||
- **Self-hosted rendering is safer.** Android accepts deliberately installed user certificate authorities without bypassing chain, hostname, or Relay-pin verification, and malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- **Developer Options reflect current product behavior.** The obsolete Relay feature toggle is removed, version-tap unlock and explicit relock persist correctly, and backup, import, reset, and completion messages now report their actual results.
|
||||
|
||||
## [1.4.9] - 2026-07-19
|
||||
|
||||
|
||||
@@ -168,10 +168,19 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android pre-push gate:** `scripts\dev.bat prepush` on Windows or
|
||||
`./scripts/dev.sh prepush` on macOS/Linux. This runs the Android repository
|
||||
checks, Google Play debug lint, and the same focused unit-test shard used by
|
||||
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||
to catch common hosted failures without waiting for another full Actions
|
||||
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
|
||||
runs are never canceled because each release-branch commit must complete its
|
||||
independent validation.
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-25 — Android 1.5.0 final release reconciliation
|
||||
|
||||
The final Android 1.5.0 release tree reconciles the accumulated Dashboard-first
|
||||
connection, Gateway recovery, background delivery, Agent Passport, onboarding,
|
||||
voice, attachment, image-generation, security, localization, and Developer
|
||||
Options work into one public release narrative. Android remains version 1.5.0
|
||||
with monotonic versionCode 33 because that prepared version was not previously
|
||||
tagged or uploaded to production.
|
||||
|
||||
Release notes, the in-app What's New assets, localized Play release notes, and
|
||||
the Play listing reference now describe the final tree rather than the earlier
|
||||
voice-focused candidate. The release train is gated by the exact-tree private
|
||||
Play preflight before the `dev` to `main` release merge and public tag.
|
||||
|
||||
## 2026-07-25 — Active-turn retention and actionable interaction alerts
|
||||
|
||||
Android now promotes user-started chat work to foreground execution until every
|
||||
connection/profile/session-scoped turn settles. Independent leases preserve
|
||||
concurrent detached Gateway sessions, track sessions paused for input, and
|
||||
prevent one completion from stopping protection for siblings. The existing
|
||||
Persistent connection switch now extends retention only to idle periods.
|
||||
|
||||
The foreground notification reports active and waiting session counts.
|
||||
Interaction alerts add privacy-safe expanded profile/session context and an
|
||||
explicit review, answer, or secure-response action that deep-links to the exact
|
||||
conversation; commands, questions, passwords, secrets, and environment-variable
|
||||
names remain confined to the authenticated chat surface.
|
||||
|
||||
Audited `ChatViewModel`, `ConnectionViewModel`, `GatewayChatClient`,
|
||||
`GatewayKeepAliveService`, `InteractionRequestNotifier`, the shared Android
|
||||
manifest, Android settings copy, chat user documentation, and Play foreground
|
||||
service declaration guidance.
|
||||
|
||||
## 2026-07-24 — Post-connect permission setup
|
||||
|
||||
Android onboarding now finishes with a layered permission step after a
|
||||
successful Hermes connection. Standard Chat and Manage are explicitly ready
|
||||
without a phone grant; Android notifications are recommended through one
|
||||
user-triggered runtime prompt; camera, microphone, notification companion, and
|
||||
flavor-supported device tools remain individually optional on the centralized
|
||||
Permissions screen. Existing just-in-time permission prompts remain available
|
||||
when users skip setup.
|
||||
|
||||
Coverage separates the Android-version notification policy from the Compose
|
||||
presentation and checks the recommended, granted, optional-review, and skip
|
||||
states. English and all shipped Android locale catalogs were updated together.
|
||||
|
||||
## 2026-07-24 — Realtime background-task delivery continuity
|
||||
|
||||
Chat stream completion now preserves an otherwise empty assistant row when it
|
||||
@@ -9,6 +56,21 @@ forced-summary events update and settle the initiating row even when a newer
|
||||
persistent Voice command has started. Existing empty-response cleanup remains
|
||||
unchanged for assistant rows without background work.
|
||||
|
||||
## 2026-07-23 — Background interaction notifications
|
||||
|
||||
Android Gateway chat now treats approval, clarification, elevated-permission,
|
||||
and secret requests as actionable background events. Privacy-safe notifications
|
||||
use stable per-session identities, reopen the exact conversation, replace
|
||||
replayed requests, and clear on answer, expiry, or resumed turn activity.
|
||||
Detached active turns retain and replay their pending interaction when the
|
||||
conversation is reopened.
|
||||
|
||||
The audit classified `terminal.read.request` as renderer plumbing rather than a
|
||||
user decision. Android now answers it with the upstream no-terminal empty
|
||||
response instead of showing an interaction or waiting for the server timeout.
|
||||
The shared main manifest continues to provide notification and persistent
|
||||
connection support to both Google Play and sideload builds.
|
||||
|
||||
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
|
||||
|
||||
The shared Android network security configuration now accepts CA certificates
|
||||
@@ -52,6 +114,19 @@ instead of a generic tool card. The completed tool result still replaces the
|
||||
placeholder through the existing tool completion path. Coverage includes pure
|
||||
JVM selection/denoise tests and a Compose accessibility snapshot test.
|
||||
|
||||
## 2026-07-20 — Faster Android validation feedback
|
||||
|
||||
Android contributors now have one cross-platform pre-push command for locale,
|
||||
documentation, collection-API, and version checks plus primary Play-variant
|
||||
lint and the focused CI unit-test shard. It uses daemon and configuration-cache
|
||||
reuse, supplies a conservative Gradle heap, and discovers the standard Windows
|
||||
Android SDK without writing worktree-local configuration. Hosted CI retains
|
||||
the exhaustive all-variant lint gate.
|
||||
|
||||
Android CI now cancels a superseded run on `dev` or a pull-request ref while
|
||||
preserving every `main` run. A newer integration commit therefore stops paying
|
||||
for an older release smoke that can no longer become the tested release tip.
|
||||
|
||||
## 2026-07-19 — Android 1.4.9 release preparation
|
||||
|
||||
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
# Hermes-Relay-Android v1.5.0
|
||||
|
||||
**Release Date:** July 22, 2026
|
||||
**Release Date:** July 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
@@ -12,22 +12,26 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
|
||||
This release makes the Hermes Dashboard and Gateway the clear standard connection, keeps active work reachable across the app and Android backgrounding, and gives profiles, voice, attachments, image generation, and approvals a more coherent native interface.
|
||||
|
||||
## Added
|
||||
|
||||
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
|
||||
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
|
||||
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
|
||||
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
|
||||
- The Agent Passport drawer combines live route and session context with explicit profile switching, personality, model, reasoning, approval-policy, chat-override, and processing-tier controls.
|
||||
- Secure browser-based Dashboard sign-in is scoped to the selected host. Chat, sessions, Manage, and Standard Voice share the same authenticated Gateway route while the API server remains an automatic fallback.
|
||||
- Standard and Realtime voice settings use focused provider, model, and voice cards with upstream-aware discovery, descriptions, inline previews, waveforms, and a browsable catalog. Standard replies can begin speaking completed segments before generation finishes.
|
||||
- User-started turns stay protected until every concurrent session settles. Privacy-safe notifications reopen the correct chat for approvals, questions, elevated permissions, or secure responses.
|
||||
- Onboarding finishes with a layered permission review: notifications are recommended deliberately, optional capabilities remain separate, and users can continue without granting phone access.
|
||||
- Chat surfaces one-turn model choices, approval modes, advisor progress, queued recovery, project labels, collapsible attachments, persisted images, interim Gateway events, and image-generation activity.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Voice settings and active-turn correction copy remain complete across supported languages.
|
||||
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
|
||||
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
|
||||
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
|
||||
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
|
||||
- Gateway reconnects reactivate the original live session without resubmitting acknowledged prompts or duplicating session rows.
|
||||
- Tailscale, QR, and other remote routes move Dashboard, Gateway, sessions, Manage, Standard Voice, API fallback, and optional Relay together.
|
||||
- Dashboard authentication, model routing, recovery, and profile state stay scoped to the selected connection and session, including during cold start and rapid switching.
|
||||
- Promoted voice and background tasks keep their owning Chat row until the work settles.
|
||||
- User-installed certificate authorities work for self-hosted HTTPS/WSS while normal chain, hostname, and Relay-pin verification remain enforced.
|
||||
- Malformed syntax-highlighting ranges no longer crash Markdown rendering.
|
||||
- Developer Options no longer exposes the obsolete Relay feature flag; version-tap unlock, relock, backup, import, and reset actions now persist and report accurately.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
|
||||
@@ -6,6 +6,24 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Verify Android native dashboard sign-in on device
|
||||
|
||||
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
|
||||
`native_pkce`. The lifecycle-owned callback binds only `127.0.0.1` on an
|
||||
OS-assigned port, keeps verifier/state inside the sign-in coroutine, rejects
|
||||
untrusted callback noise, and closes on completion, cancellation, navigation,
|
||||
or timeout. Encrypted bearer/refresh tokens authenticate Gateway chat, Manage,
|
||||
prewarm, and standard voice; sign-out clears both cookie and native sessions.
|
||||
Older gateways retain the identified WebView cookie fallback.
|
||||
|
||||
Before release, device-test the real Custom Tab → provider → loopback return,
|
||||
configuration/background transitions, Manage reload, Gateway chat ticket,
|
||||
standard voice, sign-out, and process relaunch. Native bearer exchange remains
|
||||
disabled for non-loopback HTTP dashboard addresses; configure HTTPS before
|
||||
using the native flow.
|
||||
|
||||
---
|
||||
|
||||
## Active — Remove temporary GitHub Pages docs redirects
|
||||
|
||||
PR #210 moved current source and production documentation to
|
||||
@@ -78,6 +96,19 @@ intentionally remain outside that code batch:
|
||||
public model-options payload identifies excluded and disabled providers.
|
||||
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
|
||||
empty models are not authoritative evidence that a provider should be hidden.
|
||||
- Keep persistent approval-mode writes for multiplexed non-launch profiles
|
||||
read-only until upstream `config.get` / `config.set` bind an explicit
|
||||
`profile` to that profile's `HERMES_HOME`. Gateway contract v3 currently
|
||||
accepts `approvals.mode` but resolves it against the gateway process home;
|
||||
Android may reconcile a selected profile's `session.info.approval_mode`, but
|
||||
must not claim a profile-scoped write that upstream ignores.
|
||||
- Keep gateway `model.options` profile scoping blocked until the supported
|
||||
upstream RPC accepts an explicit `profile` and documents that the returned
|
||||
provider inventory was built inside that profile's runtime scope. Android
|
||||
now keys picker results to its active profile context and rejects late
|
||||
responses after a profile switch, but it deliberately does not send an
|
||||
invented `profile` parameter. API-server fallback can use the separate,
|
||||
authenticated `/p/<profile>/api/model/options` surface when multiplexed.
|
||||
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
|
||||
once the harness can boot a credential-free upstream gateway deterministically.
|
||||
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
|
||||
@@ -1170,6 +1201,7 @@ Follow-ups:
|
||||
|
||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||
|
||||
- **Collapsible message groups (shipped 2026-07-25).** Android wraps rendered galleries and generic/LOADING/FAILED cards in a localized, accessible attachment disclosure. It defaults open, remembers the user's fold state by stable message identity, and leaves a compact count/name/type summary available to restore all attachment actions.
|
||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||
|
||||
@@ -245,6 +245,7 @@ dependencies {
|
||||
|
||||
// Activity
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.browser)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Core
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationPlaceholder
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationResultTransition
|
||||
import com.hermesandroid.relay.ui.components.ImageGenerationVisualStyle
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
|
||||
/**
|
||||
* Debug-build-only live host for fast image-generation motion tuning.
|
||||
*
|
||||
* Launch directly:
|
||||
* adb shell am start -n <applicationId>/
|
||||
* com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity
|
||||
*/
|
||||
class ImageGenerationDesignQaActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val themePreference = intent.getStringExtra("theme") ?: "auto"
|
||||
setContent {
|
||||
HermesRelayTheme(themePreference = themePreference) {
|
||||
ImageGenerationDesignQaScene(onBack = ::finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ImageGenerationDesignQaScene(onBack: () -> Unit) {
|
||||
var restartKey by remember { mutableIntStateOf(0) }
|
||||
var durationMillis by remember { mutableIntStateOf(4_800) }
|
||||
var visualStyle by remember { androidx.compose.runtime.mutableStateOf(ImageGenerationVisualStyle.LatentGrid) }
|
||||
var showResult by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Image generation lab") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Live debug preview · no generation request",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
ImageGenerationVisualStyle.LatentGrid to "Grid",
|
||||
ImageGenerationVisualStyle.ParticleOrb to "Orb",
|
||||
ImageGenerationVisualStyle.Constellation to "Nodes",
|
||||
).forEach { (style, label) ->
|
||||
FilterChip(
|
||||
selected = visualStyle == style,
|
||||
onClick = { visualStyle = style },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
key(restartKey, durationMillis, visualStyle) {
|
||||
val startedAtMillis = remember { System.currentTimeMillis() }
|
||||
ImageGenerationResultTransition(
|
||||
generating = !showResult,
|
||||
startedAtMillis = startedAtMillis,
|
||||
animationDurationMillis = durationMillis,
|
||||
visualStyle = visualStyle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_generation_transition_preview),
|
||||
contentDescription = "Generated landscape preview",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Generated image",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "12.4s",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "Cycle speed",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(
|
||||
7_200 to "Slow",
|
||||
4_800 to "Normal",
|
||||
3_200 to "Fast",
|
||||
).forEach { (duration, label) ->
|
||||
FilterChip(
|
||||
selected = durationMillis == duration,
|
||||
onClick = { durationMillis = duration },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = true
|
||||
},
|
||||
enabled = !showResult,
|
||||
) {
|
||||
Text("Reveal result")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
showResult = false
|
||||
restartKey++
|
||||
},
|
||||
) {
|
||||
Text("Restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1 +1 @@
|
||||
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
Connect through secure Dashboard sign-in, switch profiles from the new Agent Passport, and keep multiple background chats active with actionable approval and question alerts. Image generation, attachments, model routing, voice, and Gateway recovery are clearer and more reliable. Setup now guides optional notification, camera, microphone, and companion permissions without blocking chat.
|
||||
|
||||
@@ -1 +1 @@
|
||||
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
|
||||
通过安全的 Dashboard 登录连接,并在新的智能体护照中切换配置文件。多个后台对话可保持运行,审批或提问通知可直接返回正确会话。图像生成、附件、模型路由、语音和 Gateway 恢复更加清晰可靠。设置流程会说明可选的通知、相机、麦克风和通知伴侣权限,且不会阻止聊天。
|
||||
|
||||
@@ -90,12 +90,10 @@
|
||||
android:name=".notifications.ProactiveReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Opt-in "Persistent connection" — holds the user's connection to
|
||||
Hermes open while backgrounded so messages and live features stay
|
||||
responsive (relay-paired setups also keep device control +
|
||||
notification mirroring reachable). In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
<!-- Protects user-started active turns automatically; the optional
|
||||
"Persistent connection" setting extends the same foreground
|
||||
protection to idle/background connectivity (and relay-paired
|
||||
device features). In main so BOTH flavors ship it. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
<service
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
@@ -103,7 +101,7 @@
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
|
||||
android:value="Keeps user-started Hermes turns connected until they finish or need input, and optionally keeps idle connections responsive when the user enables Persistent connection." />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -2,28 +2,35 @@
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"title": "Voice that keeps pace",
|
||||
"date": "2026-07-22",
|
||||
"title": "Hermes, always in reach",
|
||||
"date": "2026-07-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "A clearer voice studio",
|
||||
"header": "One secure Hermes connection",
|
||||
"bullets": [
|
||||
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
|
||||
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
|
||||
"Connect through secure Dashboard sign-in while Chat, sessions, Manage, and Standard Voice follow the same active route.",
|
||||
"Switch profiles and control personality, model, reasoning, approvals, and processing speed from the new Agent Passport."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Natural streaming speech",
|
||||
"header": "Active work stays reachable",
|
||||
"bullets": [
|
||||
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
|
||||
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
|
||||
"Multiple user-started chats remain active in the background until every session settles.",
|
||||
"Approval, question, elevated-permission, and secure-response alerts reopen the correct conversation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "More upstream-aware controls",
|
||||
"header": "Richer chat and voice",
|
||||
"bullets": [
|
||||
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
|
||||
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
|
||||
"Attachments, image generation, model routing, recovery, advisor progress, and upstream events are clearer and more reliable.",
|
||||
"Browse and preview Standard and Realtime voices, and hear Standard replies begin speaking as completed segments arrive."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup without surprises",
|
||||
"bullets": [
|
||||
"Onboarding explains optional notification, camera, microphone, companion, and device permissions without blocking standard chat.",
|
||||
"Tailscale, QR, and remote routes now move all Hermes surfaces together and recover the original session after connection loss."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
v1.5.0 - Voice that keeps pace
|
||||
v1.5.0 - Hermes, always in reach
|
||||
|
||||
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
|
||||
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
|
||||
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
|
||||
* Connect through secure Dashboard sign-in and switch profiles from the new Agent Passport.
|
||||
* Keep multiple background chats active and reopen the right session from approval or question alerts.
|
||||
* Follow richer attachments, image generation, model routing, Gateway recovery, and streaming voice.
|
||||
* Finish setup with clear, optional permission guidance that never blocks standard chat.
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.notifications.InteractionRequestNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@@ -142,6 +143,10 @@ class MainActivity : AppCompatActivity() {
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// Action-required notifications are durable across process death.
|
||||
// Once the authenticated chat surface is visible it owns presentation;
|
||||
// unresolved asks are re-posted if the app returns to the background.
|
||||
InteractionRequestNotifier.cancelAll(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
|
||||
@@ -142,6 +142,21 @@ data class ChatMessage(
|
||||
* through `copy`, while [id] remains the authoritative lookup/wire id.
|
||||
*/
|
||||
val uiKey: String = id,
|
||||
/**
|
||||
* Mixture-of-Agents advisor responses surfaced during the live turn.
|
||||
* Unavailable advisors retain only neutral state, never their raw failure
|
||||
* body. A sanitized bounded copy may enter the local in-flight checkpoint,
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
)
|
||||
|
||||
data class MoaReference(
|
||||
val index: Int,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
@@ -392,6 +407,8 @@ data class ChatSession(
|
||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||
*/
|
||||
val source: String? = null,
|
||||
/** Server reports a persisted session runtime/model binding. */
|
||||
val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
@@ -66,6 +66,17 @@ data class ChatTurnAssistantCheckpoint(
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||
/** Sanitized, bounded live-only MoA presentation state; never server transcript data. */
|
||||
val moaReferences: List<ChatTurnMoaReferenceCheckpoint> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnMoaReferenceCheckpoint(
|
||||
val index: Int,
|
||||
val count: Int? = null,
|
||||
val label: String,
|
||||
val text: String = "",
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -255,9 +255,11 @@ class DataManager(
|
||||
suspend fun writeBackupToUri(uri: Uri, backup: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(backup.toByteArray(Charsets.UTF_8))
|
||||
outputStream.flush()
|
||||
val outputStream = context.contentResolver.openOutputStream(uri)
|
||||
?: return@withContext false
|
||||
outputStream.use {
|
||||
it.write(backup.toByteArray(Charsets.UTF_8))
|
||||
it.flush()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
@@ -288,9 +290,10 @@ class DataManager(
|
||||
* - Clear DataStore preferences
|
||||
* - Clear EncryptedSharedPreferences (auth tokens)
|
||||
* - Clear any cached data
|
||||
* Does NOT clear the onboarding flag (that's separate via [resetOnboarding]).
|
||||
* Preserves the onboarding flag. Use [resetOnboarding] when the next launch
|
||||
* should show onboarding again.
|
||||
*/
|
||||
suspend fun resetAppData() {
|
||||
suspend fun resetAppData(): Boolean =
|
||||
try {
|
||||
// Preserve onboarding state before clearing
|
||||
val onboarding = isOnboardingCompleted()
|
||||
@@ -320,10 +323,11 @@ class DataManager(
|
||||
}
|
||||
|
||||
Log.d(TAG, "App data reset complete")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset app data", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun deleteSensitivePreferenceFiles() {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -384,16 +388,17 @@ class DataManager(
|
||||
* Reset only the onboarding completion flag.
|
||||
* Next app launch will show onboarding again.
|
||||
*/
|
||||
suspend fun resetOnboarding() {
|
||||
suspend fun resetOnboarding(): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_ONBOARDING_COMPLETED)
|
||||
}
|
||||
Log.d(TAG, "Onboarding flag reset")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset onboarding flag", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if onboarding has been completed.
|
||||
@@ -412,13 +417,14 @@ class DataManager(
|
||||
/**
|
||||
* Mark onboarding as completed.
|
||||
*/
|
||||
suspend fun setOnboardingCompleted(completed: Boolean) {
|
||||
suspend fun setOnboardingCompleted(completed: Boolean): Boolean =
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
preferences[KEY_ONBOARDING_COMPLETED] = completed
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set onboarding completed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.map
|
||||
/**
|
||||
* Feature flags with compile-time defaults and runtime overrides.
|
||||
*
|
||||
* In debug builds, all features are unlocked by default.
|
||||
* In debug builds, Developer Options are unlocked by default until the user
|
||||
* explicitly locks them.
|
||||
* In release builds, experimental features are hidden unless the user
|
||||
* enables Developer Options (tap version 7 times in Settings > About).
|
||||
*
|
||||
@@ -21,7 +22,6 @@ object FeatureFlags {
|
||||
|
||||
// DataStore keys
|
||||
private val KEY_DEV_OPTIONS_UNLOCKED = booleanPreferencesKey("dev_options_unlocked")
|
||||
private val KEY_RELAY_ENABLED = booleanPreferencesKey("feature_relay_enabled")
|
||||
|
||||
/** Whether the app is running a debug build. */
|
||||
val isDevBuild: Boolean get() = BuildConfig.DEV_MODE
|
||||
@@ -29,13 +29,7 @@ object FeatureFlags {
|
||||
/** Observe whether Developer Options have been unlocked. */
|
||||
fun devOptionsUnlocked(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: false
|
||||
}
|
||||
|
||||
/** Observe whether relay features (settings, pairing, onboarding pages) are enabled. */
|
||||
fun relayEnabled(context: Context): Flow<Boolean> =
|
||||
context.relayDataStore.data.map { prefs ->
|
||||
if (isDevBuild) true else prefs[KEY_RELAY_ENABLED] ?: false
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] ?: isDevBuild
|
||||
}
|
||||
|
||||
/** Unlock Developer Options. */
|
||||
@@ -45,18 +39,10 @@ object FeatureFlags {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock Developer Options and disable all experimental features. */
|
||||
/** Lock Developer Options, including in debug builds. */
|
||||
suspend fun lockDevOptions(context: Context) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_DEV_OPTIONS_UNLOCKED] = false
|
||||
prefs[KEY_RELAY_ENABLED] = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle relay features (terminal/bridge settings, pairing, onboarding relay page). */
|
||||
suspend fun setRelayEnabled(context: Context, enabled: Boolean) {
|
||||
context.relayDataStore.edit { prefs ->
|
||||
prefs[KEY_RELAY_ENABLED] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Process-local ownership of background protection for work the user already
|
||||
* started. Keys are connection/profile/session scoped, so detached sibling
|
||||
* sessions retain independent leases and one completion cannot release
|
||||
* another session's protection.
|
||||
*/
|
||||
object ActiveTurnKeepAliveRegistry {
|
||||
data class Snapshot(
|
||||
val activeTurnCount: Int = 0,
|
||||
val waitingSessionCount: Int = 0,
|
||||
) {
|
||||
val required: Boolean get() = activeTurnCount > 0
|
||||
}
|
||||
|
||||
private val lock = Any()
|
||||
private val leases = linkedMapOf<String, Boolean>()
|
||||
private val _snapshot = MutableStateFlow(Snapshot())
|
||||
val snapshot: StateFlow<Snapshot> = _snapshot.asStateFlow()
|
||||
|
||||
fun acquire(key: String) {
|
||||
synchronized(lock) {
|
||||
leases[key] = leases[key] ?: false
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun setWaiting(key: String, waiting: Boolean) {
|
||||
synchronized(lock) {
|
||||
if (key in leases) {
|
||||
leases[key] = waiting
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(oldKey: String, newKey: String) {
|
||||
if (oldKey == newKey) return
|
||||
synchronized(lock) {
|
||||
val waiting = leases.remove(oldKey) ?: return
|
||||
leases[newKey] = waiting
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun release(key: String) {
|
||||
synchronized(lock) {
|
||||
if (leases.remove(key) != null) publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseAll() {
|
||||
synchronized(lock) {
|
||||
if (leases.isNotEmpty()) {
|
||||
leases.clear()
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resetForTest() {
|
||||
releaseAll()
|
||||
}
|
||||
|
||||
private fun publishLocked() {
|
||||
_snapshot.value = Snapshot(
|
||||
activeTurnCount = leases.size,
|
||||
waitingSessionCount = leases.count { it.value },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.MoaReference
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.shared.LocalDispatchResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
@@ -43,6 +43,9 @@ class ChatHandler {
|
||||
|
||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||
internal const val MAX_MESSAGES = 500
|
||||
private const val MAX_MOA_REFERENCES = 32
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
|
||||
private fun timestampToMillis(timestamp: Double?): Long {
|
||||
val value = timestamp ?: return 0L
|
||||
@@ -155,6 +158,15 @@ class ChatHandler {
|
||||
*/
|
||||
var onMediaBarePathRequested: (messageId: String, originalPath: String) -> Unit = { _, _ -> }
|
||||
|
||||
/**
|
||||
* Fired for a canonical `@image:<path>` directive found on a persisted
|
||||
* USER history row. This is intentionally separate from free-form
|
||||
* assistant `MEDIA:` parsing: only the bounded upstream directive parser
|
||||
* can reach this callback.
|
||||
*/
|
||||
var onPersistedUserImageRequested: (messageId: String, originalPath: String) -> Unit =
|
||||
{ _, _ -> }
|
||||
|
||||
/**
|
||||
* Buffer for incomplete lines during streaming. Tool annotations are line-oriented
|
||||
* (backtick + emoji + tool_name + backtick), so we accumulate text until we see a
|
||||
@@ -512,6 +524,42 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a provisional post-interim segment back into its sealed
|
||||
* assistant bubble when the terminal text proves they are one response.
|
||||
* Tool/card state accumulated after the interim remains attached.
|
||||
*/
|
||||
fun reconcileInterimMessage(
|
||||
interimMessageId: String,
|
||||
currentMessageId: String,
|
||||
content: String,
|
||||
) {
|
||||
_messages.update { messages ->
|
||||
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||
val merged = interim.copy(
|
||||
content = content,
|
||||
isStreaming = true,
|
||||
toolCalls = mergedTools,
|
||||
thinkingContent = current?.thinkingContent
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: interim.thinkingContent,
|
||||
isThinkingStreaming = current?.isThinkingStreaming
|
||||
?: interim.isThinkingStreaming,
|
||||
badges = (interim.badges + current?.badges.orEmpty()).distinct(),
|
||||
cards = (interim.cards + current?.cards.orEmpty()).distinct(),
|
||||
cardDispatches = (interim.cardDispatches + current?.cardDispatches.orEmpty())
|
||||
.distinctBy { "${it.cardKey}:${it.actionValue}:${it.timestamp}" },
|
||||
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||
)
|
||||
messages
|
||||
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||
.map { if (it.id == interimMessageId) merged else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a provisional client-side message that never became a real turn. */
|
||||
fun removeMessage(messageId: String) {
|
||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||
@@ -973,6 +1021,27 @@ class ChatHandler {
|
||||
startedAt = task.startedAt,
|
||||
)
|
||||
}
|
||||
val checkpointMoaReferences = assistant.moaReferences
|
||||
.filter { it.index in 1..MAX_MOA_REFERENCES }
|
||||
.distinctBy { it.index }
|
||||
.sortedBy { it.index }
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
.map { reference ->
|
||||
MoaReference(
|
||||
index = reference.index,
|
||||
count = reference.count,
|
||||
label = reference.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (reference.available) {
|
||||
reference.text.take(MAX_MOA_REFERENCE_CHARS)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
available = reference.available,
|
||||
)
|
||||
}
|
||||
val restoredMoaReferences = currentAssistant?.moaReferences
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: checkpointMoaReferences
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -996,6 +1065,7 @@ class ChatHandler {
|
||||
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
||||
?: assistant.cardDispatches,
|
||||
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
||||
moaReferences = restoredMoaReferences,
|
||||
)
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
@@ -1158,6 +1228,7 @@ class ChatHandler {
|
||||
// the wholesale `_messages.value = ...` assignment so the ViewModel's
|
||||
// mutateMessage lookups find the newly-loaded messages.
|
||||
val pendingMediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||
val pendingPersistedUserImages = mutableListOf<Pair<String, String>>()
|
||||
|
||||
// Reconcile optimistic (client-UUID) live ids to their server ids BEFORE
|
||||
// building the carry map, so the id-keyed delta-merge updates rows in
|
||||
@@ -1212,7 +1283,8 @@ class ChatHandler {
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
displayKind == "async_delegation_complete" ||
|
||||
displayKind == "auto_continue" -> MessageRole.SYSTEM
|
||||
item.role == "user" -> MessageRole.USER
|
||||
item.role == "assistant" -> MessageRole.ASSISTANT
|
||||
item.role == "system" ->
|
||||
@@ -1250,12 +1322,18 @@ class ChatHandler {
|
||||
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
|
||||
val rawContent = rawServerContent
|
||||
|
||||
val persistedImages = if (role == MessageRole.USER && rawContent.isNotEmpty()) {
|
||||
PersistedImageReferenceParser.parse(rawContent)
|
||||
} else {
|
||||
PersistedImageReferences(rawContent, emptyList())
|
||||
}
|
||||
|
||||
// Run the media marker parser on assistant content; strip matched
|
||||
// lines and queue hits for post-assignment dispatch.
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && rawContent.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, rawContent, pendingMediaHits)
|
||||
val afterMedia = if (role == MessageRole.ASSISTANT && persistedImages.cleanedText.isNotEmpty()) {
|
||||
extractMediaMarkersFromContent(messageId, persistedImages.cleanedText, pendingMediaHits)
|
||||
} else {
|
||||
rawContent
|
||||
persistedImages.cleanedText
|
||||
}
|
||||
|
||||
// Cards are synchronous (no async fetch) so we attach them
|
||||
@@ -1293,7 +1371,14 @@ class ChatHandler {
|
||||
// content-keyed queue. Inbound attachments are intentionally
|
||||
// excluded — they come back via the marker re-dispatch.
|
||||
val carriedAttachments = run {
|
||||
val byId = prior?.attachments.orEmpty().filter { it.relayToken == null }
|
||||
val persistedImagePaths = persistedImages.paths.toHashSet()
|
||||
val byId = prior?.attachments.orEmpty().filter { attachment ->
|
||||
attachment.relayToken == null ||
|
||||
(
|
||||
role == MessageRole.USER &&
|
||||
attachment.relayToken in persistedImagePaths
|
||||
)
|
||||
}
|
||||
when {
|
||||
byId.isNotEmpty() -> byId
|
||||
role == MessageRole.USER ->
|
||||
@@ -1301,6 +1386,15 @@ class ChatHandler {
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
if (
|
||||
role == MessageRole.USER &&
|
||||
carriedAttachments.isEmpty() &&
|
||||
persistedImages.paths.isNotEmpty()
|
||||
) {
|
||||
persistedImages.paths.forEach { path ->
|
||||
pendingPersistedUserImages += messageId to path
|
||||
}
|
||||
}
|
||||
// Server reasoning is authoritative when present; absent, keep the
|
||||
// live-streamed thinking rather than blanking it on reload.
|
||||
val serverThinking =
|
||||
@@ -1345,6 +1439,10 @@ class ChatHandler {
|
||||
} else {
|
||||
prior.badges
|
||||
},
|
||||
// Keep sanitized advisor state while reconciling a still-live
|
||||
// row, but clear it once completion made history authoritative.
|
||||
// The server transcript never becomes the source of these blocks.
|
||||
moaReferences = if (prior.isStreaming) prior.moaReferences else emptyList(),
|
||||
)
|
||||
} else {
|
||||
// INSERT — a server message with no local row yet. Built from
|
||||
@@ -1437,6 +1535,9 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((messageId, path) in pendingPersistedUserImages) {
|
||||
onPersistedUserImageRequested(messageId, path)
|
||||
}
|
||||
}
|
||||
|
||||
/** One adoptable server row during id reconciliation. `taken` enforces consume-once. */
|
||||
@@ -1505,7 +1606,7 @@ class ChatHandler {
|
||||
private fun renderedRoleOf(item: MessageItem): MessageRole? =
|
||||
when (item.displayKind?.trim()?.lowercase()) {
|
||||
"hidden" -> null
|
||||
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
|
||||
"model_switch", "async_delegation_complete", "auto_continue" -> MessageRole.SYSTEM
|
||||
else -> when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
"assistant" -> MessageRole.ASSISTANT
|
||||
@@ -1539,6 +1640,7 @@ class ChatHandler {
|
||||
else -> "$count background tasks completed"
|
||||
}
|
||||
}
|
||||
"auto_continue" -> "Continued after an interrupted turn"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -1565,6 +1667,7 @@ class ChatHandler {
|
||||
val t = line.trim()
|
||||
if (t.isEmpty()) continue
|
||||
if (mediaRelayRegex.containsMatchIn(t) || mediaBarePathRegex.containsMatchIn(t)) continue
|
||||
if (PersistedImageReferenceParser.parse(t).paths.isNotEmpty()) continue
|
||||
if (cardMarkerRegex.containsMatchIn(t)) continue
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(t)
|
||||
@@ -1753,6 +1856,7 @@ class ChatHandler {
|
||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||
source = item.source,
|
||||
hasModelConfig = item.hasModelConfig,
|
||||
)
|
||||
}.sortedByDescending { it.activityTimestamp }
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
@@ -2593,6 +2697,44 @@ class ChatHandler {
|
||||
|
||||
// --- Gateway subagent lanes ---
|
||||
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
|
||||
val message = messages[targetIndex]
|
||||
val nextIndex = event.index ?: ((message.moaReferences.maxOfOrNull { it.index } ?: 0) + 1)
|
||||
if (nextIndex !in 1..MAX_MOA_REFERENCES) return@update messages
|
||||
val reference = MoaReference(
|
||||
index = nextIndex,
|
||||
count = event.count,
|
||||
label = event.label.take(MAX_MOA_LABEL_CHARS),
|
||||
text = if (event.available) event.text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = event.available,
|
||||
)
|
||||
val existingAtIndex = message.moaReferences.firstOrNull { it.index == nextIndex }
|
||||
val exactReplay = existingAtIndex == reference
|
||||
val base = if (nextIndex == 1 && !exactReplay) {
|
||||
emptyList()
|
||||
} else {
|
||||
message.moaReferences
|
||||
}
|
||||
if (exactReplay) {
|
||||
messages
|
||||
} else {
|
||||
val upserted = (base.filterNot { it.index == nextIndex } + reference)
|
||||
.sortedBy(MoaReference::index)
|
||||
.take(MAX_MOA_REFERENCES)
|
||||
messages.toMutableList().also {
|
||||
it[targetIndex] = message.copy(moaReferences = upserted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane labels by task index, captured from `subagent.start` (goal
|
||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||
|
||||
@@ -50,6 +50,7 @@ data class DashboardStatus(
|
||||
val authRequired: Boolean,
|
||||
val authProviders: List<String> = emptyList(),
|
||||
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
|
||||
@SerialName("auth_flows") val authFlows: List<String> = emptyList(),
|
||||
val version: String? = null,
|
||||
val message: String? = null,
|
||||
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
|
||||
@@ -158,6 +159,7 @@ data class DashboardCustomEndpointDraft(
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val model: String,
|
||||
val models: List<String> = emptyList(),
|
||||
val apiKey: String? = null,
|
||||
val contextLength: Int? = null,
|
||||
val discoverModels: Boolean = true,
|
||||
@@ -1136,23 +1138,42 @@ class DashboardApiClient(
|
||||
put("name", draft.name)
|
||||
put("base_url", draft.baseUrl)
|
||||
put("model", draft.model)
|
||||
draft.models
|
||||
.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(MAX_CUSTOM_ENDPOINT_MODELS)
|
||||
.map(::JsonPrimitive)
|
||||
.toList()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("models", JsonArray(it)) }
|
||||
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
|
||||
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
|
||||
put("discover_models", draft.discoverModels)
|
||||
put("make_default", draft.makeDefault)
|
||||
}
|
||||
|
||||
private const val MAX_CUSTOM_ENDPOINT_MODELS = 256
|
||||
|
||||
fun defaultClient(
|
||||
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
|
||||
): OkHttpClient = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
bearerAuth: DashboardBearerAuth? = null,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.cookieJar(DashboardCookieJar(cookieStore))
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// Skills-hub search fans out server-side with a 30s overall
|
||||
// timeout; keep the read window above it so a slow-but-successful
|
||||
// search doesn't die client-side at the edge.
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
bearerAuth?.let {
|
||||
builder.addInterceptor(it)
|
||||
builder.authenticator(it)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun parseStatus(root: JsonObject): DashboardStatus {
|
||||
val authObject = root["auth"] as? JsonObject
|
||||
@@ -1180,6 +1201,9 @@ class DashboardApiClient(
|
||||
?: false,
|
||||
authProviders = providers.map { it.name },
|
||||
authProviderDetails = providers,
|
||||
authFlows = (root["auth_flows"] as? JsonArray).orEmpty().mapNotNull {
|
||||
(it as? JsonPrimitive)?.contentOrNull
|
||||
},
|
||||
version = root.stringField("version"),
|
||||
message = root.stringField("message") ?: root.stringField("detail"),
|
||||
nousSessionValid = root.stringField("nous_session_valid"),
|
||||
@@ -1338,6 +1362,35 @@ class DashboardApiClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer credentials are scoped to the exact saved dashboard base, including
|
||||
* reverse-proxy path prefix. A same-host or arbitrary Add Connection probe is
|
||||
* not sufficient authority to receive the active connection's token.
|
||||
*/
|
||||
fun sameDashboardBase(candidate: String, trusted: String): Boolean {
|
||||
val candidateUrl = candidate.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
val trustedUrl = trusted.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return candidateUrl.scheme == trustedUrl.scheme &&
|
||||
candidateUrl.host == trustedUrl.host &&
|
||||
candidateUrl.port == trustedUrl.port &&
|
||||
candidateUrl.encodedPath.trimEnd('/') == trustedUrl.encodedPath.trimEnd('/') &&
|
||||
candidateUrl.query == null &&
|
||||
trustedUrl.query == null
|
||||
}
|
||||
|
||||
fun trustedDashboardBearerAuthOrNull(
|
||||
candidate: String,
|
||||
trusted: String,
|
||||
tokenStoreProvider: () -> NativeDashboardTokenStore,
|
||||
): DashboardBearerAuth? =
|
||||
if (isNativeDashboardTransportEligible(candidate) &&
|
||||
sameDashboardBase(candidate, trusted)
|
||||
) {
|
||||
DashboardBearerAuth(candidate, tokenStoreProvider())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
interface DashboardCookieStore {
|
||||
fun load(): List<StoredDashboardCookie>
|
||||
fun save(cookies: List<StoredDashboardCookie>)
|
||||
|
||||
+302
-15
@@ -164,6 +164,7 @@ class GatewayChatClient(
|
||||
private const val CONNECT_ATTEMPTS = 2
|
||||
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
|
||||
private const val CANCELLED_TURN_SUBMIT_WAIT_MS = 2_000L
|
||||
private const val MAX_RECOVERY_BUFFERED_EVENTS = 256
|
||||
|
||||
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
|
||||
private const val MAX_TURN_REJOINS = 4
|
||||
@@ -284,6 +285,14 @@ class GatewayChatClient(
|
||||
private val _serverYolo = MutableStateFlow<Boolean?>(null)
|
||||
val serverYolo: StateFlow<Boolean?> = _serverYolo.asStateFlow()
|
||||
|
||||
private val _serverApprovalMode = MutableStateFlow<GatewayApprovalMode?>(null)
|
||||
val serverApprovalMode: StateFlow<GatewayApprovalMode?> = _serverApprovalMode.asStateFlow()
|
||||
|
||||
private val _approvalModeCapability =
|
||||
MutableStateFlow(GatewayApprovalModeCapability.Unknown)
|
||||
val approvalModeCapability: StateFlow<GatewayApprovalModeCapability> =
|
||||
_approvalModeCapability.asStateFlow()
|
||||
|
||||
private val _serverFast = MutableStateFlow<Boolean?>(null)
|
||||
val serverFast: StateFlow<Boolean?> = _serverFast.asStateFlow()
|
||||
|
||||
@@ -362,6 +371,15 @@ class GatewayChatClient(
|
||||
@Volatile
|
||||
private var activeTurn: GatewayTurn? = null
|
||||
|
||||
private data class RecoveryEvent(
|
||||
val sessionId: String,
|
||||
val type: String,
|
||||
val payload: JsonObject?,
|
||||
)
|
||||
|
||||
private val recoveryEventLock = Any()
|
||||
private var recoveryEvents: MutableList<RecoveryEvent>? = null
|
||||
|
||||
/**
|
||||
* Turns deliberately detached when the user switches profile/session.
|
||||
* Upstream continues them server-side; retain the live→durable binding so
|
||||
@@ -372,6 +390,7 @@ class GatewayChatClient(
|
||||
private data class BackgroundTurn(
|
||||
val storedSessionId: String,
|
||||
val profile: String?,
|
||||
@Volatile var pendingAsk: GatewayAsk? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -410,6 +429,11 @@ class GatewayChatClient(
|
||||
private var unmatchedTurnCompleteListener:
|
||||
((GatewayBackgroundTurnCompletion) -> Unit)? = null
|
||||
|
||||
/** Input requested or resolved on a deliberately detached turn. */
|
||||
@Volatile
|
||||
private var backgroundInteractionListener:
|
||||
((GatewayBackgroundInteractionEvent) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Connection-level process listener. Unlike [GatewayTurnCallbacks], this is
|
||||
* consulted even when there is no locally initiated [activeTurn].
|
||||
@@ -591,10 +615,23 @@ class GatewayChatClient(
|
||||
val turn = activeTurn?.takeIf { !it.ended } ?: return false
|
||||
val liveId = liveSessionId ?: return false
|
||||
val storedId = storedSessionId ?: return false
|
||||
backgroundTurns[liveId] = BackgroundTurn(
|
||||
val backgroundTurn = BackgroundTurn(
|
||||
storedSessionId = storedId,
|
||||
profile = liveSessionProfile,
|
||||
pendingAsk = turn.pendingInteraction,
|
||||
)
|
||||
backgroundTurns[liveId] = backgroundTurn
|
||||
backgroundTurn.pendingAsk?.let { ask ->
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Requested(
|
||||
storedSessionId = storedId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = ask,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
turn.detach()
|
||||
return true
|
||||
}
|
||||
@@ -664,6 +701,12 @@ class GatewayChatClient(
|
||||
unmatchedTurnCompleteListener = listener
|
||||
}
|
||||
|
||||
fun setBackgroundInteractionListener(
|
||||
listener: ((GatewayBackgroundInteractionEvent) -> Unit)?,
|
||||
) {
|
||||
backgroundInteractionListener = listener
|
||||
}
|
||||
|
||||
fun setProcessEventListener(listener: ((GatewayProcessEvent) -> Unit)?) {
|
||||
processEventListener = listener
|
||||
}
|
||||
@@ -795,24 +838,33 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
response = rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
preferredLiveId?.let { liveId ->
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
|
||||
synchronized(recoveryEventLock) {
|
||||
recoveryEvents = mutableListOf()
|
||||
}
|
||||
response = try {
|
||||
rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
preferredLiveId?.let { liveId ->
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(liveId, it) }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
val recoveredLiveId = response.stringField("session_id")
|
||||
?: run {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
if (!preferredLiveId.isNullOrBlank()) {
|
||||
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
|
||||
}
|
||||
@@ -829,6 +881,9 @@ class GatewayChatClient(
|
||||
user = value.stringField("user").orEmpty(),
|
||||
assistant = value.stringField("assistant").orEmpty(),
|
||||
streaming = value.booleanField("streaming") == true,
|
||||
status = value.stringField("status"),
|
||||
error = value.stringField("error"),
|
||||
recoverable = value.booleanField("recoverable") == true,
|
||||
)
|
||||
}
|
||||
val queued = (response["queued"] as? JsonObject)?.let { value ->
|
||||
@@ -837,8 +892,20 @@ class GatewayChatClient(
|
||||
?.let(::GatewayQueuedTurn)
|
||||
}
|
||||
val running = response.booleanField("running") == true || inflight?.streaming == true
|
||||
val autoContinue = (response["auto_continue"] as? JsonObject)?.let { value ->
|
||||
val attempt = value.stringField("attempt")?.toIntOrNull()
|
||||
?: (value["attempt"] as? JsonPrimitive)?.intOrNull
|
||||
if (attempt != null && attempt > 0) {
|
||||
GatewayAutoContinue(
|
||||
attempt = attempt,
|
||||
interruptedAt = value.stringField("interrupted_at")?.toDoubleOrNull(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (running) {
|
||||
if (running || autoContinue != null) {
|
||||
if (boundTurn == null || boundTurn.ended) {
|
||||
boundTurn = GatewayTurn(
|
||||
callbacks = dispatchOn(callbacks),
|
||||
@@ -848,14 +915,25 @@ class GatewayChatClient(
|
||||
activeTurn = turn
|
||||
}
|
||||
}
|
||||
val buffered = synchronized(recoveryEventLock) {
|
||||
recoveryEvents
|
||||
?.filter { it.sessionId == recoveredLiveId }
|
||||
.orEmpty()
|
||||
.also { recoveryEvents = null }
|
||||
}
|
||||
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
}
|
||||
}
|
||||
boundTurn.releaseDeferredEvents()
|
||||
claimedBackground?.pendingAsk?.let { ask ->
|
||||
boundTurn.restoreInteraction(ask)
|
||||
}
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
// A queued-only snapshot belongs to the NEXT turn. Never let
|
||||
// its events flow through the completed checkpoint's mapper.
|
||||
val priorBoundTurn = boundTurn
|
||||
@@ -886,6 +964,7 @@ class GatewayChatClient(
|
||||
priorBoundTurn?.detach()
|
||||
}
|
||||
} else {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
if (boundTurn != null) {
|
||||
if (activeTurn === boundTurn) activeTurn = null
|
||||
boundTurn.discardDeferredEvents()
|
||||
@@ -901,6 +980,7 @@ class GatewayChatClient(
|
||||
status = response.stringField("status"),
|
||||
inflight = inflight,
|
||||
queued = queued,
|
||||
autoContinue = autoContinue,
|
||||
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
|
||||
?.takeUnless { it.ended },
|
||||
)
|
||||
@@ -1349,6 +1429,84 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the profile-persisted approval policy added in gateway contract v3.
|
||||
* Older gateways either reject the key or return no recognized value; both
|
||||
* downgrade this optional control without affecting chat or per-session YOLO.
|
||||
*/
|
||||
suspend fun getApprovalMode(): Result<GatewayApprovalMode> {
|
||||
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
if (currentSessionProfile() != null) {
|
||||
return Result.failure(approvalModeRequiresLaunchProfile())
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
val response = rpc(
|
||||
"config.get",
|
||||
buildJsonObject { put("key", "approvals.mode") },
|
||||
)
|
||||
response.exceptionOrNull()?.let { error ->
|
||||
if (error.isApprovalModeUnsupported()) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
return Result.failure(error)
|
||||
}
|
||||
val mode = GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
|
||||
?: run {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = mode
|
||||
return Result.success(mode)
|
||||
}
|
||||
|
||||
/** Persist the selected approval policy for the active gateway profile. */
|
||||
suspend fun setApprovalMode(mode: GatewayApprovalMode): Result<GatewayApprovalMode> {
|
||||
if (_approvalModeCapability.value == GatewayApprovalModeCapability.Unsupported) {
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
if (currentSessionProfile() != null) {
|
||||
return Result.failure(approvalModeRequiresLaunchProfile())
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
val response = rpc(
|
||||
"config.set",
|
||||
buildJsonObject {
|
||||
put("key", "approvals.mode")
|
||||
put("value", mode.wireValue)
|
||||
},
|
||||
)
|
||||
response.exceptionOrNull()?.let { error ->
|
||||
if (error.isApprovalModeUnsupported()) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
return Result.failure(error)
|
||||
}
|
||||
val authoritative =
|
||||
GatewayApprovalMode.fromWire(response.getOrThrow().stringField("value"))
|
||||
?: run {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
return Result.failure(approvalModeUnsupported())
|
||||
}
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = authoritative
|
||||
return Result.success(authoritative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fast mode (priority service tier) via `config.set {key:"fast"}` —
|
||||
* desktop parity (`value` "fast"/"normal", session-scoped). Capability-gated
|
||||
@@ -1381,6 +1539,7 @@ class GatewayChatClient(
|
||||
unsolicitedTurnProvider = null
|
||||
coldPrewarmSessionReadyListener = null
|
||||
unmatchedTurnCompleteListener = null
|
||||
backgroundInteractionListener = null
|
||||
processEventListener = null
|
||||
closeSocket("client shutdown")
|
||||
backgroundCloseJob?.cancel()
|
||||
@@ -1423,6 +1582,7 @@ class GatewayChatClient(
|
||||
private suspend fun connectOnce() {
|
||||
val connectStart = System.nanoTime()
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||
throw GatewayConnectAttemptException("ws-ticket mint failed: ${e.message}")
|
||||
@@ -1512,6 +1672,14 @@ class GatewayChatClient(
|
||||
* session can paint its real model up front rather than waiting for a turn.
|
||||
*/
|
||||
private fun applySessionInfo(info: JsonObject) {
|
||||
val contract = (info["desktop_contract"] as? JsonPrimitive)?.intOrNull
|
||||
if (contract != null && contract < 3) {
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
GatewayApprovalMode.fromWire(info.stringField("approval_mode"))?.let { mode ->
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Supported
|
||||
_serverApprovalMode.value = mode
|
||||
}
|
||||
if (info.containsKey("personality")) {
|
||||
_serverPersonality.value =
|
||||
(info.stringField("personality") ?: "").ifBlank { "none" }
|
||||
@@ -1792,6 +1960,49 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// read_terminal is a renderer query, not a user decision. Android has
|
||||
// no xterm pane on the Gateway chat surface, so mirror upstream
|
||||
// desktop's no-live-pane behavior and answer with empty text instead
|
||||
// of blocking the agent for the server's 30-second timeout.
|
||||
if (type == "terminal.read.request") {
|
||||
val requestId = payload?.stringField("request_id")
|
||||
val ownedSession = !eventSessionId.isNullOrBlank() &&
|
||||
(eventSessionId == liveSessionId || backgroundTurns.containsKey(eventSessionId))
|
||||
if (!requestId.isNullOrBlank() && ownedSession) {
|
||||
scope.launch {
|
||||
rpc(
|
||||
"terminal.read.respond",
|
||||
buildJsonObject {
|
||||
put("request_id", requestId)
|
||||
put("text", "")
|
||||
},
|
||||
).onFailure {
|
||||
Log.w(TAG, "terminal.read.respond failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// A cold session.resume may schedule auto-continue before its RPC
|
||||
// response reaches Android. The recovery buffer is an ownership gate,
|
||||
// not an observational copy: an event is either claimed here for
|
||||
// replay or routed live below, never both. The resume response drains
|
||||
// and closes the gate under this same lock, so later frames route live.
|
||||
// Already-owned sibling sessions retain their background routing.
|
||||
val claimedByRecovery = !eventSessionId.isNullOrBlank() &&
|
||||
!backgroundTurns.containsKey(eventSessionId) &&
|
||||
synchronized(recoveryEventLock) {
|
||||
recoveryEvents?.let { buffered ->
|
||||
if (buffered.size >= MAX_RECOVERY_BUFFERED_EVENTS) {
|
||||
buffered.removeAt(0)
|
||||
}
|
||||
buffered += RecoveryEvent(eventSessionId, type, payload)
|
||||
true
|
||||
} ?: false
|
||||
}
|
||||
if (claimedByRecovery) return
|
||||
|
||||
// A profile/session switch may leave an upstream turn running while a
|
||||
// different profile becomes visible. Its events must never paint the
|
||||
// new transcript, but the terminal event still needs to reconcile the
|
||||
@@ -1799,6 +2010,47 @@ class GatewayChatClient(
|
||||
// switches back.
|
||||
val backgroundTurn = eventSessionId?.let(backgroundTurns::get)
|
||||
if (backgroundTurn != null) {
|
||||
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
|
||||
if (interactionRequest != null) {
|
||||
val previous = backgroundTurn.pendingAsk
|
||||
backgroundTurn.pendingAsk = interactionRequest
|
||||
if (previous?.kind != interactionRequest.kind ||
|
||||
previous.requestId != interactionRequest.requestId
|
||||
) {
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Requested(
|
||||
storedSessionId = backgroundTurn.storedSessionId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = interactionRequest,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val expiry = GatewayEventMapper.interactionExpiry(type, payload)
|
||||
val pendingAsk = backgroundTurn.pendingAsk
|
||||
val explicitlyExpired = expiry != null && pendingAsk != null &&
|
||||
pendingAsk.kind == expiry.kind &&
|
||||
(pendingAsk.kind == GatewayAsk.Kind.APPROVAL ||
|
||||
pendingAsk.requestId == expiry.requestId)
|
||||
val turnResumed = pendingAsk != null &&
|
||||
GatewayEventMapper.isInteractionResumeEvent(type)
|
||||
if (explicitlyExpired || turnResumed) {
|
||||
backgroundTurn.pendingAsk = null
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
GatewayBackgroundInteractionEvent.Resolved(
|
||||
storedSessionId = backgroundTurn.storedSessionId,
|
||||
profile = backgroundTurn.profile,
|
||||
ask = pendingAsk,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (type == "message.complete" || type == "error") {
|
||||
backgroundTurns.remove(eventSessionId, backgroundTurn)
|
||||
val expectedText = if (type == "message.complete") payload?.stringField("text") else null
|
||||
@@ -1822,7 +2074,7 @@ class GatewayChatClient(
|
||||
// `/personality`, desktop, or TUI change keeps the app in sync. Falls
|
||||
// through to the turn dispatch below so an in-flight turn still sees it.
|
||||
if (type == "session.info" &&
|
||||
(eventSessionId == null || liveSessionId == null || eventSessionId == liveSessionId)
|
||||
(eventSessionId == null || eventSessionId == liveSessionId)
|
||||
) {
|
||||
// Connection-level session info (model / provider / effort / persona /
|
||||
// yolo / fast / usage) — shared with the session.resume result via
|
||||
@@ -1952,6 +2204,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
pendingRpcs.values.forEach {
|
||||
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
|
||||
@@ -2130,6 +2383,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
}
|
||||
|
||||
@@ -2271,7 +2525,7 @@ class GatewayChatClient(
|
||||
private fun watchdogTimeoutFor(eventType: String): Long = when (eventType) {
|
||||
"clarify.request", "secret.request" -> ASK_CLARIFY_SECRET_TIMEOUT_MS
|
||||
"sudo.request" -> ASK_SUDO_TIMEOUT_MS
|
||||
"approval.request", "terminal.read.request" -> ASK_UNBOUNDED_TIMEOUT_MS
|
||||
"approval.request" -> ASK_UNBOUNDED_TIMEOUT_MS
|
||||
else -> turnIdleTimeoutMs
|
||||
}
|
||||
|
||||
@@ -2281,6 +2535,11 @@ class GatewayChatClient(
|
||||
deferEvents: Boolean = false,
|
||||
) : ActiveTurnHandle {
|
||||
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
|
||||
val pendingInteraction: GatewayAsk?
|
||||
get() = mapper.currentInteraction
|
||||
fun restoreInteraction(ask: GatewayAsk) {
|
||||
mapper.restoreInteraction(ask)
|
||||
}
|
||||
private val deferredEventLock = Any()
|
||||
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
|
||||
private var eventsDeferred = deferEvents
|
||||
@@ -2606,6 +2865,9 @@ class GatewayChatClient(
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onInterimReconciled = { text ->
|
||||
callbackDispatcher { callbacks.onInterimReconciled(text) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
@@ -2618,8 +2880,10 @@ class GatewayChatClient(
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
|
||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
|
||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||
onInteractionResolved = { v -> callbackDispatcher { callbacks.onInteractionResolved(v) } },
|
||||
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
|
||||
// onStatusUpdate a default no-op, so omitting it here silently swallows
|
||||
// EVERY gateway status line — the ❌ terminal-error lifecycle update
|
||||
@@ -2698,6 +2962,29 @@ private fun Throwable?.isMethodNotFound(): Boolean {
|
||||
msg.contains("unknown method", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun Throwable?.isApprovalModeUnsupported(): Boolean {
|
||||
val rpcError = this as? GatewayRpcException ?: return false
|
||||
val message = rpcError.message.orEmpty()
|
||||
return rpcError.code == JSONRPC_METHOD_NOT_FOUND ||
|
||||
rpcError.code == 4002 ||
|
||||
message.contains("approval mode", ignoreCase = true) &&
|
||||
(
|
||||
message.contains("unknown", ignoreCase = true) ||
|
||||
message.contains("unsupported", ignoreCase = true)
|
||||
)
|
||||
}
|
||||
|
||||
private fun approvalModeUnsupported(): GatewayRpcException =
|
||||
GatewayRpcException(
|
||||
"profile approval modes are not supported by this gateway",
|
||||
JSONRPC_METHOD_NOT_FOUND,
|
||||
)
|
||||
|
||||
private fun approvalModeRequiresLaunchProfile(): GatewayRpcException =
|
||||
GatewayRpcException(
|
||||
"profile approval mode is read-only for multiplexed non-launch profiles",
|
||||
)
|
||||
|
||||
private fun JsonObject.stringField(key: String): String? =
|
||||
(get(key) as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
|
||||
+219
-95
@@ -31,6 +31,15 @@ class GatewayEventMapper(
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
internal val currentInteraction: GatewayAsk?
|
||||
get() = pendingInteraction
|
||||
|
||||
internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
|
||||
pendingInteraction = ask
|
||||
if (!duplicate) callbacks.onInteractionRequest(ask)
|
||||
}
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var previousEventType: String? = null
|
||||
private var sawTextDelta = false
|
||||
@@ -39,6 +48,8 @@ class GatewayEventMapper(
|
||||
private var syntheticToolCounter = 0
|
||||
private var providerWaitStatusActive = false
|
||||
private var compactionStatusActive = false
|
||||
private var moaStatusActive = false
|
||||
private var pendingInteraction: GatewayAsk? = null
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
@@ -57,6 +68,30 @@ class GatewayEventMapper(
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
|
||||
interactionRequest(type, payload)?.let { ask ->
|
||||
restoreInteraction(ask)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
interactionExpiry(type, payload)?.let { expiry ->
|
||||
val pending = pendingInteraction
|
||||
if (pending != null && pending.matches(expiry)) {
|
||||
pendingInteraction = null
|
||||
}
|
||||
callbacks.onInteractionExpired(expiry)
|
||||
previousEventType = type
|
||||
return
|
||||
}
|
||||
if (type in INTERACTION_RESUME_EVENTS) {
|
||||
pendingInteraction?.let { ask ->
|
||||
pendingInteraction = null
|
||||
callbacks.onInteractionResolved(
|
||||
GatewayAskExpiry(kind = ask.kind, requestId = ask.requestId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (type) {
|
||||
"reasoning.delta" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -182,13 +217,18 @@ class GatewayEventMapper(
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true)
|
||||
val error = payload.string("error")
|
||||
val text = payload.string("text")
|
||||
val responsePreviewed = payload.boolean("response_previewed") == true
|
||||
val duplicatesPreview = responsePreviewed &&
|
||||
!text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
|
||||
if (!text.isNullOrEmpty() &&
|
||||
!duplicatesPreview &&
|
||||
?: error?.takeIf { failed }?.let { "Error: $it" }
|
||||
val reconcilesInterim = !text.isNullOrEmpty() &&
|
||||
previewedText?.let { preview ->
|
||||
preview.isNotEmpty() &&
|
||||
(text.startsWith(preview) || preview.startsWith(text))
|
||||
} == true
|
||||
if (reconcilesInterim) {
|
||||
callbacks.onInterimReconciled(text)
|
||||
} else if (!text.isNullOrEmpty() &&
|
||||
!isIntentionalSilenceMarker(text) &&
|
||||
(!sawTextDelta || previewedText != null)
|
||||
) {
|
||||
@@ -199,6 +239,12 @@ class GatewayEventMapper(
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
if (failed) {
|
||||
callbacks.onStatusUpdate(
|
||||
ERROR_STATUS_KIND,
|
||||
error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" },
|
||||
)
|
||||
}
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
@@ -236,36 +282,6 @@ class GatewayEventMapper(
|
||||
)
|
||||
}
|
||||
|
||||
"clarify.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"approval.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
// Current Hermes omits timeout metadata. Keep the legacy
|
||||
// no-countdown behavior unless a future contract exposes
|
||||
// the effective per-request timeout explicitly.
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
),
|
||||
)
|
||||
|
||||
"tool.output_risk" -> {
|
||||
val toolId = payload.string("tool_id")
|
||||
val risk = payload.string("risk")?.lowercase() ?: return
|
||||
@@ -286,59 +302,57 @@ class GatewayEventMapper(
|
||||
}
|
||||
}
|
||||
|
||||
// MoA activity proves auto-compaction has resumed even though
|
||||
// Android does not currently render these upstream events.
|
||||
"moa.reference", "moa.aggregating", "tool.progress" -> clearActivityStatuses()
|
||||
"moa.reference" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val text = payload.string("text")?.trim().orEmpty()
|
||||
if (text.isNotEmpty()) {
|
||||
val available = !isFailedMoaReference(text)
|
||||
callbacks.onMoaReference(
|
||||
GatewayMoaReference(
|
||||
index = payload.int("index")?.takeIf { it > 0 },
|
||||
count = payload.int("count")
|
||||
?.takeIf { it > 0 },
|
||||
label = payload.string("label")?.trim()?.take(MAX_MOA_LABEL_CHARS).orEmpty()
|
||||
.ifBlank { "Advisor" },
|
||||
text = if (available) text.take(MAX_MOA_REFERENCE_CHARS) else "",
|
||||
available = available,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"sudo.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
// Payload carries request_id ONLY — no command to show.
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
"moa.progress" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
|
||||
"secret.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
"moa.phase" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
when (payload.string("phase")?.lowercase()) {
|
||||
"aggregator", "aggregating" -> setMoaStatus("MoA: aggregating…")
|
||||
"reference", "references" -> {
|
||||
val total = payload.int("refs_total")
|
||||
?.takeIf { it > 0 }
|
||||
val done = payload.int("refs_done")
|
||||
if (total != null && done != null) {
|
||||
setMoaStatus("MoA: ${done.coerceIn(0, total)}/$total advisors complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"sudo.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
// Legacy phase marker retained by upstream for older consumers.
|
||||
"moa.aggregating" -> {
|
||||
clearProviderWaitAndCompaction()
|
||||
setMoaStatus("MoA: aggregating…")
|
||||
}
|
||||
|
||||
"secret.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
"clarify.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for the proposed upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> callbacks.onInteractionExpired(
|
||||
GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
),
|
||||
)
|
||||
"tool.progress" -> clearActivityStatuses()
|
||||
|
||||
"status.update" -> {
|
||||
val text = payload.string("text")
|
||||
@@ -370,27 +384,120 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
private fun clearActivityStatuses() {
|
||||
clearProviderWaitAndCompaction()
|
||||
if (!moaStatusActive) return
|
||||
moaStatusActive = false
|
||||
callbacks.onStatusClear(MOA_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun clearProviderWaitAndCompaction() {
|
||||
clearProviderWaitStatus()
|
||||
if (!compactionStatusActive) return
|
||||
compactionStatusActive = false
|
||||
callbacks.onStatusClear(COMPACTION_STATUS_KIND)
|
||||
}
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in APPROVAL_CHOICES }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
private fun setMoaStatus(text: String) {
|
||||
moaStatusActive = true
|
||||
callbacks.onStatusUpdate(MOA_STATUS_KIND, text)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
private val APPROVAL_CHOICES = setOf("once", "session", "always", "deny")
|
||||
const val ERROR_STATUS_KIND = "error"
|
||||
const val MOA_STATUS_KIND = "moa"
|
||||
private const val MAX_MOA_LABEL_CHARS = 120
|
||||
private const val MAX_MOA_REFERENCE_CHARS = 16_000
|
||||
private val OUTPUT_RISK_LEVELS = setOf("low", "medium", "high", "critical")
|
||||
private val INTERACTION_RESUME_EVENTS = setOf(
|
||||
"reasoning.delta",
|
||||
"thinking.delta",
|
||||
"reasoning.available",
|
||||
"message.delta",
|
||||
"message.interim",
|
||||
"message.start",
|
||||
"tool.generating",
|
||||
"tool.start",
|
||||
"tool.complete",
|
||||
"message.complete",
|
||||
"error",
|
||||
)
|
||||
|
||||
internal fun isFailedMoaReference(text: String): Boolean {
|
||||
val normalized = text.trimStart().lowercase()
|
||||
return normalized.startsWith("[failed:") || normalized.startsWith("[skipped:")
|
||||
}
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
"approval.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
choices = payload.approvalChoices(),
|
||||
smartDenied = payload.boolean("smart_denied") == true,
|
||||
timeoutSeconds = payload.int("timeout_seconds") ?: 0,
|
||||
)
|
||||
|
||||
"sudo.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
"secret.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
|
||||
"clarify.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"sudo.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
"secret.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
)
|
||||
|
||||
// Forward-compatible consumer for a future upstream approval
|
||||
// expiry event. Approvals correlate by session, never request id.
|
||||
"approval.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isInteractionResumeEvent(type: String): Boolean = type in INTERACTION_RESUME_EVENTS
|
||||
|
||||
/**
|
||||
* Hermes 2026-07-15 emits these operational wait lines through the
|
||||
@@ -456,3 +563,20 @@ private fun JsonObject?.int(key: String): Int? =
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? =
|
||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.booleanOrNull
|
||||
|
||||
private fun JsonObject?.approvalChoices(): List<String>? =
|
||||
(this?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.lowercase() }
|
||||
?.filter { it in setOf("once", "session", "always", "deny") }
|
||||
?.distinct()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun GatewayAsk.sameRequestAs(other: GatewayAsk): Boolean =
|
||||
kind == other.kind && requestId == other.requestId
|
||||
|
||||
private fun GatewayAsk.matches(expiry: GatewayAskExpiry): Boolean =
|
||||
kind == expiry.kind &&
|
||||
(kind == GatewayAsk.Kind.APPROVAL || requestId == expiry.requestId)
|
||||
|
||||
+93
-16
@@ -22,8 +22,9 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Opt-in foreground service that holds the app process up so the app's
|
||||
* connection to Hermes survives Android's background-freeze / Doze — i.e.
|
||||
* Foreground service that holds the app process up so work the user already
|
||||
* started survives Android's background-freeze / Doze. It runs automatically
|
||||
* while one or more turns are active, or continuously when the user enables
|
||||
* "persistent connection". Concretely it keeps the gateway chat WebSocket
|
||||
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
|
||||
@@ -39,13 +40,14 @@ import kotlinx.coroutines.launch
|
||||
* connection use case Google Play permits. The `specialUse` type is honest for
|
||||
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
||||
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
||||
* at submission. Off by default; only runs while the user enables the toggle.
|
||||
* at submission. Continuous idle retention is off by default; active work is
|
||||
* protected automatically and releases its lease on terminal settlement.
|
||||
*
|
||||
* # It does NOT own the socket
|
||||
*
|
||||
* The service's only job is to hold the process in the foreground. The socket
|
||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||
* idle-close timer while the toggle is on. On task removal (user swipes the app
|
||||
* idle-close timer while retention is required. On task removal (user swipes the app
|
||||
* away) the ViewModel + socket die with the process, so the service stops
|
||||
* itself rather than leave a notification that lies about being connected.
|
||||
*
|
||||
@@ -63,9 +65,31 @@ class GatewayKeepAliveService : Service() {
|
||||
private const val CHANNEL_NAME = "Persistent connection"
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
|
||||
private const val EXTRA_PERSISTENT = "persistent"
|
||||
private const val EXTRA_ACTIVE_TURNS = "active_turns"
|
||||
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
|
||||
@Volatile private var runningInstance: GatewayKeepAliveService? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
fun update(
|
||||
context: Context,
|
||||
persistent: Boolean,
|
||||
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
) {
|
||||
if (!persistent && !activeTurns.required) {
|
||||
stop(context)
|
||||
return
|
||||
}
|
||||
runningInstance?.let { service ->
|
||||
service.applyState(persistent, activeTurns)
|
||||
service.startForegroundNotification()
|
||||
return
|
||||
}
|
||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||
.setAction(ACTION_REFRESH)
|
||||
.putExtra(EXTRA_PERSISTENT, persistent)
|
||||
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
|
||||
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
@@ -83,21 +107,38 @@ class GatewayKeepAliveService : Service() {
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var persistent = false
|
||||
private var activeTurns = 0
|
||||
private var waitingSessions = 0
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runningInstance = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_REFRESH) {
|
||||
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
|
||||
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
|
||||
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
|
||||
.coerceIn(0, activeTurns)
|
||||
}
|
||||
startForegroundNotification()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
||||
// Flip the pref off so ConnectionViewModel's collector won't
|
||||
// restart us on the next foreground.
|
||||
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
persistent = false
|
||||
if (activeTurns == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
startForegroundNotification()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return START_STICKY
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
@@ -105,15 +146,26 @@ class GatewayKeepAliveService : Service() {
|
||||
// The socket lives in the ViewModel, which dies when the task is
|
||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (runningInstance === this) runningInstance = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
persistent: Boolean,
|
||||
turns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
) {
|
||||
this.persistent = persistent
|
||||
activeTurns = turns.activeTurnCount
|
||||
waitingSessions = turns.waitingSessionCount.coerceIn(0, activeTurns)
|
||||
}
|
||||
|
||||
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||
// are all declared in the main manifest (both flavors), so the type is
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
@@ -148,17 +200,42 @@ class GatewayKeepAliveService : Service() {
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
val (title, body) = when {
|
||||
waitingSessions > 0 -> {
|
||||
val title = if (waitingSessions == 1) {
|
||||
"Hermes is waiting for input"
|
||||
} else {
|
||||
"$waitingSessions Hermes sessions need input"
|
||||
}
|
||||
title to if (activeTurns > waitingSessions) {
|
||||
"$waitingSessions waiting · ${activeTurns - waitingSessions} still working"
|
||||
} else {
|
||||
"Open the requested session to review and continue."
|
||||
}
|
||||
}
|
||||
activeTurns > 0 -> {
|
||||
val title = if (activeTurns == 1) {
|
||||
"Hermes is finishing a turn"
|
||||
} else {
|
||||
"Hermes is finishing $activeTurns turns"
|
||||
}
|
||||
title to "The connection stays active until this work completes."
|
||||
}
|
||||
else -> getString(R.string.gateway_keepalive_title) to
|
||||
getString(R.string.gateway_keepalive_body)
|
||||
}
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.gateway_keepalive_title))
|
||||
.setContentText(getString(R.string.gateway_keepalive_body))
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Turn off", stopPending)
|
||||
.build()
|
||||
if (persistent) builder.addAction(0, "Turn off always-on", stopPending)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
|
||||
@@ -50,6 +50,29 @@ enum class GatewayConnectionState {
|
||||
Ready,
|
||||
}
|
||||
|
||||
/** Profile-persisted approval policy introduced by upstream gateway contract v3. */
|
||||
enum class GatewayApprovalMode(val wireValue: String) {
|
||||
Manual("manual"),
|
||||
Smart("smart"),
|
||||
Off("off");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): GatewayApprovalMode? = when (value?.trim()?.lowercase()) {
|
||||
"manual" -> Manual
|
||||
"smart" -> Smart
|
||||
"off" -> Off
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this gateway exposes the contract-v3 profile approval-mode RPCs. */
|
||||
enum class GatewayApprovalModeCapability {
|
||||
Unknown,
|
||||
Supported,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||
@@ -57,8 +80,9 @@ enum class GatewayConnectionState {
|
||||
*
|
||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
||||
* gateway while the dashboard probe is unresolved or ready. A capability-
|
||||
* preferred SSE fallback is selected only after a definitive unavailable,
|
||||
* unsupported, or sign-in-required verdict.
|
||||
*/
|
||||
fun resolveStreamingEndpointPreference(
|
||||
preference: String,
|
||||
@@ -66,7 +90,10 @@ fun resolveStreamingEndpointPreference(
|
||||
capabilities: ServerCapabilities,
|
||||
): String = when (preference) {
|
||||
"sessions", "completions", "runs", "gateway" -> preference
|
||||
else -> if (gateway == GatewayAvailability.Ready) {
|
||||
else -> if (
|
||||
gateway == GatewayAvailability.Ready ||
|
||||
gateway == GatewayAvailability.Unknown
|
||||
) {
|
||||
"gateway"
|
||||
} else {
|
||||
capabilities.preferredChatEndpoint()
|
||||
@@ -95,6 +122,9 @@ data class GatewayInflightTurn(
|
||||
val user: String,
|
||||
val assistant: String,
|
||||
val streaming: Boolean,
|
||||
val status: String? = null,
|
||||
val error: String? = null,
|
||||
val recoverable: Boolean = false,
|
||||
)
|
||||
|
||||
/** A next-turn prompt accepted by upstream while the current turn was busy. */
|
||||
@@ -102,6 +132,12 @@ data class GatewayQueuedTurn(
|
||||
val user: String,
|
||||
)
|
||||
|
||||
/** A fresh crash marker caused `session.resume` to schedule one continuation. */
|
||||
data class GatewayAutoContinue(
|
||||
val attempt: Int,
|
||||
val interruptedAt: Double?,
|
||||
)
|
||||
|
||||
/** Optional project identity attached to newer upstream session metadata. */
|
||||
data class GatewaySessionProject(
|
||||
val id: String?,
|
||||
@@ -120,10 +156,11 @@ data class GatewaySessionRecovery(
|
||||
val queued: GatewayQueuedTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
val autoContinue: GatewayAutoContinue? = null,
|
||||
) {
|
||||
/** Whether upstream still owes this client live turn events. */
|
||||
val hasPendingWork: Boolean
|
||||
get() = running || queued != null
|
||||
get() = running || queued != null || autoContinue != null
|
||||
}
|
||||
|
||||
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
|
||||
@@ -133,6 +170,25 @@ data class GatewayBackgroundTurnCompletion(
|
||||
val expectedAssistantText: String?,
|
||||
)
|
||||
|
||||
/** Input lifecycle from a deliberately detached Gateway turn. */
|
||||
sealed interface GatewayBackgroundInteractionEvent {
|
||||
val storedSessionId: String
|
||||
val profile: String?
|
||||
val ask: GatewayAsk
|
||||
|
||||
data class Requested(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
|
||||
data class Resolved(
|
||||
override val storedSessionId: String,
|
||||
override val profile: String?,
|
||||
override val ask: GatewayAsk,
|
||||
) : GatewayBackgroundInteractionEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
@@ -291,6 +347,14 @@ data class GatewayModelProvider(
|
||||
val totalModels: Int = 0,
|
||||
)
|
||||
|
||||
data class GatewayMoaReference(
|
||||
val index: Int?,
|
||||
val count: Int?,
|
||||
val label: String,
|
||||
val text: String,
|
||||
val available: Boolean = true,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
@@ -298,6 +362,15 @@ data class GatewayModelOptions(
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||
internal fun isCurrentModelOptionsResponse(
|
||||
requestGeneration: Long,
|
||||
currentGeneration: Long,
|
||||
requestProfileKey: String,
|
||||
currentProfileKey: String,
|
||||
): Boolean =
|
||||
requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
|
||||
|
||||
/**
|
||||
* The explicit in-chat overrides to bind onto a gateway `session.create` as the
|
||||
* new session's PER-SESSION overrides. Matches the upstream desktop client,
|
||||
@@ -355,6 +428,12 @@ class GatewayTurnCallbacks(
|
||||
* sealing the current assistant segment.
|
||||
*/
|
||||
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
|
||||
/**
|
||||
* The terminal text is equal/prefix-related to the sealed interim, so the
|
||||
* existing segment should be replaced in place instead of opening a second
|
||||
* assistant bubble.
|
||||
*/
|
||||
val onInterimReconciled: (text: String) -> Unit = { _ -> },
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
@@ -379,6 +458,8 @@ class GatewayTurnCallbacks(
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/** Successful MoA advisor output for a transient labelled reference block. */
|
||||
val onMoaReference: (GatewayMoaReference) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
@@ -387,6 +468,8 @@ class GatewayTurnCallbacks(
|
||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||
/** Server declared a pending interaction expired; clear only the matching card. */
|
||||
val onInteractionExpired: (GatewayAskExpiry) -> Unit,
|
||||
/** The turn resumed after a pending interaction was resolved elsewhere. */
|
||||
val onInteractionResolved: (GatewayAskExpiry) -> Unit = { _ -> },
|
||||
/**
|
||||
* Gateway `status.update` lifecycle line — model fallback, retries, and
|
||||
* errors (often emoji-prefixed: 🔄 fallback, ⏳ retry, ❌ error). Default
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -77,6 +78,10 @@ data class ServerCapabilities(
|
||||
val portable: Boolean,
|
||||
/** `/health` — basic reachability. */
|
||||
val healthy: Boolean,
|
||||
/** Authenticated provider/model inventory at `/api/model/options`. */
|
||||
val modelOptions: Boolean = false,
|
||||
/** Backend-acknowledged per-session model lock. */
|
||||
val sessionModelLock: Boolean = false,
|
||||
) {
|
||||
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
|
||||
fun preferredChatEndpoint(): String = when {
|
||||
@@ -100,6 +105,8 @@ data class ServerCapabilities(
|
||||
runs = false,
|
||||
portable = false,
|
||||
healthy = false,
|
||||
modelOptions = false,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +146,8 @@ internal fun parseCapabilitiesBody(json: Json, body: String): ServerCapabilities
|
||||
feature("chat_completions") ||
|
||||
endpoint("chat_completions"),
|
||||
healthy = true,
|
||||
modelOptions = feature("model_options") || endpoint("model_options"),
|
||||
sessionModelLock = feature("session_model_lock") || endpoint("session_model_lock"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,6 +199,129 @@ data class ApiModelOption(
|
||||
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
|
||||
}
|
||||
|
||||
/** Authenticated provider/model inventory advertised by `/api/model/options`. */
|
||||
data class ApiProviderModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
internal fun parseApiProviderModelOptionsBody(
|
||||
json: Json,
|
||||
body: String,
|
||||
): ApiProviderModelOptions? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val rows = root["providers"] as? JsonArray ?: return null
|
||||
val providers = rows.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
return ApiProviderModelOptions(
|
||||
providers = providers,
|
||||
currentModel = (root["model"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
currentProvider = (root["provider"] as? JsonPrimitive)?.contentOrNull.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
enum class ApiModelRoutingErrorCode {
|
||||
INVENTORY_UNSUPPORTED,
|
||||
INVENTORY_UNAVAILABLE,
|
||||
PROVIDER_NOT_AUTHENTICATED,
|
||||
MODEL_NOT_AVAILABLE,
|
||||
MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
LOCK_CAPABILITY_INCOMPLETE,
|
||||
LOCK_REJECTED,
|
||||
LOCK_ACK_MISMATCH,
|
||||
LEGACY_PROVIDER_UNSUPPORTED,
|
||||
}
|
||||
|
||||
class ApiModelRoutingException(
|
||||
val code: ApiModelRoutingErrorCode,
|
||||
message: String,
|
||||
) : IOException(message)
|
||||
|
||||
sealed interface ApiModelSelectionAck {
|
||||
data object ServerDefault : ApiModelSelectionAck
|
||||
data class Locked(
|
||||
val sessionId: String,
|
||||
val model: String,
|
||||
val provider: String?,
|
||||
val effectiveModel: String = model,
|
||||
val effectiveProvider: String? = provider,
|
||||
) : ApiModelSelectionAck
|
||||
data class LegacyModelHint(val model: String) : ApiModelSelectionAck
|
||||
}
|
||||
|
||||
internal enum class ApiModelRoutingStrategy { LOCKED, LEGACY_HINT, INCOMPLETE }
|
||||
|
||||
internal fun apiModelRoutingStrategy(capabilities: ServerCapabilities): ApiModelRoutingStrategy =
|
||||
when {
|
||||
capabilities.sessionModelLock && capabilities.modelOptions ->
|
||||
ApiModelRoutingStrategy.LOCKED
|
||||
capabilities.sessionModelLock ->
|
||||
ApiModelRoutingStrategy.INCOMPLETE
|
||||
else ->
|
||||
ApiModelRoutingStrategy.LEGACY_HINT
|
||||
}
|
||||
|
||||
internal fun sessionTurnModelHint(
|
||||
acknowledgement: ApiModelSelectionAck,
|
||||
requestedModel: String?,
|
||||
): String? =
|
||||
if (acknowledgement is ApiModelSelectionAck.Locked) null else requestedModel
|
||||
|
||||
internal data class ParsedApiModelLockAck(
|
||||
val sessionId: String?,
|
||||
val model: String?,
|
||||
val provider: String?,
|
||||
val state: String?,
|
||||
val effectiveModel: String?,
|
||||
val effectiveProvider: String?,
|
||||
)
|
||||
|
||||
internal fun parseApiModelLockAck(json: Json, body: String): ParsedApiModelLockAck? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
val runtime = root["runtime"] as? JsonObject ?: return null
|
||||
val requested = runtime["requested"] as? JsonObject
|
||||
val effective = runtime["effective"] as? JsonObject
|
||||
return ParsedApiModelLockAck(
|
||||
sessionId = (root["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||
model = (requested?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
provider = (requested?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
state = (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveModel = (effective?.get("model") as? JsonPrimitive)?.contentOrNull,
|
||||
effectiveProvider = (effective?.get("provider") as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun confirmedRuntimeMatches(
|
||||
runtime: JsonObject?,
|
||||
expected: ApiModelSelectionAck.Locked,
|
||||
): Boolean {
|
||||
runtime ?: return false
|
||||
val effective = runtime["effective"] as? JsonObject ?: return false
|
||||
return (runtime["model_lock"] as? JsonPrimitive)?.contentOrNull == "confirmed" &&
|
||||
(effective["model"] as? JsonPrimitive)?.contentOrNull == expected.effectiveModel &&
|
||||
(effective["provider"] as? JsonPrimitive)?.contentOrNull == expected.effectiveProvider
|
||||
}
|
||||
|
||||
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
|
||||
val data = try {
|
||||
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
|
||||
@@ -315,6 +447,8 @@ class HermesApiClient(
|
||||
isLenient = true
|
||||
}
|
||||
) {
|
||||
@Volatile
|
||||
private var lastCapabilities: ServerCapabilities? = null
|
||||
private val baseUrl: String = baseUrl.trimEnd('/')
|
||||
|
||||
companion object {
|
||||
@@ -634,6 +768,205 @@ class HermesApiClient(
|
||||
/** Compatibility view for callers that only need request ids. */
|
||||
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
|
||||
|
||||
/** Provider-aware picker inventory; never falls back to unauthenticated local guesses. */
|
||||
suspend fun getProviderModelOptions(
|
||||
refresh: Boolean = false,
|
||||
): Result<ApiProviderModelOptions> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val suffix = if (refresh) "?refresh=true" else ""
|
||||
val request = authRequest("$baseUrl/api/model/options$suffix").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
if (response.code == 404) {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
|
||||
} else {
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE
|
||||
},
|
||||
if (response.code == 401 || response.code == 403) {
|
||||
"Model inventory authorization failed (HTTP ${response.code})."
|
||||
} else {
|
||||
"Model inventory unavailable (HTTP ${response.code})."
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
val parsed = parseApiProviderModelOptionsBody(json, response.body.string())
|
||||
?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory returned an invalid response.",
|
||||
),
|
||||
)
|
||||
Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and, on capable servers, persist a model/provider lock before a
|
||||
* session turn is submitted. This never writes global config.
|
||||
*/
|
||||
suspend fun acknowledgeSessionModelSelection(
|
||||
sessionId: String,
|
||||
model: String?,
|
||||
provider: String?,
|
||||
): Result<ApiModelSelectionAck> = withContext(Dispatchers.IO) {
|
||||
val selectedModel = AgentDisplay.requestModelName(model)
|
||||
?: return@withContext Result.success(ApiModelSelectionAck.ServerDefault)
|
||||
val selectedProvider = provider?.trim()?.takeIf { it.isNotEmpty() }
|
||||
// Capability snapshots can be populated by a disconnected startup
|
||||
// probe. Re-probe at the lock boundary instead of trusting a stale
|
||||
// false forever after the connection recovers.
|
||||
val capabilities = probeCapabilities()
|
||||
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.LOCKED) {
|
||||
val inventory = getProviderModelOptions().getOrElse {
|
||||
return@withContext Result.failure(it)
|
||||
}
|
||||
val aliases = getModelOptions()
|
||||
val selectedRoot = aliases.firstOrNull { it.id == selectedModel }?.root
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val providerModel = selectedRoot ?: selectedModel
|
||||
val providerRow = when {
|
||||
selectedProvider != null ->
|
||||
inventory.providers.firstOrNull { it.slug == selectedProvider }
|
||||
else -> inventory.providers.singleOrNull { providerModel in it.models }
|
||||
?: inventory.providers.firstOrNull {
|
||||
it.isCurrent && providerModel in it.models
|
||||
}
|
||||
} ?: return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not in the API server's authenticated inventory.",
|
||||
),
|
||||
)
|
||||
if (!providerRow.authenticated) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.PROVIDER_NOT_AUTHENTICATED,
|
||||
"The selected provider is not authenticated on this profile.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel !in providerRow.models) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"The selected model is not available from ${providerRow.name}.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (providerModel in providerRow.unavailableModels) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE_ON_PLAN,
|
||||
"The selected model is not available on the authenticated account.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val body = kotlinx.serialization.json.buildJsonObject {
|
||||
put("model", selectedModel)
|
||||
put("provider", providerRow.slug)
|
||||
}
|
||||
try {
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/model")
|
||||
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body.string()
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
streamHttpFailureMessage(
|
||||
response.code,
|
||||
response.message,
|
||||
response.header("Retry-After"),
|
||||
responseBody,
|
||||
json,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
val ack = parseApiModelLockAck(json, responseBody)
|
||||
if (
|
||||
ack?.sessionId != sessionId ||
|
||||
ack?.model != selectedModel ||
|
||||
ack?.provider != providerRow.slug ||
|
||||
ack?.state != "accepted" ||
|
||||
ack?.effectiveModel.isNullOrBlank() ||
|
||||
ack?.effectiveProvider.isNullOrBlank()
|
||||
) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_ACK_MISMATCH,
|
||||
"Server did not acknowledge the requested model lock.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val confirmedAck = requireNotNull(ack)
|
||||
Result.success(
|
||||
ApiModelSelectionAck.Locked(
|
||||
sessionId = sessionId,
|
||||
model = selectedModel,
|
||||
provider = providerRow.slug,
|
||||
effectiveModel = requireNotNull(confirmedAck.effectiveModel),
|
||||
effectiveProvider = confirmedAck.effectiveProvider,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(
|
||||
if (e is ApiModelRoutingException) e else {
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_REJECTED,
|
||||
"Model lock request failed before the message was sent.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (apiModelRoutingStrategy(capabilities) == ApiModelRoutingStrategy.INCOMPLETE) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LOCK_CAPABILITY_INCOMPLETE,
|
||||
"Server advertises an incomplete model-routing contract.",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (selectedProvider != null) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.LEGACY_PROVIDER_UNSUPPORTED,
|
||||
"This Hermes version cannot safely preserve a provider selection on API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
val advertised = getModelOptions().map { it.id }
|
||||
if (selectedModel !in advertised) {
|
||||
return@withContext Result.failure(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.MODEL_NOT_AVAILABLE,
|
||||
"This Hermes version did not advertise the selected model for API fallback.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Result.success(ApiModelSelectionAck.LegacyModelHint(selectedModel))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -756,6 +1089,7 @@ class HermesApiClient(
|
||||
onError: (String) -> Unit,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
expectedModelLock: ApiModelSelectionAck.Locked? = null,
|
||||
): EventSource {
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
Log.d(TAG, "sendChatStream: modelOverride=$modelOverride (profile pick)")
|
||||
@@ -786,6 +1120,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val runtimeConfirmed = AtomicBoolean(expectedModelLock == null)
|
||||
val receivedEvent = AtomicBoolean(false)
|
||||
val drainRetryScheduled = AtomicBoolean(false)
|
||||
val turnSource = RetryingEventSource(request, mainHandler)
|
||||
@@ -806,7 +1141,13 @@ class HermesApiClient(
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -882,9 +1223,17 @@ class HermesApiClient(
|
||||
}
|
||||
// assistant.completed — one turn finished, but run may continue with tool calls
|
||||
"assistant.completed" -> {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
}
|
||||
} else if (event.interrupted == true) {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
onError("Response interrupted")
|
||||
}
|
||||
@@ -896,9 +1245,15 @@ class HermesApiClient(
|
||||
// run.completed — the entire agent loop is done (all turns + tool calls)
|
||||
"run.completed" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val runtimeMatches = expectedModelLock?.let {
|
||||
confirmedRuntimeMatches(event.runtime, it)
|
||||
} ?: true
|
||||
if (runtimeMatches) runtimeConfirmed.set(true)
|
||||
mainHandler.post {
|
||||
onUsage(event.usage)
|
||||
if (event.interrupted == true) {
|
||||
if (!runtimeMatches) {
|
||||
onError("Server response did not confirm the selected model route.")
|
||||
} else if (event.interrupted == true) {
|
||||
onError("Run interrupted")
|
||||
} else {
|
||||
onComplete()
|
||||
@@ -908,7 +1263,13 @@ class HermesApiClient(
|
||||
}
|
||||
"done" -> {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server ended the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"error" -> {
|
||||
@@ -987,7 +1348,13 @@ class HermesApiClient(
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
mainHandler.post {
|
||||
if (runtimeConfirmed.get()) {
|
||||
onComplete()
|
||||
} else {
|
||||
onError("Server closed the turn without confirming the selected model route.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1530,7 +1897,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
|
||||
if (!healthy) {
|
||||
lastCapabilities = ServerCapabilities.DISCONNECTED
|
||||
return@withContext ServerCapabilities.DISCONNECTED
|
||||
}
|
||||
|
||||
val advertisedCapabilities = try {
|
||||
val req = authRequest("$baseUrl/v1/capabilities").get().build()
|
||||
@@ -1544,7 +1914,10 @@ class HermesApiClient(
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (advertisedCapabilities != null) return@withContext advertisedCapabilities
|
||||
if (advertisedCapabilities != null) {
|
||||
lastCapabilities = advertisedCapabilities
|
||||
return@withContext advertisedCapabilities
|
||||
}
|
||||
|
||||
// Reusable HEAD probe — returns true if the route is registered
|
||||
// (any status except 404 + network errors). Already inside the
|
||||
@@ -1589,7 +1962,7 @@ class HermesApiClient(
|
||||
runs = runs,
|
||||
portable = portable,
|
||||
healthy = true,
|
||||
)
|
||||
).also { lastCapabilities = it }
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
@@ -26,10 +26,12 @@ import kotlinx.serialization.json.putJsonObject
|
||||
*
|
||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||
* consumes `message` (or `input`) and `system_message` (or
|
||||
* `instructions`, string only). `message` accepts either a plain string
|
||||
* or OpenAI-style content parts (text + `image_url`) via
|
||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
||||
* `model`, and `profile` are NOT parsed.
|
||||
* `instructions`, string only). Newer servers also parse per-request model
|
||||
* fields and reuse a backend-acknowledged session model lock when those
|
||||
* fields are omitted. Android therefore acknowledges a lock first and
|
||||
* omits `model` on that turn; the builder's model field remains only for
|
||||
* older-server compatibility. Top-level `messages`, `attachments`, and
|
||||
* `profile` are not parsed.
|
||||
*
|
||||
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||
* array), `instructions`, `conversation_history` (array of
|
||||
@@ -45,9 +47,8 @@ import kotlinx.serialization.json.putJsonObject
|
||||
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||
* Top-level `attachments` and `profile` are NOT parsed.
|
||||
*
|
||||
* Legacy hint fields we deliberately keep sending although current native
|
||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
||||
* `profile` on runs/completions, and `stream` on runs. They are
|
||||
* Legacy hint fields we deliberately keep sending: `model` + `profile` on the
|
||||
* sessions path, `profile` on runs/completions, and `stream` on runs. They are
|
||||
* configuration hints (never user content, so they cannot mask data
|
||||
* loss) honored by legacy fork builds — the runs path in particular only
|
||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.Route
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
private const val NATIVE_PKCE_FLOW = "native_pkce"
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val TOKEN_KEY = "dashboard_native_tokens_json"
|
||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
@Serializable
|
||||
data class NativeDashboardTokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("refresh_token") val refreshToken: String = "",
|
||||
@SerialName("expires_at") val expiresAt: Long = 0L,
|
||||
val provider: String = "",
|
||||
@SerialName("user_id") val userId: String = "",
|
||||
)
|
||||
|
||||
interface NativeDashboardTokenStore {
|
||||
/** Stable, non-secret identity used to serialize refresh-token rotation. */
|
||||
val coordinationKey: String
|
||||
fun load(): NativeDashboardTokens?
|
||||
fun save(tokens: NativeDashboardTokens)
|
||||
fun clear()
|
||||
}
|
||||
|
||||
internal fun clearNativeDashboardTokens(store: NativeDashboardTokenStore) {
|
||||
NativeTokenRefreshCoordinator.clear(store)
|
||||
}
|
||||
|
||||
class EncryptedNativeDashboardTokenStore(
|
||||
context: Context,
|
||||
tokenStoreKey: String,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
) : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = tokenStoreKey
|
||||
private val store: SessionTokenStore = SecureStoreCache.getOrBuild(tokenStoreKey) {
|
||||
buildRawTokenStore(context.applicationContext, tokenStoreKey)
|
||||
}
|
||||
|
||||
override fun load(): NativeDashboardTokens? =
|
||||
store.getString(TOKEN_KEY)?.let { raw ->
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(raw) }.getOrNull()
|
||||
}
|
||||
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
store.putString(TOKEN_KEY, json.encodeToString(tokens))
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
store.remove(TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ephemeral authorization state. Keep this object in the sign-in coroutine:
|
||||
* its verifier and CSRF state must never be persisted, logged, or copied into
|
||||
* Compose/SavedState UI state.
|
||||
*/
|
||||
class NativeDashboardAuthorization internal constructor(
|
||||
val authorizationUrl: String,
|
||||
internal val verifier: String,
|
||||
internal val state: String,
|
||||
internal val generation: Long,
|
||||
)
|
||||
|
||||
class NativeDashboardAuthClient(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
private val random: SecureRandom = SecureRandom(),
|
||||
) {
|
||||
private val baseUrl = baseUrl.trim().trimEnd('/')
|
||||
|
||||
fun supportsNativePkce(status: DashboardStatus): Boolean =
|
||||
NATIVE_PKCE_FLOW in status.authFlows
|
||||
|
||||
fun beginAuthorization(
|
||||
redirectUri: String,
|
||||
provider: String? = null,
|
||||
): NativeDashboardAuthorization {
|
||||
requireStrictLoopbackRedirect(redirectUri)
|
||||
val verifier = randomBytes(32).base64Url()
|
||||
val challenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
val state = randomBytes(24).base64Url()
|
||||
val root = "$baseUrl/auth/native/authorize".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val url = root.newBuilder()
|
||||
.addQueryParameter("code_challenge", challenge)
|
||||
.addQueryParameter("code_challenge_method", "S256")
|
||||
.addQueryParameter("redirect_uri", redirectUri)
|
||||
.addQueryParameter("state", state)
|
||||
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
|
||||
.build()
|
||||
.toString()
|
||||
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
return NativeDashboardAuthorization(url, verifier, state, generation)
|
||||
}
|
||||
|
||||
fun exchangeCallback(
|
||||
authorization: NativeDashboardAuthorization,
|
||||
callbackTarget: String,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val callback = callbackTarget.toHttpUrlOrNull()
|
||||
?: "http://127.0.0.1$callbackTarget".toHttpUrlOrNull()
|
||||
?: throw NativeDashboardCallbackException("Native sign-in callback was malformed")
|
||||
if (callback.host != "127.0.0.1" || callback.encodedPath != CALLBACK_PATH) {
|
||||
throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not use the expected loopback path",
|
||||
)
|
||||
}
|
||||
if (callback.queryParameter("state") != authorization.state) {
|
||||
throw NativeDashboardCallbackException("Native sign-in callback state did not match")
|
||||
}
|
||||
callback.queryParameter("error")?.let {
|
||||
throw NativeDashboardCallbackException(
|
||||
message = "Gateway rejected native sign-in",
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
val code = callback.queryParameter("code")
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: throw NativeDashboardCallbackException(
|
||||
"Native sign-in callback did not include an authorization code",
|
||||
)
|
||||
val payload = NativeTokenExchange(code = code, codeVerifier = authorization.verifier)
|
||||
return postTokens(
|
||||
path = "/auth/native/token",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = authorization.generation,
|
||||
commitAllowed = commitAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun cancelAuthorization(authorization: NativeDashboardAuthorization) {
|
||||
NativeTokenRefreshCoordinator.cancelAuthorization(
|
||||
tokenStore.coordinationKey,
|
||||
authorization.generation,
|
||||
)
|
||||
}
|
||||
|
||||
fun clearStoredSession() {
|
||||
clearNativeDashboardTokens(tokenStore)
|
||||
}
|
||||
|
||||
fun refresh(tokens: NativeDashboardTokens? = null): NativeDashboardTokens {
|
||||
return synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load()
|
||||
?: tokens
|
||||
?: throw IOException("No native dashboard session is stored")
|
||||
// A sibling client may already have rotated the single-use refresh
|
||||
// token while this caller was waiting. Adopt that winner instead
|
||||
// of replaying the stale token.
|
||||
if (tokens != null && current != tokens) return@synchronized current
|
||||
if (current.refreshToken.isBlank()) {
|
||||
clearIfUnchanged(current)
|
||||
throw IOException("Native dashboard session cannot be refreshed")
|
||||
}
|
||||
val payload = NativeTokenRefresh(current.refreshToken, current.provider)
|
||||
val generation = NativeTokenRefreshCoordinator.currentGeneration(
|
||||
tokenStore.coordinationKey,
|
||||
)
|
||||
try {
|
||||
postTokens(
|
||||
path = "/auth/native/refresh",
|
||||
payload = json.encodeToString(payload),
|
||||
clearOnAuthFailure = false,
|
||||
expectedGeneration = generation,
|
||||
)
|
||||
} catch (error: NativeDashboardAuthHttpException) {
|
||||
if (error.statusCode == 400 || error.statusCode == 401) {
|
||||
clearIfUnchanged(current)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postTokens(
|
||||
path: String,
|
||||
payload: String,
|
||||
clearOnAuthFailure: Boolean,
|
||||
expectedGeneration: Long,
|
||||
commitAllowed: () -> Boolean = { true },
|
||||
): NativeDashboardTokens {
|
||||
val url = "$baseUrl$path".toHttpUrlOrNull()
|
||||
?: throw IOException("Dashboard URL is not a valid http(s) address")
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(payload.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val tokens = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
if (clearOnAuthFailure && (response.code == 400 || response.code == 401)) {
|
||||
tokenStore.clear()
|
||||
}
|
||||
throw NativeDashboardAuthHttpException(response.code)
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
runCatching { json.decodeFromString<NativeDashboardTokens>(body) }
|
||||
.getOrElse { throw IOException("Dashboard token response was malformed", it) }
|
||||
.also {
|
||||
if (it.accessToken.isBlank()) {
|
||||
throw IOException("Dashboard token response did not include an access token")
|
||||
}
|
||||
}
|
||||
}
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
if (!commitAllowed() ||
|
||||
NativeTokenRefreshCoordinator.currentGeneration(tokenStore.coordinationKey) !=
|
||||
expectedGeneration
|
||||
) {
|
||||
throw IOException("Dashboard sign-in is no longer active")
|
||||
}
|
||||
tokenStore.save(tokens)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private fun randomBytes(size: Int) = ByteArray(size).also(random::nextBytes).toByteString()
|
||||
|
||||
private fun clearIfUnchanged(expected: NativeDashboardTokens) {
|
||||
if (tokenStore.load() == expected) tokenStore.clear()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun requireStrictLoopbackRedirect(redirectUri: String) {
|
||||
val url = redirectUri.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Native redirect must be a valid loopback HTTP URL")
|
||||
require(url.scheme == "http" && url.host == "127.0.0.1") {
|
||||
"Native redirect must use the 127.0.0.1 loopback address"
|
||||
}
|
||||
require(url.port in 1..65535 && url.encodedPath == CALLBACK_PATH && url.query == null) {
|
||||
"Native redirect must use an ephemeral port and the exact /callback path"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class NativeDashboardCallbackException(
|
||||
message: String,
|
||||
val retryable: Boolean = true,
|
||||
) : IOException(message)
|
||||
|
||||
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" ||
|
||||
(url.scheme == "http" && url.host == "127.0.0.1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the native bearer to dashboard REST calls and rotates it before expiry
|
||||
* or after one 401. Refresh requests use a separate bare client, so neither a
|
||||
* stale bearer nor the authenticator can recurse into token rotation.
|
||||
*/
|
||||
class DashboardBearerAuth(
|
||||
baseUrl: String,
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val clockSeconds: () -> Long = { System.currentTimeMillis() / 1000L },
|
||||
) : Interceptor, Authenticator {
|
||||
private val authClient = NativeDashboardAuthClient(baseUrl, tokenStore)
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val tokens = usableTokens(forceRefresh = false, failedAccessToken = null)
|
||||
val request = tokens?.let {
|
||||
chain.request().newBuilder()
|
||||
.header("Authorization", "Bearer ${it.accessToken}")
|
||||
.build()
|
||||
} ?: chain.request()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
override fun authenticate(route: Route?, response: Response): Request? {
|
||||
if (responseCount(response) >= 2) return null
|
||||
val previous = response.request.header("Authorization") ?: return null
|
||||
val failedAccessToken = previous.removePrefix("Bearer ").takeIf { it != previous }
|
||||
val tokens = usableTokens(
|
||||
forceRefresh = true,
|
||||
failedAccessToken = failedAccessToken,
|
||||
) ?: return null
|
||||
val next = "Bearer ${tokens.accessToken}"
|
||||
if (next == previous) return null
|
||||
return response.request.newBuilder().header("Authorization", next).build()
|
||||
}
|
||||
|
||||
private fun usableTokens(
|
||||
forceRefresh: Boolean,
|
||||
failedAccessToken: String?,
|
||||
): NativeDashboardTokens? =
|
||||
synchronized(NativeTokenRefreshCoordinator.lockFor(tokenStore.coordinationKey)) {
|
||||
val current = tokenStore.load() ?: return@synchronized null
|
||||
// A request can receive its 401 after another client already
|
||||
// rotated the token. Retry with the winner; do not rotate again.
|
||||
if (failedAccessToken != null && current.accessToken != failedAccessToken) {
|
||||
return@synchronized current
|
||||
}
|
||||
val nearExpiry = current.expiresAt <= 0L || clockSeconds() >= current.expiresAt - 60L
|
||||
if (!forceRefresh && !nearExpiry) return@synchronized current
|
||||
runCatching { authClient.refresh(current) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun responseCount(response: Response): Int {
|
||||
var count = 1
|
||||
var prior = response.priorResponse
|
||||
while (prior != null) {
|
||||
count += 1
|
||||
prior = prior.priorResponse
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private class NativeDashboardAuthHttpException(
|
||||
val statusCode: Int,
|
||||
) : IOException("Dashboard native authentication failed (HTTP $statusCode)")
|
||||
|
||||
private object NativeTokenRefreshCoordinator {
|
||||
private val locks = ConcurrentHashMap<String, Any>()
|
||||
private val generations = ConcurrentHashMap<String, Long>()
|
||||
|
||||
fun lockFor(key: String): Any = locks.computeIfAbsent(key) { Any() }
|
||||
|
||||
fun currentGeneration(key: String): Long =
|
||||
synchronized(lockFor(key)) { generations[key] ?: 0L }
|
||||
|
||||
fun beginAuthorization(key: String): Long =
|
||||
synchronized(lockFor(key)) {
|
||||
(generations[key] ?: 0L).plus(1L).also { generations[key] = it }
|
||||
}
|
||||
|
||||
fun cancelAuthorization(key: String, expectedGeneration: Long) {
|
||||
synchronized(lockFor(key)) {
|
||||
if ((generations[key] ?: 0L) == expectedGeneration) {
|
||||
generations[key] = expectedGeneration + 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(store: NativeDashboardTokenStore) {
|
||||
synchronized(lockFor(store.coordinationKey)) {
|
||||
generations[store.coordinationKey] =
|
||||
(generations[store.coordinationKey] ?: 0L) + 1L
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenExchange(
|
||||
val code: String,
|
||||
@SerialName("code_verifier") val codeVerifier: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NativeTokenRefresh(
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
val provider: String,
|
||||
)
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.SocketTimeoutException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
private const val CALLBACK_PATH = "/callback"
|
||||
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
|
||||
private const val MAX_HEADER_BYTES = 16 * 1024
|
||||
private const val ACCEPT_POLL_MILLIS = 500
|
||||
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
|
||||
|
||||
internal enum class DashboardRedirectAuthMode {
|
||||
NativePkce,
|
||||
WebView,
|
||||
}
|
||||
|
||||
internal fun dashboardRedirectAuthMode(authFlows: List<String>): DashboardRedirectAuthMode =
|
||||
if ("native_pkce" in authFlows) {
|
||||
DashboardRedirectAuthMode.NativePkce
|
||||
} else {
|
||||
DashboardRedirectAuthMode.WebView
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns one native dashboard sign-in attempt.
|
||||
*
|
||||
* The listener and PKCE authorization are both local to [signIn], so leaving
|
||||
* the screen, cancellation, timeout, or callback completion closes the port
|
||||
* and discards verifier/state. Nothing secret enters Compose or saved state.
|
||||
*/
|
||||
class NativeDashboardSignInCoordinator(
|
||||
private val authClient: NativeDashboardAuthClient,
|
||||
private val timeoutMillis: Long = DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS,
|
||||
private val serverSocketFactory: () -> ServerSocket = ::ServerSocket,
|
||||
) {
|
||||
suspend fun signIn(
|
||||
provider: String?,
|
||||
launchAuthorization: suspend (String) -> Unit,
|
||||
): NativeDashboardTokens =
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
withTimeout(timeoutMillis) {
|
||||
serverSocketFactory().use { server ->
|
||||
server.reuseAddress = false
|
||||
server.bind(
|
||||
InetSocketAddress(
|
||||
InetAddress.getByName("127.0.0.1"),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
)
|
||||
server.soTimeout = ACCEPT_POLL_MILLIS
|
||||
check(server.inetAddress.hostAddress == "127.0.0.1") {
|
||||
"Native sign-in listener did not bind to IPv4 loopback"
|
||||
}
|
||||
|
||||
val redirectUri = "http://127.0.0.1:${server.localPort}$CALLBACK_PATH"
|
||||
val authorization = authClient.beginAuthorization(redirectUri, provider)
|
||||
val attemptContext = currentCoroutineContext()
|
||||
var completed = false
|
||||
try {
|
||||
launchAuthorization(authorization.authorizationUrl)
|
||||
awaitValidCallback(
|
||||
server = server,
|
||||
authorization = authorization,
|
||||
commitAllowed = { attemptContext.isActive },
|
||||
).also { completed = true }
|
||||
} finally {
|
||||
if (!completed) {
|
||||
authClient.cancelAuthorization(authorization)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
throw IOException("Dashboard sign-in timed out")
|
||||
}
|
||||
|
||||
private suspend fun awaitValidCallback(
|
||||
server: ServerSocket,
|
||||
authorization: NativeDashboardAuthorization,
|
||||
commitAllowed: () -> Boolean,
|
||||
): NativeDashboardTokens {
|
||||
while (true) {
|
||||
val callback = acceptCallback(server)
|
||||
val tokens = callback.use { socket ->
|
||||
if (socket.inetAddress.hostAddress != "127.0.0.1") {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "403 Forbidden",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
val target = try {
|
||||
readCallbackTarget(
|
||||
input = socket.getInputStream(),
|
||||
expectedPort = server.localPort,
|
||||
)
|
||||
} catch (_: IOException) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
try {
|
||||
authClient.exchangeCallback(
|
||||
authorization,
|
||||
target,
|
||||
commitAllowed = commitAllowed,
|
||||
).also {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "200 OK",
|
||||
body = "Sign-in complete. You can return to Hermes Relay.",
|
||||
)
|
||||
}
|
||||
} catch (error: NativeDashboardCallbackException) {
|
||||
if (error.retryable) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "This sign-in callback was not accepted.",
|
||||
)
|
||||
return@use null
|
||||
}
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
writeResponse(
|
||||
socket,
|
||||
status = "400 Bad Request",
|
||||
body = "Sign-in could not be completed. Return to Hermes Relay and try again.",
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (tokens != null) return tokens
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun acceptCallback(server: ServerSocket): Socket {
|
||||
while (true) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
try {
|
||||
return server.accept().apply { soTimeout = 5_000 }
|
||||
} catch (_: SocketTimeoutException) {
|
||||
// Poll so coroutine cancellation closes the lifecycle-owned listener promptly.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readCallbackTarget(input: InputStream, expectedPort: Int): String {
|
||||
val requestLine = readAsciiLine(input, MAX_REQUEST_LINE_BYTES)
|
||||
?: throw IOException("Native sign-in callback was empty")
|
||||
val requestParts = requestLine.split(' ')
|
||||
if (requestParts.size != 3 || requestParts[0] != "GET" ||
|
||||
!requestParts[1].startsWith("/") ||
|
||||
!requestParts[2].startsWith("HTTP/1.")
|
||||
) {
|
||||
throw IOException("Native sign-in callback request was malformed")
|
||||
}
|
||||
|
||||
var headerBytes = 0
|
||||
var host: String? = null
|
||||
while (true) {
|
||||
val line = readAsciiLine(input, MAX_HEADER_BYTES - headerBytes)
|
||||
?: throw IOException("Native sign-in callback headers were incomplete")
|
||||
headerBytes += line.length + 2
|
||||
if (line.isEmpty()) break
|
||||
if (line.startsWith("Host:", ignoreCase = true)) {
|
||||
host = line.substringAfter(':').trim()
|
||||
}
|
||||
if (headerBytes >= MAX_HEADER_BYTES) {
|
||||
throw IOException("Native sign-in callback headers were too large")
|
||||
}
|
||||
}
|
||||
if (host != "127.0.0.1:$expectedPort") {
|
||||
throw IOException("Native sign-in callback host was not accepted")
|
||||
}
|
||||
return requestParts[1]
|
||||
}
|
||||
|
||||
private fun readAsciiLine(input: InputStream, limit: Int): String? {
|
||||
if (limit <= 0) throw IOException("Native sign-in callback was too large")
|
||||
val bytes = ArrayList<Byte>(minOf(limit, 128))
|
||||
var previous = -1
|
||||
while (bytes.size < limit) {
|
||||
val current = input.read()
|
||||
if (current == -1) return if (bytes.isEmpty()) null else throw IOException(
|
||||
"Native sign-in callback ended unexpectedly",
|
||||
)
|
||||
if (previous == '\r'.code && current == '\n'.code) {
|
||||
bytes.removeAt(bytes.lastIndex)
|
||||
return bytes.toByteArray().toString(Charsets.US_ASCII)
|
||||
}
|
||||
bytes += current.toByte()
|
||||
previous = current
|
||||
}
|
||||
throw IOException("Native sign-in callback line was too large")
|
||||
}
|
||||
|
||||
private fun writeResponse(socket: Socket, status: String, body: String) {
|
||||
val html = """
|
||||
<!doctype html>
|
||||
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body><p>${escapeHtml(body)}</p></body></html>
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8)
|
||||
val headers = buildString {
|
||||
append("HTTP/1.1 ").append(status).append("\r\n")
|
||||
append("Content-Type: text/html; charset=utf-8\r\n")
|
||||
append("Content-Length: ").append(html.size).append("\r\n")
|
||||
append("Cache-Control: no-store\r\n")
|
||||
append("Connection: close\r\n\r\n")
|
||||
}.toByteArray(Charsets.US_ASCII)
|
||||
runCatching {
|
||||
socket.getOutputStream().apply {
|
||||
write(headers)
|
||||
write(html)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeHtml(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
/**
|
||||
* Parsed form of upstream's persisted user-image directives.
|
||||
*
|
||||
* Only canonical, full-line `@image:<absolute-path>` values are recognized.
|
||||
* Unknown or malformed directives stay visible as text. Valid directives are
|
||||
* removed from the bubble so a host-local path is never exposed in the UI.
|
||||
*/
|
||||
internal data class PersistedImageReferences(
|
||||
val cleanedText: String,
|
||||
val paths: List<String>,
|
||||
)
|
||||
|
||||
internal object PersistedImageReferenceParser {
|
||||
private const val MAX_INPUT_CHARS = 256 * 1024
|
||||
private const val MAX_PATH_CHARS = 2_048
|
||||
private const val MAX_ATTACHMENTS = 8
|
||||
|
||||
private val imageExtensions = setOf(
|
||||
"avif",
|
||||
"bmp",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jpeg",
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
)
|
||||
|
||||
fun parse(content: String): PersistedImageReferences {
|
||||
if (content.isEmpty() || content.length > MAX_INPUT_CHARS || "@image:" !in content) {
|
||||
return PersistedImageReferences(content, emptyList())
|
||||
}
|
||||
|
||||
val keptLines = ArrayList<String>()
|
||||
val paths = ArrayList<String>()
|
||||
|
||||
for (line in content.lines()) {
|
||||
val path = parseDirectiveLine(line)
|
||||
if (path == null) {
|
||||
keptLines += line
|
||||
} else if (paths.size < MAX_ATTACHMENTS) {
|
||||
paths += path
|
||||
}
|
||||
// Recognized refs beyond the attachment cap are still removed:
|
||||
// exposing a server-local path is worse than omitting an excessive
|
||||
// attachment from a deliberately bounded gallery.
|
||||
}
|
||||
|
||||
return PersistedImageReferences(
|
||||
cleanedText = keptLines.joinToString("\n").trim(),
|
||||
paths = paths,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseDirectiveLine(line: String): String? {
|
||||
if (!line.startsWith("@image:")) return null
|
||||
val rawValue = line.removePrefix("@image:")
|
||||
if (rawValue.isEmpty() || rawValue.length > MAX_PATH_CHARS) return null
|
||||
|
||||
val path = unwrapCanonicalValue(rawValue) ?: return null
|
||||
if (path.isBlank() || path.any { it == '\u0000' || it == '\r' || it == '\n' }) return null
|
||||
if (!isAbsolutePath(path) || !hasImageExtension(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
private fun unwrapCanonicalValue(value: String): String? {
|
||||
val first = value.first()
|
||||
if (first !in charArrayOf('`', '"', '\'')) {
|
||||
return value.takeIf { candidate -> candidate.none { it.isWhitespace() } }
|
||||
}
|
||||
if (value.length < 3 || value.last() != first) return null
|
||||
val inner = value.substring(1, value.lastIndex)
|
||||
return inner.takeIf { first !in it }
|
||||
}
|
||||
|
||||
private fun isAbsolutePath(path: String): Boolean =
|
||||
path.startsWith("/") ||
|
||||
(
|
||||
path.length >= 3 &&
|
||||
path[0].isLetter() &&
|
||||
path[1] == ':' &&
|
||||
(path[2] == '\\' || path[2] == '/')
|
||||
)
|
||||
|
||||
private fun hasImageExtension(path: String): Boolean {
|
||||
val fileName = path.substringAfterLast('/').substringAfterLast('\\')
|
||||
val extension = fileName.substringAfterLast('.', missingDelimiterValue = "").lowercase()
|
||||
return extension in imageExtensions
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -41,15 +41,15 @@ import java.util.concurrent.TimeUnit
|
||||
* These routes live on `hermes_cli/web_server.py` (:9119 by convention), NOT
|
||||
* on the API server (:8642) — current upstream api_server advertises
|
||||
* `audio_api: false` and registers no audio routes. Auth is the dashboard
|
||||
* cookie session (gated_auth_middleware), so [okHttpClient] must carry the
|
||||
* same per-connection cookie jar the Manage tab signs in with; an API bearer
|
||||
* header is meaningless on this surface. Revisit when upstream PR #8199
|
||||
* dashboard session (gated_auth_middleware), so [dashboardHttpClientProvider]
|
||||
* must carry the same exact-origin cookie or native bearer session used by
|
||||
* Manage. The API-server bearer is unrelated to this surface. Revisit when upstream PR #8199
|
||||
* lands the `/v1/audio` routes on the API server (docs/upstream-contributions.md section 6).
|
||||
* (No glob spellings in block comments — Kotlin block comments nest.)
|
||||
*/
|
||||
class StandardHermesVoiceClient(
|
||||
private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val dashboardHttpClientProvider: (String) -> OkHttpClient,
|
||||
private val dashboardUrlProvider: () -> String?,
|
||||
// Active chat profile name (null = default/launch). Sent DEFENSIVELY on
|
||||
// /api/audio/speak: upstream `TTSSpeakRequest` is text-only and Pydantic
|
||||
@@ -66,12 +66,10 @@ class StandardHermesVoiceClient(
|
||||
) : VoiceAudioClient {
|
||||
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
|
||||
|
||||
private val callClient: OkHttpClient =
|
||||
standardHermesDashboardAudioClient(okHttpClient)
|
||||
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
if (!audioFile.exists() || audioFile.length() == 0L) {
|
||||
return@withContext Result.failure(IOException("Audio file missing or empty: ${audioFile.name}"))
|
||||
}
|
||||
@@ -102,7 +100,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio transcribe").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio transcribe", callClient).mapCatching { root ->
|
||||
val transcript = root.stringField("transcript")
|
||||
?: root.stringField("text")
|
||||
?: root.stringField("message")
|
||||
@@ -116,6 +114,7 @@ class StandardHermesVoiceClient(
|
||||
override suspend fun synthesize(text: String): Result<File> = withContext(Dispatchers.IO) {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: return@withContext Result.failure(IllegalStateException("Hermes dashboard URL not configured"))
|
||||
val callClient = callClient(baseUrl)
|
||||
val cleanText = text.trim()
|
||||
if (cleanText.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Cannot synthesize blank text"))
|
||||
@@ -138,7 +137,7 @@ class StandardHermesVoiceClient(
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
executeJson(request, "Hermes audio speak").mapCatching { root ->
|
||||
executeJson(request, "Hermes audio speak", callClient).mapCatching { root ->
|
||||
val dataUrl = root.stringField("data_url") ?: root.stringField("dataUrl")
|
||||
if (dataUrl.isNullOrBlank()) {
|
||||
throw IOException("Hermes audio speak returned no audio")
|
||||
@@ -162,13 +161,14 @@ class StandardHermesVoiceClient(
|
||||
try {
|
||||
val baseUrl = dashboardBaseUrl()
|
||||
?: throw IllegalStateException("Hermes dashboard URL not configured")
|
||||
val callClient = callClient(baseUrl)
|
||||
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
|
||||
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
|
||||
val ticketRequest = Request.Builder()
|
||||
.url(ticketUrl)
|
||||
.post(ByteArray(0).toRequestBody(null))
|
||||
.build()
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
|
||||
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket", callClient)
|
||||
.getOrThrow()
|
||||
.stringField("ticket")
|
||||
?: throw IOException("Dashboard websocket ticket response missing ticket")
|
||||
@@ -195,7 +195,14 @@ class StandardHermesVoiceClient(
|
||||
private fun dashboardBaseUrl(): String? =
|
||||
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun executeJson(request: Request, operation: String): Result<JsonObject> {
|
||||
private fun callClient(baseUrl: String): OkHttpClient =
|
||||
standardHermesDashboardAudioClient(dashboardHttpClientProvider(baseUrl))
|
||||
|
||||
private fun executeJson(
|
||||
request: Request,
|
||||
operation: String,
|
||||
callClient: OkHttpClient,
|
||||
): Result<JsonObject> {
|
||||
return try {
|
||||
callClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
|
||||
+5
-2
@@ -163,7 +163,8 @@ data class SessionItem(
|
||||
@SerialName("message_count") val messageCount: Int? = null,
|
||||
@SerialName("tool_call_count") val toolCallCount: Int? = null,
|
||||
@SerialName("input_tokens") val inputTokens: Int? = null,
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
|
||||
) {
|
||||
val resolvedLastActivity: Double?
|
||||
get() = lastActive ?: lastActivity ?: lastActivityAt ?: updatedAt
|
||||
@@ -391,7 +392,9 @@ data class HermesSseEvent(
|
||||
@SerialName("thinking_delta") val thinkingDelta: String? = null,
|
||||
val text: String? = null, // /v1/runs reasoning.available text
|
||||
// Usage/token fields (on assistant.completed / run.completed)
|
||||
val usage: UsageInfo? = null
|
||||
val usage: UsageInfo? = null,
|
||||
// Native session routing proof on run.started and terminal events.
|
||||
val runtime: JsonObject? = null,
|
||||
) {
|
||||
/** Resolve the event type from whichever field is populated. */
|
||||
val resolvedType: String?
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
|
||||
/**
|
||||
* Action-required notifications for Gateway turns blocked on user input.
|
||||
*
|
||||
* Notification copy is deliberately generic. Approval commands, clarification
|
||||
* questions, secret prompts, environment-variable names, and password requests
|
||||
* stay inside the authenticated chat surface and never appear on the lock
|
||||
* screen. A stable tag derived from the durable session and request identity
|
||||
* makes replay/reconnect delivery replace the existing notification.
|
||||
*/
|
||||
object InteractionRequestNotifier {
|
||||
|
||||
private const val TAG = "InteractionNotifier"
|
||||
internal const val CHANNEL_ID = "chat_interactions"
|
||||
private const val CHANNEL_NAME = "Hermes needs input"
|
||||
private const val GROUP_KEY = "gateway-interactions"
|
||||
internal const val NOTIFICATION_ID = 3823
|
||||
internal const val DEFAULT_PROFILE_ROUTE_VALUE = "__server_default__"
|
||||
|
||||
internal fun shouldPost(
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
hasPermission: Boolean,
|
||||
): Boolean = alertsEnabled && !appForeground && hasPermission
|
||||
|
||||
internal fun requestKey(sessionId: String, ask: GatewayAsk, profile: String? = null): String {
|
||||
val requestIdentity = ask.requestId?.takeIf { it.isNotBlank() } ?: "session"
|
||||
return "${profile ?: DEFAULT_PROFILE_ROUTE_VALUE}:$sessionId:${ask.kind.name}:$requestIdentity"
|
||||
}
|
||||
|
||||
internal fun notificationTag(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String = "gateway-interaction:${requestKey(sessionId, ask, profile)}"
|
||||
|
||||
internal fun chatRoute(sessionId: String, profile: String? = null): String =
|
||||
"chat?sessionId=${Uri.encode(sessionId)}&profile=${Uri.encode(profile ?: DEFAULT_PROFILE_ROUTE_VALUE)}"
|
||||
|
||||
internal fun safeTitle(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Hermes needs approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Hermes has a question"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Hermes needs sensitive input"
|
||||
}
|
||||
|
||||
internal fun safeBody(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Open Hermes to review the requested action."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open Hermes to answer and continue this turn."
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Open Hermes to respond securely."
|
||||
}
|
||||
|
||||
internal fun safeExpandedBody(
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
): String {
|
||||
val action = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL ->
|
||||
"Review the requested action. Nothing is approved from the notification."
|
||||
GatewayAsk.Kind.CLARIFY -> "Open this conversation to answer Hermes' question."
|
||||
GatewayAsk.Kind.SUDO -> "Open this conversation to respond securely or deny."
|
||||
GatewayAsk.Kind.SECRET -> "Open this conversation to respond securely or skip."
|
||||
}
|
||||
val profileLabel = profile?.takeIf { it.isNotBlank() } ?: "Server default"
|
||||
val sessionLabel = sessionId.takeLast(12)
|
||||
return "$action\nProfile: $profileLabel\nSession: …$sessionLabel"
|
||||
}
|
||||
|
||||
internal fun actionLabel(ask: GatewayAsk): String = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> "Review approval"
|
||||
GatewayAsk.Kind.CLARIFY -> "Answer"
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
-> "Respond securely"
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notify(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
ask: GatewayAsk,
|
||||
profile: String? = null,
|
||||
alertsEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
): Boolean {
|
||||
ensureChannel(context)
|
||||
if (!shouldPost(alertsEnabled, appForeground, hasPostNotificationsPermission(context))) {
|
||||
return false
|
||||
}
|
||||
|
||||
val tag = notificationTag(sessionId, ask, profile)
|
||||
val requestKey = requestKey(sessionId, ask, profile)
|
||||
val requestCode = requestKey.hashCode()
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
data = Uri.Builder()
|
||||
.scheme("hermes-relay")
|
||||
.authority("interaction")
|
||||
.appendPath(requestKey)
|
||||
.build()
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, chatRoute(sessionId, profile))
|
||||
}
|
||||
val tapPending = PendingIntent.getActivity(
|
||||
context,
|
||||
requestCode,
|
||||
tapIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val title = safeTitle(ask)
|
||||
val body = safeBody(ask)
|
||||
val expandedBody = safeExpandedBody(sessionId, ask, profile)
|
||||
val publicVersion = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes needs your input")
|
||||
.setContentText("Open Hermes to continue.")
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedBody))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(false)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setPublicVersion(publicVersion)
|
||||
.setGroup(GROUP_KEY)
|
||||
.addAction(0, actionLabel(ask), tapPending)
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
NotificationManagerCompat.from(context).notify(tag, NOTIFICATION_ID, notification)
|
||||
true
|
||||
}.onFailure {
|
||||
Log.w(TAG, "notify failed", it)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, sessionId: String, ask: GatewayAsk, profile: String? = null) {
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(notificationTag(sessionId, ask, profile), NOTIFICATION_ID)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancel failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only this feature's notifications. Android keeps notifications
|
||||
* across process death, so the active-notification scan is also used when
|
||||
* MainActivity returns without an in-memory request registry.
|
||||
*/
|
||||
fun cancelAll(context: Context) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
runCatching {
|
||||
manager.activeNotifications
|
||||
.filter { it.notification.channelId == CHANNEL_ID }
|
||||
.forEach { manager.cancel(it.tag, it.id) }
|
||||
}.onFailure {
|
||||
Log.w(TAG, "cancelAll failed", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Alerts when a Hermes turn is waiting for your response."
|
||||
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
setShowBadge(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,6 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
@@ -274,13 +273,29 @@ sealed class Screen(
|
||||
// NavHost, and the NavigationBarItem click must navigate via [route]()
|
||||
// so no unresolved `{openAgentSheet}` leaks into the destination.
|
||||
data object Chat : Screen(
|
||||
"chat?openAgentSheet={openAgentSheet}",
|
||||
"chat?openAgentSheet={openAgentSheet}&sessionId={sessionId}&profile={profile}",
|
||||
"Chat",
|
||||
Icons.AutoMirrored.Filled.Chat,
|
||||
) {
|
||||
const val ARG_OPEN_AGENT_SHEET: String = "openAgentSheet"
|
||||
fun route(openAgentSheet: Boolean = false): String =
|
||||
if (openAgentSheet) "chat?openAgentSheet=true" else "chat"
|
||||
const val ARG_SESSION_ID: String = "sessionId"
|
||||
const val ARG_PROFILE: String = "profile"
|
||||
fun route(
|
||||
openAgentSheet: Boolean = false,
|
||||
sessionId: String? = null,
|
||||
profile: String? = null,
|
||||
): String {
|
||||
val params = buildList {
|
||||
if (openAgentSheet) add("$ARG_OPEN_AGENT_SHEET=true")
|
||||
sessionId?.takeIf { it.isNotBlank() }?.let {
|
||||
add("$ARG_SESSION_ID=${android.net.Uri.encode(it)}")
|
||||
}
|
||||
profile?.takeIf { it.isNotBlank() }?.let {
|
||||
add("$ARG_PROFILE=${android.net.Uri.encode(it)}")
|
||||
}
|
||||
}
|
||||
return if (params.isEmpty()) "chat" else "chat?${params.joinToString("&")}"
|
||||
}
|
||||
}
|
||||
data object Terminal : Screen("terminal", "Terminal", Icons.Filled.Code)
|
||||
data object Bridge : Screen("bridge", "Bridge", Icons.Filled.PhoneAndroid)
|
||||
@@ -583,15 +598,9 @@ fun RelayApp() {
|
||||
val standardVoiceClient = remember {
|
||||
StandardHermesVoiceClient(
|
||||
context = mediaContext,
|
||||
okHttpClient = okhttp3.OkHttpClient.Builder()
|
||||
.cookieJar(
|
||||
DynamicDashboardCookieJar {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
},
|
||||
)
|
||||
.readTimeout(2, java.util.concurrent.TimeUnit.MINUTES)
|
||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build(),
|
||||
dashboardHttpClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardHttpClientForActive(dashboardUrl)
|
||||
},
|
||||
dashboardUrlProvider = { connectionViewModel.activeDashboardUrl() },
|
||||
// Live read (null for the default profile) — sent defensively on
|
||||
// /api/audio/speak; upstream ignores it, so standard voice stays the
|
||||
@@ -1460,10 +1469,10 @@ fun RelayApp() {
|
||||
// The VM's cached per-connection store — the prewarm must NOT
|
||||
// construct its own (each instance lazily pays a multi-second
|
||||
// Keystore keyset build under a process-global Tink lock).
|
||||
val cookieStore = connectionViewModel.activeDashboardCookieStore()
|
||||
?: return@LaunchedEffect
|
||||
prewarmDashboardManage(
|
||||
cookieStore = cookieStore,
|
||||
clientFactory = {
|
||||
connectionViewModel.dashboardClientForActive(effectiveDashboardUrl)
|
||||
},
|
||||
connectionId = connection.id,
|
||||
dashboardUrl = effectiveDashboardUrl,
|
||||
effectiveProfileName = effectiveManageProfile,
|
||||
@@ -1825,6 +1834,16 @@ fun RelayApp() {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
navArgument(Screen.Chat.ARG_SESSION_ID) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument(Screen.Chat.ARG_PROFILE) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
// Responsive bubble width based on screen width. The "Blend"
|
||||
@@ -1849,6 +1868,48 @@ fun RelayApp() {
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
val requestedSessionId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_SESSION_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProfileRoute = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROFILE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
LaunchedEffect(
|
||||
requestedSessionId,
|
||||
requestedProfileRoute,
|
||||
profileSelectionSettled,
|
||||
effectiveSessionProfileName,
|
||||
agentProfiles,
|
||||
) {
|
||||
val sessionId = requestedSessionId ?: return@LaunchedEffect
|
||||
if (requestedProfileRoute != null) {
|
||||
val targetProfile = requestedProfileRoute.takeUnless {
|
||||
it == com.hermesandroid.relay.notifications
|
||||
.InteractionRequestNotifier.DEFAULT_PROFILE_ROUTE_VALUE
|
||||
}
|
||||
if (!profileSelectionSettled) return@LaunchedEffect
|
||||
if (effectiveSessionProfileName != targetProfile) {
|
||||
val selection = targetProfile?.let { name ->
|
||||
agentProfiles.firstOrNull { it.name == name }
|
||||
}
|
||||
if (targetProfile == null || selection != null) {
|
||||
connectionViewModel.selectProfile(selection)
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
chatViewModel.switchProfileContext(
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnectionId,
|
||||
profileName = targetProfile,
|
||||
),
|
||||
sessionId = sessionId,
|
||||
)
|
||||
} else if (chatViewModel.currentSessionId.value != sessionId) {
|
||||
chatViewModel.switchSession(sessionId)
|
||||
}
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
|
||||
}
|
||||
|
||||
val screenChatLabel = stringResource(R.string.screen_chat_label)
|
||||
|
||||
@@ -2233,8 +2294,8 @@ fun RelayApp() {
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
dashboardUrl = voiceDashboardUrl,
|
||||
dashboardCookieStoreProvider = {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
dashboardClientProvider = { dashboardUrl ->
|
||||
connectionViewModel.dashboardClientForActive(dashboardUrl)
|
||||
},
|
||||
onOpenManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
@@ -2572,6 +2633,17 @@ fun RelayApp() {
|
||||
onNavigateToRealtimeVoice = {
|
||||
navController.navigate(Screen.RealtimeVoiceTest.route)
|
||||
},
|
||||
onNavigateToImageGenerationLab = {
|
||||
terminalAppContext.startActivity(
|
||||
android.content.Intent().apply {
|
||||
setClassName(
|
||||
terminalAppContext,
|
||||
"com.hermesandroid.relay.ui.screens.ImageGenerationDesignQaActivity",
|
||||
)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.RealtimeVoiceTest.route) {
|
||||
|
||||
+5
-10
@@ -268,7 +268,6 @@ fun ActiveCardRelayStatusSection(
|
||||
@Composable
|
||||
fun ActiveCardFeaturesSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
@@ -349,21 +348,19 @@ fun ActiveCardFeaturesSection(
|
||||
}
|
||||
|
||||
val relayValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
!relayConfigured -> stringResource(R.string.active_section_optional)
|
||||
relayReady -> stringResource(R.string.active_section_ready)
|
||||
relayUiState == RelayUiState.Stale -> stringResource(R.string.active_section_reconnect)
|
||||
else -> stringResource(R.string.active_section_configured)
|
||||
}
|
||||
val relayTone = when {
|
||||
!relayEnabled || !relayConfigured -> CapabilityTone.Neutral
|
||||
!relayConfigured -> CapabilityTone.Neutral
|
||||
relayReady -> CapabilityTone.Good
|
||||
relayUiState == RelayUiState.Stale -> CapabilityTone.Warning
|
||||
else -> CapabilityTone.Info
|
||||
}
|
||||
|
||||
val terminalValue = when {
|
||||
!relayEnabled -> stringResource(R.string.active_section_disabled)
|
||||
authState is AuthState.Paired -> stringResource(R.string.active_section_ready)
|
||||
relayConfigured -> stringResource(R.string.active_section_pair_relay)
|
||||
else -> stringResource(R.string.active_section_optional)
|
||||
@@ -614,7 +611,6 @@ private fun CapabilityRow(
|
||||
@Composable
|
||||
fun ActiveCardAdvancedSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") isDarkTheme: Boolean,
|
||||
onPairRelay: () -> Unit,
|
||||
onInsecureAckRequested: () -> Unit,
|
||||
@@ -654,7 +650,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled, showApi = true, showRelay = false)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = true, showRelay = false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -731,7 +727,7 @@ fun ActiveCardAdvancedSection(
|
||||
TextButton(onClick = { relayEditorOpen = !relayEditorOpen }) {
|
||||
Text(stringResource(R.string.active_section_other_relay_methods))
|
||||
}
|
||||
if (relayEnabled && relayEditorOpen) {
|
||||
if (relayEditorOpen) {
|
||||
Surface(
|
||||
color = Color.Transparent,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -746,7 +742,7 @@ fun ActiveCardAdvancedSection(
|
||||
Text(stringResource(R.string.active_section_done))
|
||||
}
|
||||
}
|
||||
ManualUrlSubsection(connectionViewModel, relayEnabled = true, showApi = false, showRelay = true)
|
||||
ManualUrlSubsection(connectionViewModel, showApi = false, showRelay = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,7 +794,6 @@ fun ActiveCardAdvancedSection(
|
||||
@Composable
|
||||
private fun ManualUrlSubsection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
relayEnabled: Boolean,
|
||||
showApi: Boolean,
|
||||
showRelay: Boolean,
|
||||
) {
|
||||
@@ -960,7 +955,7 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
}
|
||||
|
||||
if (relayEnabled && showRelay) {
|
||||
if (showRelay) {
|
||||
HorizontalDivider()
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
|
||||
/**
|
||||
* Stable, presentation-only summary for a message's attachment group.
|
||||
*
|
||||
* Attachment bytes, fetch state, retry callbacks, and persistence remain owned
|
||||
* by the existing attachment pipeline; this helper only chooses the compact
|
||||
* label shown while that pipeline is folded away.
|
||||
*/
|
||||
internal data class AttachmentGroupSummary(
|
||||
val count: Int,
|
||||
val firstName: String?,
|
||||
val firstType: AttachmentRenderMode,
|
||||
val remainingCount: Int,
|
||||
)
|
||||
|
||||
internal fun attachmentGroupSummary(attachments: List<Attachment>): AttachmentGroupSummary? {
|
||||
val first = attachments.firstOrNull() ?: return null
|
||||
return AttachmentGroupSummary(
|
||||
count = attachments.size,
|
||||
firstName = first.fileName?.trim()?.takeIf(String::isNotEmpty),
|
||||
firstType = first.renderMode,
|
||||
remainingCount = (attachments.size - 1).coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack-style disclosure for all attachments belonging to one message.
|
||||
*
|
||||
* The fold state is saveable and keyed by the message's stable Compose
|
||||
* identity, so attachment lifecycle updates do not unexpectedly reopen a
|
||||
* group the user collapsed. The compact header always remains available,
|
||||
* making preview, retry, download, and file actions recoverable with one tap.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CollapsibleAttachmentGroup(
|
||||
messageKey: String,
|
||||
attachments: List<Attachment>,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val summary = attachmentGroupSummary(attachments) ?: return
|
||||
var expanded by rememberSaveable(messageKey) { mutableStateOf(true) }
|
||||
val stateLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_expanded else R.string.attachment_group_collapsed,
|
||||
)
|
||||
val actionLabel = stringResource(
|
||||
if (expanded) R.string.attachment_group_collapse else R.string.attachment_group_expand,
|
||||
)
|
||||
val typeLabel = stringResource(summary.firstType.labelResource())
|
||||
val detail = when {
|
||||
summary.firstName != null && summary.remainingCount > 0 ->
|
||||
stringResource(
|
||||
R.string.attachment_group_named_more,
|
||||
summary.firstName,
|
||||
typeLabel,
|
||||
summary.remainingCount,
|
||||
)
|
||||
summary.firstName != null ->
|
||||
stringResource(R.string.attachment_group_named, summary.firstName, typeLabel)
|
||||
summary.remainingCount > 0 ->
|
||||
stringResource(R.string.attachment_group_typed_more, typeLabel, summary.remainingCount)
|
||||
else -> typeLabel
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { expanded = !expanded },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-toggle-$messageKey")
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = actionLabel
|
||||
role = Role.Button
|
||||
stateDescription = stateLabel
|
||||
},
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
R.plurals.attachment_group_count,
|
||||
summary.count,
|
||||
summary.count,
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("attachment-group-content-$messageKey"),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun AttachmentRenderMode.labelResource(): Int = when (this) {
|
||||
AttachmentRenderMode.IMAGE -> R.string.attachment_type_image
|
||||
AttachmentRenderMode.VIDEO -> R.string.attachment_type_video
|
||||
AttachmentRenderMode.AUDIO -> R.string.attachment_type_audio
|
||||
AttachmentRenderMode.PDF -> R.string.attachment_type_pdf
|
||||
AttachmentRenderMode.TEXT -> R.string.attachment_type_text
|
||||
AttachmentRenderMode.GENERIC -> R.string.attachment_type_file
|
||||
}
|
||||
+1265
-16
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,6 @@ import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.network.shared.HermesLanDiscovery
|
||||
@@ -197,8 +196,6 @@ fun ConnectionWizard(
|
||||
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
val pairingCode by connectionViewModel.pairingCode.collectAsState()
|
||||
val currentApiUrl by connectionViewModel.apiServerUrl.collectAsState()
|
||||
val currentRelayUrl by connectionViewModel.relayUrl.collectAsState()
|
||||
@@ -621,7 +618,6 @@ fun ConnectionWizard(
|
||||
WizardStep.RelayChoice -> RelayChoiceStep(
|
||||
connectionLabel = activeConnection?.label.orEmpty(),
|
||||
dashboardUrl = currentDashboardUrl,
|
||||
relayEnabled = relayEnabled,
|
||||
onPickScan = {
|
||||
chosenMethod = PairMethod.Scan
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
@@ -638,7 +634,6 @@ fun ConnectionWizard(
|
||||
)
|
||||
|
||||
WizardStep.Method -> MethodStep(
|
||||
relayEnabled = relayEnabled,
|
||||
onPickStandard = {
|
||||
chosenMethod = PairMethod.Standard
|
||||
standardError = null
|
||||
@@ -1701,7 +1696,6 @@ private fun FoundCapabilityLine(label: String, value: String, ready: Boolean) {
|
||||
private fun RelayChoiceStep(
|
||||
connectionLabel: String,
|
||||
dashboardUrl: String,
|
||||
relayEnabled: Boolean,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
onPickShowCode: () -> Unit,
|
||||
@@ -1769,14 +1763,12 @@ private fun RelayChoiceStep(
|
||||
subtitle = stringResource(R.string.cw_method_pair_code_subtitle),
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { openExternalUrl(context, RelaySetupDocsUrl) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -1794,7 +1786,6 @@ private fun RelayChoiceStep(
|
||||
|
||||
@Composable
|
||||
private fun MethodStep(
|
||||
relayEnabled: Boolean,
|
||||
onPickStandard: () -> Unit,
|
||||
onPickScan: () -> Unit,
|
||||
onPickEnterCode: () -> Unit,
|
||||
@@ -1931,14 +1922,12 @@ private fun MethodStep(
|
||||
onClick = onPickEnterCode,
|
||||
)
|
||||
|
||||
if (relayEnabled) {
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
}
|
||||
MethodTile(
|
||||
icon = Icons.Filled.PhonelinkLock,
|
||||
title = stringResource(R.string.cw_method_show_code_title),
|
||||
subtitle = stringResource(R.string.cw_method_show_code_subtitle),
|
||||
onClick = onPickShowCode,
|
||||
)
|
||||
|
||||
if (onSkip != null) {
|
||||
TextButton(
|
||||
|
||||
+918
-11
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -139,6 +138,8 @@ fun MessageBubble(
|
||||
* happening.
|
||||
*/
|
||||
recoveringAnswer: Boolean = false,
|
||||
imageGenerationStylePreference: String = "rotate",
|
||||
imageGenerationRotationIndex: Int = 0,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -211,6 +212,23 @@ fun MessageBubble(
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val hasImageGenerationCall = remember(message.toolCalls) {
|
||||
message.toolCalls.any {
|
||||
it.name.trim().lowercase() == "image_generate"
|
||||
}
|
||||
}
|
||||
val imageGenerationStartMillis = remember(message.toolCalls) {
|
||||
imageGenerationStartedAt(message.toolCalls)
|
||||
}
|
||||
val imageGenerationVisualStyle = remember(
|
||||
imageGenerationStylePreference,
|
||||
imageGenerationRotationIndex,
|
||||
) {
|
||||
resolveImageGenerationVisualStyle(
|
||||
preference = imageGenerationStylePreference,
|
||||
rotationIndex = imageGenerationRotationIndex,
|
||||
)
|
||||
}
|
||||
|
||||
// Provide the sensitive-media blur mode to the attachment / inline-image
|
||||
// renderers below, sourced as locally as possible (here, not threaded
|
||||
@@ -297,6 +315,30 @@ fun MessageBubble(
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && showThinking) {
|
||||
message.moaReferences.forEach { reference ->
|
||||
ThinkingBlock(
|
||||
thinkingContent = if (reference.available) {
|
||||
reference.text
|
||||
} else {
|
||||
"Advisor unavailable."
|
||||
},
|
||||
isStreaming = false,
|
||||
headerText = buildString {
|
||||
append("Advisor ")
|
||||
append(reference.index)
|
||||
reference.count?.let { append("/").append(it) }
|
||||
append(" · ")
|
||||
append(reference.label)
|
||||
},
|
||||
accessibilityLabel = "Mixture of Agents advisor response",
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Message bubble.
|
||||
//
|
||||
// Action bubbles (voice/phone origin) wrap the existing Surface in
|
||||
@@ -485,44 +527,78 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Image generation owns the bubble's progress slot. It replaces
|
||||
// the generic first-token dots, remains mounted through the
|
||||
// tool-complete → MEDIA marker handoff, then crossfades into the
|
||||
// attachment renderer in this same Surface.
|
||||
Crossfade(
|
||||
targetState = showImageGeneration,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "imageGenerationToResult",
|
||||
) { generating ->
|
||||
if (generating) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationPlaceholder()
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
// Image generation owns the bubble's progress slot. Keep the
|
||||
// selected progress treatment mounted under the real result,
|
||||
// then reveal the same collapsible attachment surface without
|
||||
// rebuilding the surrounding message bubble.
|
||||
if (hasImageGenerationCall) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ImageGenerationResultTransition(
|
||||
generating = showImageGeneration,
|
||||
startedAtMillis = imageGenerationStartMillis,
|
||||
visualStyle = imageGenerationVisualStyle,
|
||||
) {
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (message.attachments.isNotEmpty()) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = message.uiKey,
|
||||
attachments = message.attachments,
|
||||
) {
|
||||
// Two or more loaded images collapse into one grid +
|
||||
// swipe-across gallery. Every other item stays on the
|
||||
// unified attachment path, retaining original indices.
|
||||
val attachmentItems = attachmentLayoutItems(message.attachments)
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator — only while awaiting the first token. Once
|
||||
|
||||
@@ -39,6 +39,8 @@ fun ThinkingBlock(
|
||||
modifier: Modifier = Modifier,
|
||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||
timestamp: Long? = null,
|
||||
headerText: String? = null,
|
||||
accessibilityLabel: String = "Thinking",
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
@@ -65,13 +67,13 @@ fun ThinkingBlock(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Psychology,
|
||||
contentDescription = "Thinking",
|
||||
contentDescription = accessibilityLabel,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = if (isStreaming) "Thinking..." else "Thought process",
|
||||
text = headerText ?: if (isStreaming) "Thinking..." else "Thought process",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -25,11 +29,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -59,6 +65,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -71,13 +78,42 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.permissions.AppPermissionStatusProbe
|
||||
import com.hermesandroid.relay.ui.components.ConnectionWizard
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Page identifiers for dynamic onboarding flow. */
|
||||
private enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect }
|
||||
/** Page identifiers for the standard-first onboarding flow. */
|
||||
internal enum class OnboardingPage { Welcome, Chat, Manage, Power, Connect, Permissions }
|
||||
|
||||
internal val standardOnboardingPages = listOf(
|
||||
OnboardingPage.Welcome,
|
||||
OnboardingPage.Chat,
|
||||
OnboardingPage.Manage,
|
||||
OnboardingPage.Power,
|
||||
OnboardingPage.Connect,
|
||||
OnboardingPage.Permissions,
|
||||
)
|
||||
|
||||
internal enum class OnboardingNotificationAction {
|
||||
RequestPermission,
|
||||
Finish,
|
||||
}
|
||||
|
||||
internal fun onboardingNotificationAction(
|
||||
sdkInt: Int,
|
||||
notificationsPermitted: Boolean,
|
||||
): OnboardingNotificationAction {
|
||||
return if (
|
||||
sdkInt >= Build.VERSION_CODES.TIRAMISU &&
|
||||
!notificationsPermitted
|
||||
) {
|
||||
OnboardingNotificationAction.RequestPermission
|
||||
} else {
|
||||
OnboardingNotificationAction.Finish
|
||||
}
|
||||
}
|
||||
|
||||
private val OnboardingAccent = Color(0xFF7B55F6)
|
||||
|
||||
@@ -125,21 +161,23 @@ fun OnboardingScreen(
|
||||
*/
|
||||
onTryDemo: () -> Unit = {},
|
||||
) {
|
||||
val pages = remember {
|
||||
buildList {
|
||||
add(OnboardingPage.Welcome)
|
||||
add(OnboardingPage.Chat)
|
||||
add(OnboardingPage.Manage)
|
||||
add(OnboardingPage.Power)
|
||||
add(OnboardingPage.Connect)
|
||||
}
|
||||
}
|
||||
val pages = standardOnboardingPages
|
||||
val pageCount = pages.size
|
||||
val lastPage = pageCount - 1
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { pageCount })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var showSkipConfirm by rememberSaveable { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
var notificationsPermitted by remember {
|
||||
mutableStateOf(AppPermissionStatusProbe.snapshot(context).notificationsPermitted)
|
||||
}
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
notificationsPermitted =
|
||||
granted || AppPermissionStatusProbe.snapshot(context).notificationsPermitted
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -174,7 +212,11 @@ fun OnboardingScreen(
|
||||
|
||||
// The selected welcome frame has no toolbar. Later information
|
||||
// pages keep quiet navigation without reserving space above it.
|
||||
if (currentPage in 1 until lastPage) {
|
||||
if (
|
||||
currentPageType == OnboardingPage.Chat ||
|
||||
currentPageType == OnboardingPage.Manage ||
|
||||
currentPageType == OnboardingPage.Power
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -201,7 +243,13 @@ fun OnboardingScreen(
|
||||
// Pager content
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.weight(1f),
|
||||
// Connect and permission setup advance only through their
|
||||
// explicit actions. This keeps the post-connect setup page
|
||||
// unreachable until the wizard reports a successful save.
|
||||
userScrollEnabled =
|
||||
currentPageType != OnboardingPage.Connect &&
|
||||
currentPageType != OnboardingPage.Permissions,
|
||||
) { pageIndex ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -216,18 +264,42 @@ fun OnboardingScreen(
|
||||
)
|
||||
OnboardingPage.Connect -> ConnectPage(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = onComplete,
|
||||
onComplete = {
|
||||
notificationsPermitted =
|
||||
AppPermissionStatusProbe.snapshot(context).notificationsPermitted
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(
|
||||
pages.indexOf(OnboardingPage.Permissions),
|
||||
)
|
||||
}
|
||||
},
|
||||
onManageSignIn = onManageSignIn,
|
||||
onSkip = { showSkipConfirm = true },
|
||||
onTryDemo = onTryDemo,
|
||||
)
|
||||
OnboardingPage.Permissions -> PermissionSetupPage(
|
||||
notificationAction = onboardingNotificationAction(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
notificationsPermitted = notificationsPermitted,
|
||||
),
|
||||
onEnableNotifications = {
|
||||
notificationPermissionLauncher.launch(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
},
|
||||
onReviewPermissions = onOpenPermissions,
|
||||
onFinish = onComplete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom navigation only on informational pages — the wizard
|
||||
// owns its own back/pair affordances.
|
||||
if (currentPageType != OnboardingPage.Connect) {
|
||||
if (
|
||||
currentPageType != OnboardingPage.Connect &&
|
||||
currentPageType != OnboardingPage.Permissions
|
||||
) {
|
||||
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -627,6 +699,80 @@ private fun PowerToolsPage(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PermissionSetupPage(
|
||||
notificationAction: OnboardingNotificationAction,
|
||||
onEnableNotifications: () -> Unit,
|
||||
onReviewPermissions: () -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
) {
|
||||
OnboardingPage(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.onboarding_finish_setup_title),
|
||||
description = stringResource(R.string.onboarding_finish_setup_description),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.CheckCircle,
|
||||
label = stringResource(R.string.perms_chat_and_manage),
|
||||
description = stringResource(R.string.onboarding_chat_manage_ready),
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Notifications,
|
||||
label = stringResource(R.string.onboarding_chat_alerts),
|
||||
description = if (
|
||||
notificationAction == OnboardingNotificationAction.Finish
|
||||
) {
|
||||
stringResource(R.string.onboarding_chat_alerts_ready)
|
||||
} else {
|
||||
stringResource(R.string.onboarding_chat_alerts_description)
|
||||
},
|
||||
)
|
||||
SetupPathSummary(
|
||||
icon = Icons.Filled.Tune,
|
||||
label = stringResource(R.string.onboarding_optional_features),
|
||||
description = stringResource(R.string.onboarding_optional_features_description),
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onReviewPermissions,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Security,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.onboarding_review_optional_permissions))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
if (notificationAction == OnboardingNotificationAction.RequestPermission) {
|
||||
GradientOnboardingButton(
|
||||
label = stringResource(R.string.onboarding_enable_chat_alerts),
|
||||
onClick = onEnableNotifications,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onFinish,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.onboarding_not_now))
|
||||
}
|
||||
} else {
|
||||
GradientOnboardingButton(
|
||||
label = stringResource(R.string.onboarding_finish),
|
||||
onClick = onFinish,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectPage(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
|
||||
@@ -206,10 +206,16 @@ fun AboutScreen(
|
||||
val remaining = 7 - versionTapCount
|
||||
when {
|
||||
remaining <= 0 -> {
|
||||
scope.launch { FeatureFlags.unlockDevOptions(context) }
|
||||
Toast.makeText(context, devUnlockedMsg, Toast.LENGTH_SHORT).show()
|
||||
versionTapCount = 0
|
||||
onUnlockDeveloperOptions()
|
||||
scope.launch {
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
devUnlockedMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onUnlockDeveloperOptions()
|
||||
}
|
||||
}
|
||||
remaining <= 3 -> {
|
||||
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
||||
|
||||
@@ -460,6 +460,7 @@ fun AppearanceSettingsScreen(
|
||||
) {
|
||||
val animEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -526,6 +527,48 @@ fun AppearanceSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_image_generation_style),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_image_generation_style_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val imageStyleOptions = listOf(
|
||||
"rotate" to stringResource(R.string.appearance_image_generation_rotate),
|
||||
"grid" to stringResource(R.string.appearance_image_generation_grid),
|
||||
"sphere" to stringResource(R.string.appearance_image_generation_sphere),
|
||||
"nodes" to stringResource(R.string.appearance_image_generation_nodes),
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
imageStyleOptions.forEach { (id, label) ->
|
||||
FilterChip(
|
||||
selected = imageGenerationStyle == id,
|
||||
onClick = { connectionViewModel.setImageGenerationStyle(id) },
|
||||
label = { Text(label) },
|
||||
leadingIcon = if (imageGenerationStyle == id) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -663,9 +663,24 @@ fun ChatScreen(
|
||||
// Animation settings
|
||||
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
val imageGenerationStyle by connectionViewModel.imageGenerationStyle.collectAsState()
|
||||
val thinkingIndicatorStyle by connectionViewModel.thinkingIndicatorStyle.collectAsState()
|
||||
val thinkingMatrixPattern by connectionViewModel.thinkingMatrixPattern.collectAsState()
|
||||
val thinkingMatrixColor by connectionViewModel.thinkingMatrixColor.collectAsState()
|
||||
val imageGenerationOrdinals = remember(messages) {
|
||||
var nextOrdinal = 0
|
||||
buildMap {
|
||||
messages.forEach { message ->
|
||||
val generationCount = message.toolCalls.count {
|
||||
it.name.trim().equals("image_generate", ignoreCase = true)
|
||||
}
|
||||
if (generationCount > 0) {
|
||||
put(message.uiKey, nextOrdinal + generationCount - 1)
|
||||
nextOrdinal += generationCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
|
||||
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
|
||||
// empty / new-chat view (no messages) — it teaches the long-press entry
|
||||
@@ -2284,6 +2299,9 @@ fun ChatScreen(
|
||||
isLastInGroup = isLastInGroup,
|
||||
retainStreamingLayout = retainLiveLayout,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
imageGenerationStylePreference = imageGenerationStyle,
|
||||
imageGenerationRotationIndex =
|
||||
imageGenerationOrdinals[message.uiKey] ?: 0,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
@@ -2836,6 +2854,18 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val providerModelIds = modelProviders.flatMap { it.models }.toSet()
|
||||
sseModelOptions.filter { it.id !in providerModelIds }.forEach { model ->
|
||||
add(
|
||||
ChatInputPickerOption(
|
||||
label = AgentDisplay.displayModelName(model.id) ?: model.id,
|
||||
value = model.id,
|
||||
group = "Routes",
|
||||
secondary = model.routeDetail,
|
||||
selected = selectedModelOverride == model.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
add(
|
||||
|
||||
@@ -443,7 +443,7 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Turn-complete notification toggle. First enable on
|
||||
// Background chat-alert toggle. First enable on
|
||||
// API 33+ runs the POST_NOTIFICATIONS request (the
|
||||
// BridgeScreen master-toggle precedent); if the user
|
||||
// denies, the notifier silently no-ops at post time.
|
||||
|
||||
@@ -54,7 +54,6 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
||||
@@ -110,9 +109,6 @@ fun ConnectionDetailScreen(
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
val connection = connections.firstOrNull { it.id == connectionId }
|
||||
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
||||
LaunchedEffect(connection == null) {
|
||||
@@ -275,7 +271,6 @@ fun ConnectionDetailScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
relayUiState = relayUiState,
|
||||
relayEnabled = relayEnabled,
|
||||
onReconnect = onReconnect,
|
||||
onRepair = { onRepair(connectionId) },
|
||||
onOpenApiInfo = { showApiInfoSheet = true },
|
||||
@@ -303,7 +298,6 @@ fun ConnectionDetailScreen(
|
||||
|
||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onPairRelay = { onRepair(connectionId) },
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
@@ -423,7 +417,6 @@ private fun ActiveOverview(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
connection: Connection,
|
||||
relayUiState: RelayUiState,
|
||||
relayEnabled: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
@@ -498,7 +491,6 @@ private fun ActiveOverview(
|
||||
|
||||
ActiveCardFeaturesSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
onOpenApiInfo = onOpenApiInfo,
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
|
||||
+84
-28
@@ -107,6 +107,7 @@ import com.hermesandroid.relay.network.upstream.McpOAuthFlowCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
@@ -464,15 +465,8 @@ fun DashboardManagementScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = cookieStoreFactory(),
|
||||
),
|
||||
)
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||
}
|
||||
|
||||
suspend fun loadDashboardSection(
|
||||
@@ -1548,6 +1542,41 @@ private fun ManageOverviewBody(
|
||||
)
|
||||
}
|
||||
}
|
||||
status?.componentHealth
|
||||
?.takeIf { it.supported }
|
||||
?.let { health ->
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
RelaySectionCaption(
|
||||
title = stringResource(R.string.dashboard_metric_dashboard),
|
||||
meta = health.overall?.replaceFirstChar(Char::uppercase)
|
||||
?: stringResource(R.string.conn_label_status),
|
||||
)
|
||||
dashboardComponentHealthLines(
|
||||
health = health,
|
||||
connectedLabel = stringResource(R.string.dashboard_component_connected),
|
||||
serverErrorsLabel = stringResource(R.string.dashboard_component_server_errors_5m),
|
||||
).forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val signInStatus = status
|
||||
if (signInStatus?.authRequired == true && authenticated != true) {
|
||||
item {
|
||||
@@ -1916,7 +1945,7 @@ private suspend fun fetchDashboardSectionStateWith(
|
||||
* start lands on an already-populated Manage tab.
|
||||
*/
|
||||
internal suspend fun prewarmDashboardManage(
|
||||
cookieStore: DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
effectiveProfileName: String? = null,
|
||||
@@ -1945,12 +1974,7 @@ internal suspend fun prewarmDashboardManage(
|
||||
// encrypted cookie store per section: 8 Keystore keyset builds, each
|
||||
// holding Tink's process-global lock for seconds on StrongBox devices,
|
||||
// which starved main-thread keystore users and froze the UI at startup.
|
||||
val client = withContext(Dispatchers.IO) {
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStore = cookieStore),
|
||||
)
|
||||
}
|
||||
val client = withContext(Dispatchers.IO) { clientFactory() }
|
||||
try {
|
||||
val preamble = try {
|
||||
fetchDashboardPreamble(client)
|
||||
@@ -2598,6 +2622,7 @@ private fun DashboardOAuthSignInDialog(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -2634,16 +2659,7 @@ private fun DashboardOAuthSignInDialog(
|
||||
statusText = context.getString(R.string.dashboard_oauth_verifying)
|
||||
scope.launch {
|
||||
try {
|
||||
val session = withDashboardClient(
|
||||
clientFactory = {
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = cookieStoreFactory(),
|
||||
),
|
||||
)
|
||||
},
|
||||
) { client ->
|
||||
val session = withDashboardClient(clientFactory = clientFactory) { client ->
|
||||
client.currentSession().getOrNull()
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
@@ -3466,6 +3482,7 @@ private fun CustomEndpointDialog(
|
||||
var discoverModels by remember(existing) {
|
||||
mutableStateOf(existing?.meta?.contains("discover=off") != true)
|
||||
}
|
||||
var validatedModels by remember(existing) { mutableStateOf(emptyList<String>()) }
|
||||
var busy by remember(existing) { mutableStateOf(false) }
|
||||
var message by remember(existing) { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -3474,6 +3491,7 @@ private fun CustomEndpointDialog(
|
||||
name = name.trim(),
|
||||
baseUrl = baseUrl.trim(),
|
||||
model = model.trim(),
|
||||
models = validatedModels,
|
||||
apiKey = apiKey.takeIf { it.isNotBlank() },
|
||||
contextLength = contextLength.toIntOrNull(),
|
||||
discoverModels = discoverModels,
|
||||
@@ -3488,8 +3506,24 @@ private fun CustomEndpointDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(name, { name = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_name)) }, enabled = !busy)
|
||||
OutlinedTextField(baseUrl, { baseUrl = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) }, enabled = !busy)
|
||||
OutlinedTextField(model, { model = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) }, enabled = !busy)
|
||||
OutlinedTextField(
|
||||
baseUrl,
|
||||
{
|
||||
baseUrl = it
|
||||
validatedModels = emptyList()
|
||||
},
|
||||
label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) },
|
||||
enabled = !busy,
|
||||
)
|
||||
OutlinedTextField(
|
||||
model,
|
||||
{
|
||||
model = it
|
||||
validatedModels = emptyList()
|
||||
},
|
||||
label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) },
|
||||
enabled = !busy,
|
||||
)
|
||||
OutlinedTextField(
|
||||
apiKey,
|
||||
{ apiKey = it },
|
||||
@@ -3518,6 +3552,11 @@ private fun CustomEndpointDialog(
|
||||
busy = false
|
||||
message = result.fold(
|
||||
onSuccess = { validation ->
|
||||
validatedModels = validation.models
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(256)
|
||||
validation.message.ifBlank {
|
||||
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
|
||||
}
|
||||
@@ -3837,6 +3876,23 @@ internal fun summarizeCustomEndpoints(root: JsonElement): List<DashboardSummaryI
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
internal fun dashboardComponentHealthLines(
|
||||
health: DashboardComponentHealthRollup,
|
||||
connectedLabel: String = "connected",
|
||||
serverErrorsLabel: String = "server errors / 5m",
|
||||
): List<String> = health.components.map { component ->
|
||||
buildList {
|
||||
add("${component.name}: ${component.status}")
|
||||
component.message?.takeIf(String::isNotBlank)?.let(::add)
|
||||
if (component.configured != null || component.connected != null) {
|
||||
add("${component.connected ?: 0}/${component.configured ?: 0} $connectedLabel")
|
||||
}
|
||||
component.unhandled5xxCount5m
|
||||
?.takeIf { it > 0 }
|
||||
?.let { add("$it $serverErrorsLabel") }
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun summarizeRoot(root: JsonElement): String {
|
||||
return when (root) {
|
||||
is JsonObject -> {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -50,9 +51,17 @@ import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardSignInCoordinator
|
||||
import com.hermesandroid.relay.network.upstream.dashboardRedirectAuthMode
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Connection-level Dashboard authentication flow. It is deliberately outside
|
||||
@@ -66,7 +75,8 @@ fun DashboardSignInScreen(
|
||||
onBack: () -> Unit,
|
||||
onAuthenticated: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val context = LocalContext.current
|
||||
val appContext = context.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
val activeConnection by connectionViewModel.activeConnection.collectAsState()
|
||||
val dashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
@@ -78,22 +88,22 @@ fun DashboardSignInScreen(
|
||||
var loading by remember(dashboardUrl, connectionId) { mutableStateOf(true) }
|
||||
var actionInFlight by remember { mutableStateOf(false) }
|
||||
var actionMessage by remember { mutableStateOf<String?>(null) }
|
||||
var actionIsError by remember { mutableStateOf(false) }
|
||||
var oauthProvider by remember { mutableStateOf<DashboardAuthProvider?>(null) }
|
||||
var redirectAuthMode by remember(dashboardUrl, connectionId) {
|
||||
mutableStateOf(DashboardRedirectAuthMode.WebView)
|
||||
}
|
||||
var nativeSignInJob by remember(dashboardUrl, connectionId) { mutableStateOf<Job?>(null) }
|
||||
var authenticationComplete by remember { mutableStateOf(false) }
|
||||
|
||||
val cookieStoreFactory = remember(context, connectionId) {
|
||||
val cookieStoreFactory = remember(appContext, connectionId) {
|
||||
{
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
?: EncryptedDashboardCookieStore(context, connectionId)
|
||||
?: EncryptedDashboardCookieStore(appContext, connectionId)
|
||||
}
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, cookieStoreFactory) {
|
||||
{
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
}
|
||||
val clientFactory = remember(dashboardUrl, connectionViewModel) {
|
||||
{ connectionViewModel.dashboardClientForActive(dashboardUrl) }
|
||||
}
|
||||
|
||||
suspend fun verifyAndRecord(client: DashboardApiClient): DashboardAuthSession? {
|
||||
@@ -115,7 +125,7 @@ fun DashboardSignInScreen(
|
||||
|
||||
fun finishAuthentication() {
|
||||
scope.launch {
|
||||
invalidateDashboardManageCache(context.cacheDir)
|
||||
invalidateDashboardManageCache(appContext.cacheDir)
|
||||
connectionViewModel.refreshStandardVoice()
|
||||
connectionViewModel.refreshDashboardProfiles()
|
||||
authenticationComplete = true
|
||||
@@ -132,11 +142,13 @@ fun DashboardSignInScreen(
|
||||
try {
|
||||
val status = client.getStatus().getOrElse {
|
||||
actionMessage = it.message ?: context.getString(R.string.dashboard_request_failed)
|
||||
actionIsError = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providers = status.authProviderDetails.ifEmpty {
|
||||
client.getAuthProviders().getOrNull().orEmpty()
|
||||
}
|
||||
providers = client.getAuthProviders().getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: status.authProviderDetails
|
||||
redirectAuthMode = dashboardRedirectAuthMode(status.authFlows)
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
connectionViewModel.recordDashboardStatus(
|
||||
status = status,
|
||||
@@ -157,6 +169,7 @@ fun DashboardSignInScreen(
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
actionInFlight = true
|
||||
actionMessage = null
|
||||
actionIsError = false
|
||||
scope.launch {
|
||||
val client = clientFactory()
|
||||
try {
|
||||
@@ -167,9 +180,11 @@ fun DashboardSignInScreen(
|
||||
} else {
|
||||
actionMessage = result.exceptionOrNull()?.message
|
||||
?: context.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
actionMessage = e.message ?: context.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
client.shutdown()
|
||||
@@ -177,11 +192,75 @@ fun DashboardSignInScreen(
|
||||
}
|
||||
}
|
||||
|
||||
oauthProvider?.let { provider ->
|
||||
fun startRedirectSignIn(provider: DashboardAuthProvider) {
|
||||
if (actionInFlight || dashboardUrl.isBlank()) return
|
||||
if (redirectAuthMode == DashboardRedirectAuthMode.WebView) {
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_requires_https)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
if (authClient == null) {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_unavailable)
|
||||
actionIsError = true
|
||||
return
|
||||
}
|
||||
|
||||
actionInFlight = true
|
||||
actionIsError = false
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_opening)
|
||||
nativeSignInJob = scope.launch {
|
||||
try {
|
||||
NativeDashboardSignInCoordinator(authClient).signIn(provider.name) { authorizationUrl ->
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
launchNativeDashboardAuthorization(context, authorizationUrl)
|
||||
}
|
||||
}
|
||||
val client = clientFactory()
|
||||
val session = try {
|
||||
verifyAndRecord(client)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
if (session?.authenticated == true) {
|
||||
actionMessage = session.provider?.let {
|
||||
context.getString(R.string.dashboard_signed_in_with, it)
|
||||
} ?: context.getString(R.string.dashboard_signed_in)
|
||||
actionIsError = false
|
||||
finishAuthentication()
|
||||
} else {
|
||||
actionMessage = context.getString(R.string.dashboard_signin_no_session)
|
||||
actionIsError = true
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
actionMessage = error.message
|
||||
?: context.getString(R.string.dashboard_signin_failed)
|
||||
actionIsError = true
|
||||
} finally {
|
||||
actionInFlight = false
|
||||
nativeSignInJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(dashboardUrl, connectionId) {
|
||||
onDispose { nativeSignInJob?.cancel() }
|
||||
}
|
||||
|
||||
oauthProvider
|
||||
?.takeIf { redirectAuthMode == DashboardRedirectAuthMode.WebView }
|
||||
?.let { provider ->
|
||||
DashboardOAuthDialog(
|
||||
dashboardUrl = dashboardUrl,
|
||||
provider = provider,
|
||||
cookieStoreFactory = cookieStoreFactory,
|
||||
clientFactory = clientFactory,
|
||||
onDismiss = { oauthProvider = null },
|
||||
onAuthenticated = { session ->
|
||||
oauthProvider = null
|
||||
@@ -198,7 +277,10 @@ fun DashboardSignInScreen(
|
||||
finishAuthentication()
|
||||
}
|
||||
},
|
||||
onError = { actionMessage = it },
|
||||
onError = {
|
||||
actionMessage = it
|
||||
actionIsError = true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -207,7 +289,10 @@ fun DashboardSignInScreen(
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.dashboard_sign_in)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
IconButton(onClick = {
|
||||
nativeSignInJob?.cancel()
|
||||
onBack()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.dashboard_back),
|
||||
@@ -235,8 +320,17 @@ fun DashboardSignInScreen(
|
||||
providers = providers,
|
||||
actionInFlight = actionInFlight,
|
||||
actionMessage = actionMessage,
|
||||
actionIsError = actionIsError,
|
||||
nativePkce = redirectAuthMode == DashboardRedirectAuthMode.NativePkce,
|
||||
nativeSignInInFlight = nativeSignInJob != null,
|
||||
nativeTransportEligible = isNativeDashboardTransportEligible(dashboardUrl),
|
||||
onSignIn = ::submitPassword,
|
||||
onOAuthSignIn = { oauthProvider = it },
|
||||
onOAuthSignIn = ::startRedirectSignIn,
|
||||
onCancelNativeSignIn = {
|
||||
actionMessage = context.getString(R.string.dashboard_native_signin_cancelled)
|
||||
actionIsError = false
|
||||
nativeSignInJob?.cancel()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -301,8 +395,13 @@ private fun DashboardSignInForm(
|
||||
providers: List<DashboardAuthProvider>,
|
||||
actionInFlight: Boolean,
|
||||
actionMessage: String?,
|
||||
actionIsError: Boolean,
|
||||
nativePkce: Boolean,
|
||||
nativeSignInInFlight: Boolean,
|
||||
nativeTransportEligible: Boolean,
|
||||
onSignIn: (String, String, String) -> Unit,
|
||||
onOAuthSignIn: (DashboardAuthProvider) -> Unit,
|
||||
onCancelNativeSignIn: () -> Unit,
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -328,12 +427,19 @@ private fun DashboardSignInForm(
|
||||
redirectProviders.forEach { provider ->
|
||||
Button(
|
||||
onClick = { onOAuthSignIn(provider) },
|
||||
enabled = !actionInFlight,
|
||||
enabled = !actionInFlight && (!nativePkce || nativeTransportEligible),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_signin_with_provider, provider.displayName ?: provider.name))
|
||||
}
|
||||
}
|
||||
if (nativePkce && !nativeTransportEligible && redirectProviders.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_native_signin_requires_https),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
if (passwordProvider != null || providers.isEmpty()) {
|
||||
if (redirectProviders.isNotEmpty()) HorizontalDivider()
|
||||
OutlinedTextField(
|
||||
@@ -360,7 +466,23 @@ private fun DashboardSignInForm(
|
||||
}
|
||||
}
|
||||
actionMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (actionIsError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
if (nativeSignInInFlight) {
|
||||
Button(
|
||||
onClick = onCancelNativeSignIn,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.dashboard_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +491,7 @@ private fun DashboardOAuthDialog(
|
||||
dashboardUrl: String,
|
||||
provider: DashboardAuthProvider,
|
||||
cookieStoreFactory: () -> DashboardCookieStore,
|
||||
clientFactory: () -> DashboardApiClient,
|
||||
onDismiss: () -> Unit,
|
||||
onAuthenticated: (DashboardAuthSession) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
@@ -408,10 +531,7 @@ private fun DashboardOAuthDialog(
|
||||
checking = true
|
||||
statusText = verifyingStatus
|
||||
scope.launch {
|
||||
val client = DashboardApiClient(
|
||||
dashboardUrl,
|
||||
DashboardApiClient.defaultClient(cookieStoreFactory()),
|
||||
)
|
||||
val client = clientFactory()
|
||||
try {
|
||||
val session = client.currentSession().getOrNull()
|
||||
if (session?.authenticated == true) onAuthenticated(session) else {
|
||||
|
||||
@@ -34,13 +34,11 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -64,8 +62,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dedicated Developer Options screen. Gated behind the tap-version-7x
|
||||
* unlock. Hosts feature flags (voice/terminal/bridge/etc.), data management
|
||||
* (clear session / wipe caches), and any experimental toggles.
|
||||
* unlock. Hosts experimental labs, test tools, and data-management utilities.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -73,13 +70,12 @@ fun DeveloperSettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToRealtimeVoice: () -> Unit = {},
|
||||
onNavigateToImageGenerationLab: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context).collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
// Data management local state — unfolded from the private
|
||||
// DataManagementSection helper in the old SettingsScreen.
|
||||
var showResetDialog by remember { mutableStateOf(false) }
|
||||
@@ -184,8 +180,17 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.resetOnboarding()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_onboarding_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetOnboarding { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_onboarding_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.RestartAlt,
|
||||
@@ -299,42 +304,6 @@ fun DeveloperSettingsScreen(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Relay features toggle
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_relay_features_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = relayEnabled,
|
||||
onCheckedChange = { scope.launch { FeatureFlags.setRelayEnabled(context, it) } }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -372,6 +341,47 @@ fun DeveloperSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (FeatureFlags.isDevBuild) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_image_generation_lab_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onNavigateToImageGenerationLab) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = stringResource(
|
||||
R.string.dev_settings_open_image_generation_lab_cd
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Lock developer options
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -390,9 +400,15 @@ fun DeveloperSettingsScreen(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch { FeatureFlags.lockDevOptions(context) }
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_locked_toast), Toast.LENGTH_SHORT).show()
|
||||
onBack()
|
||||
scope.launch {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_locked_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
onBack()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
@@ -520,8 +536,16 @@ fun DeveloperSettingsScreen(
|
||||
onClick = {
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
if (json == null) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_export_failed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
} else {
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -577,8 +601,17 @@ fun DeveloperSettingsScreen(
|
||||
TextButton(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
connectionViewModel.resetAppData()
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_app_data_reset_toast), Toast.LENGTH_SHORT).show()
|
||||
connectionViewModel.resetAppData { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
context.getString(R.string.dev_settings_app_data_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.dev_settings_reset_action), color = MaterialTheme.colorScheme.error)
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
|
||||
internal fun launchNativeDashboardAuthorization(
|
||||
context: Context,
|
||||
authorizationUrl: String,
|
||||
) {
|
||||
val uri = Uri.parse(authorizationUrl)
|
||||
val customTab = CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
|
||||
.build()
|
||||
.also {
|
||||
if (context !is Activity) {
|
||||
it.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
try {
|
||||
customTab.launchUrl(context, uri)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -812,6 +817,20 @@ private fun QuickControlsCard(
|
||||
) {
|
||||
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
|
||||
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* Posting re-checks the grant. */ }
|
||||
val requestNotificationPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -844,7 +863,10 @@ private fun QuickControlsCard(
|
||||
stringResource(R.string.settings_connect_on_demand)
|
||||
},
|
||||
checked = gatewayKeepAlive,
|
||||
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) },
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setGatewayKeepAlive(enabled)
|
||||
if (enabled && notifyTurnComplete) requestNotificationPermission()
|
||||
},
|
||||
)
|
||||
// Doze: even with the keep-alive service running, a specialUse FGS
|
||||
// still gets its network deferred in deep sleep unless the app is
|
||||
@@ -863,7 +885,10 @@ private fun QuickControlsCard(
|
||||
stringResource(R.string.settings_turn_complete_alerts_off)
|
||||
},
|
||||
checked = notifyTurnComplete,
|
||||
onCheckedChange = { connectionViewModel.setNotifyTurnComplete(it) },
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setNotifyTurnComplete(enabled)
|
||||
if (enabled) requestNotificationPermission()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +112,7 @@ import com.hermesandroid.relay.network.relay.VoiceProviderValidationResponse
|
||||
import com.hermesandroid.relay.network.upstream.ConfigFieldType
|
||||
import com.hermesandroid.relay.network.upstream.ConfigSchemaField
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.ElevenLabsVoices
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.applyConfigEdits
|
||||
import com.hermesandroid.relay.network.upstream.configValueAt
|
||||
import com.hermesandroid.relay.network.upstream.parseConfigSchema
|
||||
@@ -184,12 +182,12 @@ fun VoiceSettingsScreen(
|
||||
*/
|
||||
connectionId: String? = null,
|
||||
/**
|
||||
* Dashboard base URL + per-connection cookie store provider for the
|
||||
* standard-path server voice-config editor (`/api/config`, cookie auth).
|
||||
* Dashboard base URL + trusted per-connection client provider for the
|
||||
* standard-path server voice-config editor (`/api/config`, session auth).
|
||||
* Null on connections with no dashboard — the editor card is then hidden.
|
||||
*/
|
||||
dashboardUrl: String? = null,
|
||||
dashboardCookieStoreProvider: (() -> DashboardCookieStore?)? = null,
|
||||
dashboardClientProvider: ((String) -> DashboardApiClient)? = null,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
@@ -208,17 +206,12 @@ fun VoiceSettingsScreen(
|
||||
// just observes it; the editor cards push saves back through the VM.
|
||||
val configState by settingsViewModel.configState.collectAsState()
|
||||
|
||||
// Standard-path server voice-config editor client (dashboard cookie auth).
|
||||
// Standard-path server voice-config editor client (cookie or native bearer).
|
||||
// Built once per (dashboardUrl, connection); shut down on dispose. Null when
|
||||
// the connection has no dashboard, which hides the card entirely.
|
||||
val dashboardConfigClient = remember(dashboardUrl, connectionId) {
|
||||
val url = dashboardUrl?.trim()?.takeIf { it.isNotBlank() } ?: return@remember null
|
||||
DashboardApiClient(
|
||||
baseUrl = url,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreProvider?.invoke() ?: InMemoryDashboardCookieStore(),
|
||||
),
|
||||
)
|
||||
dashboardClientProvider?.invoke(url)
|
||||
}
|
||||
DisposableEffect(dashboardConfigClient) {
|
||||
onDispose { dashboardConfigClient?.shutdown() }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,9 +68,11 @@ import com.hermesandroid.relay.network.upstream.mirrorDashboardSessionCookies
|
||||
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
|
||||
import com.hermesandroid.relay.network.upstream.ToolsetInfo
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
|
||||
@@ -216,6 +218,19 @@ internal fun resolveEffectiveDashboardUrl(
|
||||
return connection.resolvedDashboardUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime API route only after the optional fallback was explicitly
|
||||
* configured. Discovery may attach a conventional same-host API candidate to a
|
||||
* Dashboard route, but that candidate alone must not enable API traffic.
|
||||
*/
|
||||
internal fun resolveEffectiveApiServerUrl(
|
||||
savedUrl: String,
|
||||
endpoint: EndpointCandidate?,
|
||||
): String {
|
||||
if (savedUrl.isBlank()) return ""
|
||||
return endpoint?.api?.url?.takeIf { it.isNotBlank() } ?: savedUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Relay transport metadata to the connection's existing standard routes
|
||||
* without adopting the Relay QR's API/Dashboard identity.
|
||||
@@ -390,6 +405,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// Animation
|
||||
private val KEY_ANIMATION_ENABLED = booleanPreferencesKey("animation_enabled")
|
||||
private val KEY_ANIMATION_BEHIND_CHAT = booleanPreferencesKey("animation_behind_chat")
|
||||
private val KEY_IMAGE_GENERATION_STYLE = stringPreferencesKey("image_generation_style")
|
||||
private val KEY_CHAT_RECENT_PROMPTS = booleanPreferencesKey("chat_recent_prompts")
|
||||
|
||||
// Chat scroll behavior
|
||||
@@ -623,7 +639,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
context = application,
|
||||
activeConnectionIdProvider = { connectionStore.activeConnectionId.value },
|
||||
dashboardUrlProvider = { activeDashboardUrl() },
|
||||
gatewayKeepAliveProvider = { gatewayKeepAlive.value },
|
||||
gatewayKeepAliveProvider = {
|
||||
gatewayKeepAlive.value || ActiveTurnKeepAliveRegistry.snapshot.value.required
|
||||
},
|
||||
// Lets the dashboard cookie store ride the connection's token keyset
|
||||
// (one keyset build instead of two on cold start).
|
||||
tokenStoreKeyProvider = { cid ->
|
||||
@@ -775,7 +793,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
|
||||
private fun effectiveApiServerUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.api?.url ?: _apiServerUrl.value
|
||||
resolveEffectiveApiServerUrl(
|
||||
savedUrl = _apiServerUrl.value,
|
||||
endpoint = connectionManager.activeEndpoint.value,
|
||||
)
|
||||
|
||||
private fun effectiveRelayUrlSnapshot(): String =
|
||||
connectionManager.activeEndpoint.value?.relay?.url ?: autoRelayUrlSnapshot()
|
||||
@@ -851,7 +872,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val isInsecureConnection: StateFlow<Boolean> = connectionManager.isInsecureConnection
|
||||
|
||||
// --- API Server state ---
|
||||
private val _apiServerUrl = MutableStateFlow(DEFAULT_API_URL)
|
||||
// Blank is the unhydrated sentinel. Seeding this with the legacy localhost
|
||||
// default made a discovered remote API candidate look explicitly configured
|
||||
// during the first DataStore frame, briefly building an unauthenticated
|
||||
// Sessions client before a Dashboard-only connection restored its saved
|
||||
// blank URL.
|
||||
private val _apiServerUrl = MutableStateFlow("")
|
||||
val apiServerUrl: StateFlow<String> = _apiServerUrl.asStateFlow()
|
||||
|
||||
private val _apiServerReachable = MutableStateFlow(false)
|
||||
@@ -958,6 +984,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun activeDashboardCookieStore(): DashboardCookieStore? =
|
||||
upstreamTransport.activeDashboardCookieStore()
|
||||
|
||||
/** Trusted active-connection clients used by the shared dashboard sign-in route. */
|
||||
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient =
|
||||
upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? =
|
||||
upstreamTransport.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
|
||||
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient =
|
||||
upstreamTransport.dashboardHttpClientForActive(dashboardUrl)
|
||||
|
||||
/** Authenticated Dashboard config for dashboard-primary feature catalogs. */
|
||||
suspend fun loadActiveDashboardConfig(): Result<JsonObject>? {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||
@@ -1017,17 +1053,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()
|
||||
|
||||
/**
|
||||
* Runtime route for chat/API traffic. The persisted API URL remains the
|
||||
* connection's base config; a resolver-selected endpoint temporarily wins
|
||||
* so paired devices can roam between LAN, Tailscale, and operator VPN
|
||||
* routes without rewriting stored settings.
|
||||
* Runtime route for chat/API traffic. Once API fallback is explicitly
|
||||
* configured, a resolver-selected endpoint temporarily wins so paired
|
||||
* devices can roam without rewriting stored settings. Discovery alone
|
||||
* never enables the optional API surface.
|
||||
*/
|
||||
val effectiveApiServerUrl: StateFlow<String> = combine(
|
||||
_apiServerUrl,
|
||||
connectionManager.activeEndpoint,
|
||||
) { savedUrl, endpoint ->
|
||||
endpoint?.api?.url ?: savedUrl
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
|
||||
resolveEffectiveApiServerUrl(savedUrl, endpoint)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
|
||||
|
||||
/**
|
||||
* Whether a chat turn is currently streaming — mirrored from
|
||||
@@ -1671,17 +1707,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
init {
|
||||
// Drive the keep-alive: flip the active client's no-background-close
|
||||
// flag and start/stop the foreground service. Both flavors — the
|
||||
// Drive the keep-alive from either the user's always-on preference or
|
||||
// work the user already started. Active-turn leases are session scoped,
|
||||
// so sibling turns release independently. Both flavors — the
|
||||
// GatewayKeepAliveService is declared in the main manifest (Play permits
|
||||
// this Home-Assistant-class persistent-connection use case). Mirrors
|
||||
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
|
||||
viewModelScope.launch {
|
||||
gatewayKeepAlive.collect { enabled ->
|
||||
upstreamTransport.applyGatewayKeepAlive(enabled)
|
||||
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
|
||||
persistent to turns
|
||||
}.distinctUntilChanged().collect { (persistent, turns) ->
|
||||
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
|
||||
val ctx = getApplication<Application>()
|
||||
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
|
||||
else runCatching { GatewayKeepAliveService.stop(ctx) }
|
||||
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1740,6 +1778,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.map { it[KEY_ANIMATION_BEHIND_CHAT] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
val imageGenerationStyle: StateFlow<String> = application.relayDataStore.data
|
||||
.map { it[KEY_IMAGE_GENERATION_STYLE] ?: "rotate" }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "rotate")
|
||||
|
||||
fun setAnimationEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
@@ -1772,6 +1814,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun setImageGenerationStyle(value: String) {
|
||||
val normalized = value.takeIf {
|
||||
it in setOf("rotate", "grid", "sphere", "nodes")
|
||||
} ?: "rotate"
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_IMAGE_GENERATION_STYLE] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth auto-scroll during chat streaming.
|
||||
// When enabled, the chat list smoothly follows new tokens, tool cards, and
|
||||
// reasoning deltas as they stream in — but only while the user is at the
|
||||
@@ -4922,7 +4975,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val active = connectionStore.connections.value.firstOrNull { it.id == connectionId }
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
dashboardCookieStoreFor(connectionId).clear()
|
||||
upstreamTransport.clearDashboardAuthentication(connectionId)
|
||||
}
|
||||
connectionStore.setDashboardStatus(
|
||||
connectionId = connectionId,
|
||||
@@ -6318,50 +6371,62 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOnboarding() {
|
||||
fun resetOnboarding(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
dataManager.resetOnboarding()
|
||||
_onboardingCompleted.value = false
|
||||
val success = dataManager.resetOnboarding()
|
||||
if (success) {
|
||||
_onboardingCompleted.value = false
|
||||
}
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun resetAppData() {
|
||||
fun resetAppData(onResult: (Boolean) -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
dataManager.resetAppData()
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
val success = runCatching {
|
||||
disconnectRelay()
|
||||
authManager.clearSession()
|
||||
authManager.clearApiKey()
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
_relayUrl.value = ""
|
||||
rebuildApiClient()
|
||||
shutdownClientOffMain(profileChatApiClient)
|
||||
profileChatApiClient = null
|
||||
profileChatApiClientUrl = null
|
||||
profileChatApiClientKey = null
|
||||
profileController.clearSelectionState()
|
||||
_lastSessionId.value = null
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to reset app data", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportSettings(onResult: (String) -> Unit) {
|
||||
fun exportSettings(onResult: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val json = dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
val json = runCatching {
|
||||
dataManager.exportSettings(
|
||||
serverUrl = _relayUrl.value,
|
||||
theme = theme.value,
|
||||
onboardingCompleted = _onboardingCompleted.value,
|
||||
// Pass 2: AuthManager.sessionLabels is gone — replaced by
|
||||
// `agentProfiles: StateFlow<List<Profile>>`. The DataManager
|
||||
// param is marked @Suppress("UNUSED_PARAMETER") and isn't
|
||||
// written to the backup anyway, so an empty list keeps the
|
||||
// signature stable until the param is removed in a later pass.
|
||||
sessionLabels = emptyList(),
|
||||
apiServerUrl = _apiServerUrl.value,
|
||||
relayUrl = _relayUrl.value
|
||||
)
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to prepare settings backup", it)
|
||||
}.getOrNull()
|
||||
onResult(json)
|
||||
}
|
||||
}
|
||||
@@ -6383,24 +6448,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
onResult(false)
|
||||
return@launch
|
||||
}
|
||||
// Apply imported settings
|
||||
if (backup.connections.isNotEmpty()) {
|
||||
val success = runCatching {
|
||||
val importedRelayUrl = if (backup.connections.isEmpty()) {
|
||||
backup.relayUrl ?: backup.serverUrl
|
||||
} else {
|
||||
null
|
||||
}
|
||||
// A backup is a replacement snapshot, including when it
|
||||
// intentionally contains zero connections.
|
||||
dataManager.restoreConnectionBackup(backup)
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_THEME] = backup.theme
|
||||
importedRelayUrl?.let { preferences[KEY_RELAY_URL] = it }
|
||||
backup.apiServerUrl
|
||||
?.takeIf { backup.connections.isEmpty() }
|
||||
?.let { preferences[KEY_API_SERVER_URL] = it }
|
||||
}
|
||||
connectionStore.activeConnection.value?.let { restored ->
|
||||
restorePersistedActiveConnectionContext(restored)
|
||||
}
|
||||
} else {
|
||||
// Prefer v2 fields, fall back to v1 serverUrl for relay
|
||||
val importedRelayUrl = backup.relayUrl ?: backup.serverUrl
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
setTheme(backup.theme)
|
||||
if (backup.onboardingCompleted) {
|
||||
dataManager.setOnboardingCompleted(true)
|
||||
_onboardingCompleted.value = true
|
||||
}
|
||||
onResult(true)
|
||||
if (backup.connections.isEmpty()) {
|
||||
// Preserve v1/v2 compatibility after clearing the current
|
||||
// multi-connection snapshot.
|
||||
importedRelayUrl?.let { updateRelayUrl(it) }
|
||||
backup.apiServerUrl?.let { updateApiServerUrl(it) }
|
||||
}
|
||||
check(dataManager.setOnboardingCompleted(backup.onboardingCompleted)) {
|
||||
"Failed to restore onboarding state"
|
||||
}
|
||||
_onboardingCompleted.value = backup.onboardingCompleted
|
||||
}.onFailure {
|
||||
android.util.Log.e("ConnectionVM", "Failed to import settings backup", it)
|
||||
}.isSuccess
|
||||
onResult(success)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -99,6 +100,104 @@ private enum class StandardSpeechStreamState {
|
||||
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
|
||||
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
|
||||
|
||||
internal data class AssistantSpeechDelta(
|
||||
val message: ChatMessage,
|
||||
val text: String,
|
||||
val startsNewBubble: Boolean,
|
||||
)
|
||||
|
||||
internal data class AssistantSpeechBatch(
|
||||
val deltas: List<AssistantSpeechDelta>,
|
||||
val assistantMessages: List<ChatMessage>,
|
||||
val aggregateText: String,
|
||||
val hasTurnAssistant: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Per-voice-turn cursor over every assistant bubble created after the user
|
||||
* submits the turn. Tool-using Hermes runs may finalize an interim assistant
|
||||
* bubble and then append a second bubble with the actual answer; tracking only
|
||||
* the last bubble drops one of those segments.
|
||||
*
|
||||
* Existing stable UI keys are fenced at construction so StateFlow replay and
|
||||
* later history reconciliation cannot narrate an older session after adopting
|
||||
* a server message ID. Content rewrites are adopted silently unless they
|
||||
* preserve the exact prior prefix: only genuine suffix growth is speech.
|
||||
*/
|
||||
internal class AssistantSpeechCursor(
|
||||
baselineMessages: List<ChatMessage>,
|
||||
) {
|
||||
private val baselineAssistantKeys = baselineMessages.asSequence()
|
||||
.filter { it.role == MessageRole.ASSISTANT }
|
||||
.mapTo(mutableSetOf()) { it.uiKey }
|
||||
private val observedContent = mutableMapOf<String, String>()
|
||||
|
||||
fun poll(messages: List<ChatMessage>): AssistantSpeechBatch {
|
||||
val turnAssistants = messages.filter {
|
||||
it.role == MessageRole.ASSISTANT && it.uiKey !in baselineAssistantKeys
|
||||
}
|
||||
val deltas = buildList {
|
||||
turnAssistants.forEach { message ->
|
||||
val key = message.uiKey
|
||||
val firstObservation = key !in observedContent
|
||||
val hasPriorBubbleSpeech = observedContent.values.any { it.isNotEmpty() }
|
||||
val previous = observedContent[key].orEmpty()
|
||||
val current = message.content
|
||||
if (current.length > previous.length && current.startsWith(previous)) {
|
||||
add(
|
||||
AssistantSpeechDelta(
|
||||
message = message,
|
||||
text = current.substring(previous.length),
|
||||
startsNewBubble = firstObservation && hasPriorBubbleSpeech,
|
||||
),
|
||||
)
|
||||
}
|
||||
observedContent[key] = current
|
||||
}
|
||||
}
|
||||
return AssistantSpeechBatch(
|
||||
deltas = deltas,
|
||||
assistantMessages = turnAssistants,
|
||||
aggregateText = turnAssistants
|
||||
.map { it.content.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString("\n\n"),
|
||||
hasTurnAssistant = turnAssistants.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds one voice request to its chat session. Existing-session turns are
|
||||
* fixed immediately. A brand-new chat starts with no session id, so the first
|
||||
* server id is accepted only while the locally submitted user row is still in
|
||||
* that session's message list; switching to another existing session while
|
||||
* creation is pending therefore fails closed.
|
||||
*/
|
||||
internal class VoiceTurnSessionFence(initialSessionId: String?) {
|
||||
private var boundSessionId: String? = initialSessionId
|
||||
private val startedWithoutSession = initialSessionId == null
|
||||
private var submittedUserUiKey: String? = null
|
||||
|
||||
fun bindSubmittedUser(uiKey: String?) {
|
||||
submittedUserUiKey = uiKey
|
||||
}
|
||||
|
||||
fun accepts(sessionId: String?, messages: List<ChatMessage>): Boolean {
|
||||
boundSessionId?.let { return sessionId == it }
|
||||
if (!startedWithoutSession) return false
|
||||
if (sessionId == null) return true
|
||||
|
||||
val userKey = submittedUserUiKey ?: return false
|
||||
val ownsSubmittedTurn = messages.any {
|
||||
it.role == MessageRole.USER && it.uiKey == userKey
|
||||
}
|
||||
if (!ownsSubmittedTurn) return false
|
||||
boundSessionId = sessionId
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
|
||||
|
||||
@@ -372,7 +471,7 @@ data class VoiceStats(
|
||||
* ### Sentence-boundary streaming TTS
|
||||
* The SSE stream emits text one token at a time, but TTS wants whole
|
||||
* sentences to sound natural. We observe [ChatViewModel.messages],
|
||||
* extract deltas from the currently-streaming assistant message, and
|
||||
* extract deltas from every assistant message created by the active run, and
|
||||
* feed each completed sentence into a bounded [ttsQueue]. A dedicated
|
||||
* consumer coroutine pulls from the queue, synthesizes each sentence,
|
||||
* and plays them back-to-back via [VoicePlayer.awaitCompletion].
|
||||
@@ -380,10 +479,9 @@ data class VoiceStats(
|
||||
* ### Integration note (V2a → V2b cleanup)
|
||||
* This first version uses the public [ChatViewModel.messages] StateFlow
|
||||
* to observe streaming deltas rather than adding a `// VOICE HOOK`
|
||||
* callback inside ChatViewModel. It's clean but depends on the
|
||||
* "last message with isStreaming=true" invariant — if ChatViewModel
|
||||
* ever streams multiple assistant messages concurrently this will need
|
||||
* a dedicated per-turn flow. See `DEVLOG.md` and V2b ticket.
|
||||
* callback inside ChatViewModel. A per-turn cursor fences pre-existing
|
||||
* history and follows all assistant bubbles until ChatViewModel reports
|
||||
* that the complete Hermes run has ended.
|
||||
*/
|
||||
class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
@@ -672,8 +770,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/** Tracks which assistant-message IDs have already been consumed so
|
||||
* we don't re-process older turns when the history list updates. */
|
||||
private var lastObservedMessageId: String? = null
|
||||
private var lastObservedContentLength: Int = 0
|
||||
private var assistantSpeechCursor: AssistantSpeechCursor? = null
|
||||
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
|
||||
private var sentenceBuffer: StringBuilder = StringBuilder()
|
||||
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
|
||||
private val brokeredToolSpeechKeys = mutableSetOf<String>()
|
||||
@@ -776,15 +874,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var ttsChunksThisResponse: Int = 0
|
||||
private var lastTtsChunkFinishedAtMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Assistant-message-id that already existed BEFORE the current turn's
|
||||
* [chatVm.sendMessage] call. The stream observer ignores any emission
|
||||
* whose `lastAssistant.id` equals this, so StateFlow's initial replay
|
||||
* of the previous turn's response doesn't get spoken as a reply to
|
||||
* the current voice input.
|
||||
*/
|
||||
private var ignoreAssistantId: String? = null
|
||||
|
||||
/** MP3 files produced by synthesize — trimmed to [TTS_CACHE_CAP]. */
|
||||
private val ttsFileHistory = ArrayDeque<File>()
|
||||
|
||||
@@ -1737,8 +1826,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sentenceBuffer = StringBuilder()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
currentTurnPcm = ByteArray(0)
|
||||
resetBrokeredToolSpeechState()
|
||||
@@ -2144,8 +2233,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sentenceBuffer = StringBuilder()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
currentTurnPcm = ByteArray(0)
|
||||
resetBrokeredToolSpeechState()
|
||||
@@ -3061,8 +3150,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Reset sentence buffering state for the new turn.
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = AssistantSpeechCursor(chatVm.messages.value)
|
||||
voiceTurnSessionFence = VoiceTurnSessionFence(chatVm.currentSessionId.value)
|
||||
streamComplete = false
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
@@ -3073,14 +3162,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
resumeWatchdog?.cancel(); resumeWatchdog = null
|
||||
clearSpokenChunksState()
|
||||
|
||||
// Capture the id of the assistant message that currently sits at
|
||||
// the end of history. StateFlow.collect replays the current value
|
||||
// to new subscribers, so without this guard the observer would
|
||||
// treat the previous turn's full response as one giant delta for
|
||||
// the new turn and TTS the wrong answer.
|
||||
ignoreAssistantId = chatVm.messages.value
|
||||
.lastOrNull { it.role == MessageRole.ASSISTANT }?.id
|
||||
|
||||
prepareStandardSpeechStream()
|
||||
|
||||
// Kick off streaming observer BEFORE sending the message so we don't
|
||||
@@ -3089,7 +3170,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Route the transcribed text through the normal chat pipeline.
|
||||
// This will create a user message + kick off the SSE stream.
|
||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
||||
val submittedUserUiKey =
|
||||
chatVm.sendVoiceMessage(userText, STABLE_VOICE_INTERFACE_CONTEXT)
|
||||
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
|
||||
}
|
||||
|
||||
private suspend fun runVoiceRelayPreflight(engineLabel: String): Boolean {
|
||||
@@ -3147,8 +3230,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
sentenceBuffer = StringBuilder()
|
||||
pendingRawDelta = StringBuilder()
|
||||
lastObservedMessageId = null
|
||||
lastObservedContentLength = 0
|
||||
assistantSpeechCursor = null
|
||||
voiceTurnSessionFence = null
|
||||
streamComplete = false
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
@@ -4223,64 +4306,62 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe [ChatViewModel.messages]. When the last assistant message
|
||||
* grows (isStreaming=true), diff the content against our last snapshot,
|
||||
* push the new delta into [sentenceBuffer], and flush completed
|
||||
* sentences into [ttsQueue]. On isStreaming=false, flush the remaining
|
||||
* buffer and end the turn.
|
||||
* Observe every assistant bubble created by the active Hermes run. A tool
|
||||
* turn can finalize one bubble while the run is still active and later
|
||||
* append the final answer in another bubble, so completion is keyed to
|
||||
* [ChatViewModel.isStreaming], not an individual message flag.
|
||||
*/
|
||||
private fun startStreamObserver(chatVm: ChatViewModel) {
|
||||
streamObserverJob?.cancel()
|
||||
streamObserverJob = viewModelScope.launch {
|
||||
chatVm.messages.collect { messages ->
|
||||
val lastAssistant = messages.lastOrNull {
|
||||
it.role == MessageRole.ASSISTANT
|
||||
} ?: return@collect
|
||||
|
||||
// Skip the assistant message that existed BEFORE the current
|
||||
// turn's sendMessage. Without this, StateFlow's replay of the
|
||||
// current list (containing the PREVIOUS turn's response) gets
|
||||
// treated as a delta and the agent voices the old answer.
|
||||
if (lastAssistant.id == ignoreAssistantId) return@collect
|
||||
|
||||
val msgId = lastAssistant.id
|
||||
if (lastObservedMessageId == null) {
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
} else if (lastObservedMessageId != msgId) {
|
||||
// A new assistant turn appeared — flush whatever's left
|
||||
// from the previous one, then switch tracking.
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
resetBrokeredToolSpeechState()
|
||||
lastObservedMessageId = msgId
|
||||
lastObservedContentLength = 0
|
||||
}
|
||||
|
||||
observeHermesToolLoopForSpeech(lastAssistant)
|
||||
|
||||
val content = lastAssistant.content
|
||||
if (content.length > lastObservedContentLength) {
|
||||
val delta = content.substring(lastObservedContentLength)
|
||||
lastObservedContentLength = content.length
|
||||
onStreamDelta(delta, content)
|
||||
}
|
||||
|
||||
if (!lastAssistant.isStreaming && lastObservedContentLength > 0) {
|
||||
// Stream ended — mark complete so the chunker stops
|
||||
// holding short trailing sentences, cancel the idle
|
||||
// timer (we know exactly when the stream is done), and
|
||||
// flush any trailing buffer.
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
// Speaking state will naturally end when TTS queue drains.
|
||||
// We can't easily wait here without blocking the collector;
|
||||
// the TTS consumer transitions back to Idle.
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
val cursor = assistantSpeechCursor ?: AssistantSpeechCursor(chatVm.messages.value).also {
|
||||
assistantSpeechCursor = it
|
||||
}
|
||||
val sessionFence = voiceTurnSessionFence
|
||||
?: VoiceTurnSessionFence(chatVm.currentSessionId.value).also {
|
||||
voiceTurnSessionFence = it
|
||||
}
|
||||
streamObserverJob = viewModelScope.launch {
|
||||
combine(
|
||||
chatVm.messages,
|
||||
chatVm.isStreaming,
|
||||
chatVm.currentSessionId,
|
||||
) { messages, runActive, sessionId -> Triple(messages, runActive, sessionId) }
|
||||
.collect { (messages, runActive, sessionId) ->
|
||||
if (!sessionFence.accepts(sessionId, messages)) {
|
||||
cancelStandardSpeechStream("chat session changed")
|
||||
streamObserverJob?.cancel()
|
||||
return@collect
|
||||
}
|
||||
|
||||
val batch = cursor.poll(messages)
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
// Tool state can change without text growth.
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
|
||||
if (!runActive && batch.hasTurnAssistant) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (!finishStandardSpeechStream()) flushRemainingBuffer()
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginAssistantSpeechBubble() {
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (standardSpeechStreamOwnsReply()) {
|
||||
offerStandardSpeechText("\n\n")
|
||||
} else {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+106
@@ -4,12 +4,18 @@ import android.content.Context
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.DashboardBearerAuth
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
|
||||
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
|
||||
import com.hermesandroid.relay.network.upstream.clearNativeDashboardTokens
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
|
||||
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -78,6 +84,10 @@ class UpstreamTransportController(
|
||||
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
|
||||
private val dashboardCookieStores =
|
||||
ConcurrentHashMap<String, EncryptedDashboardCookieStore>()
|
||||
private val dashboardTokenStores =
|
||||
ConcurrentHashMap<String, EncryptedNativeDashboardTokenStore>()
|
||||
private var dashboardHttpClientCache:
|
||||
Triple<String, String, okhttp3.OkHttpClient>? = null
|
||||
|
||||
/**
|
||||
* Cookie store for [connectionId] — ONE instance per connection,
|
||||
@@ -107,6 +117,28 @@ class UpstreamTransportController(
|
||||
return dashboardCookieStoreFor(connectionId)
|
||||
}
|
||||
|
||||
private fun dashboardTokenStoreFor(connectionId: String): EncryptedNativeDashboardTokenStore {
|
||||
val key = tokenStoreKeyProvider(connectionId)
|
||||
?: com.hermesandroid.relay.data.Connection.buildTokenStoreKey(connectionId)
|
||||
return dashboardTokenStores.getOrPut(connectionId) {
|
||||
EncryptedNativeDashboardTokenStore(context, key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bearerAuthForTrustedDashboard(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
): DashboardBearerAuth? {
|
||||
if (activeConnectionIdProvider() != connectionId) return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
return trustedDashboardBearerAuthOrNull(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
tokenStoreProvider = { dashboardTokenStoreFor(connectionId) },
|
||||
)
|
||||
}
|
||||
|
||||
// --- DashboardApiClient factory ----------------------------------------
|
||||
|
||||
/**
|
||||
@@ -120,6 +152,7 @@ class UpstreamTransportController(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -133,9 +166,80 @@ class UpstreamTransportController(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let {
|
||||
bearerAuthForTrustedDashboard(it, dashboardUrl)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Native PKCE client for the active connection's exact trusted dashboard
|
||||
* base. Setup probes and stale routes never receive the encrypted bearer
|
||||
* store.
|
||||
*/
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? {
|
||||
val connectionId = activeConnectionIdProvider() ?: return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
return null
|
||||
}
|
||||
if (!com.hermesandroid.relay.network.upstream.sameDashboardBase(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return NativeDashboardAuthClient(
|
||||
baseUrl = dashboardUrl,
|
||||
tokenStore = dashboardTokenStoreFor(connectionId),
|
||||
)
|
||||
}
|
||||
|
||||
/** Exact-origin authenticated HTTP client for non-REST dashboard consumers such as voice. */
|
||||
@Synchronized
|
||||
fun dashboardHttpClientForActive(dashboardUrl: String): okhttp3.OkHttpClient {
|
||||
val connectionId = activeConnectionIdProvider() ?: "unassociated"
|
||||
dashboardHttpClientCache?.let { (cachedConnection, cachedUrl, client) ->
|
||||
if (cachedConnection == connectionId && cachedUrl == dashboardUrl) return client
|
||||
disposeDashboardHttpClient(client)
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
return DashboardApiClient.defaultClient(
|
||||
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
bearerAuth = activeConnectionIdProvider()?.let { activeId ->
|
||||
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
|
||||
},
|
||||
).also { dashboardHttpClientCache = Triple(connectionId, dashboardUrl, it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clearDashboardAuthentication(connectionId: String) {
|
||||
dashboardCookieStoreFor(connectionId).clear()
|
||||
clearNativeDashboardTokens(dashboardTokenStoreFor(connectionId))
|
||||
dashboardHttpClientCache
|
||||
?.takeIf { it.first == connectionId }
|
||||
?.third
|
||||
?.let(::disposeDashboardHttpClient)
|
||||
if (dashboardHttpClientCache?.first == connectionId) {
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
gatewayClientCache
|
||||
?.takeIf { it.first == connectionId }
|
||||
?.third
|
||||
?.shutdown()
|
||||
if (gatewayClientCache?.first == connectionId) {
|
||||
gatewayClientCache = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
|
||||
client.dispatcher.cancelAll()
|
||||
client.connectionPool.evictAll()
|
||||
runCatching { client.cache?.close() }
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
|
||||
// --- Gateway availability ----------------------------------------------
|
||||
|
||||
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
||||
@@ -226,6 +330,8 @@ class UpstreamTransportController(
|
||||
synchronized(this) {
|
||||
gatewayClientCache?.third?.shutdown()
|
||||
gatewayClientCache = null
|
||||
dashboardHttpClientCache?.third?.let(::disposeDashboardHttpClient)
|
||||
dashboardHttpClientCache = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,18 @@
|
||||
<string name="onboarding_realtime_label">Tempo real</string>
|
||||
<string name="onboarding_realtime_description">Agente de voz em tempo real nativo do provedor e provedores de voz específicos para cada perfil.</string>
|
||||
<string name="onboarding_review_permissions">Revisar permissões</string>
|
||||
<string name="onboarding_finish_setup_title">Concluir configuração</string>
|
||||
<string name="onboarding_finish_setup_description">Sua conexão com o Hermes está pronta. Escolha agora o que este telefone pode fazer ou altere depois nas Configurações.</string>
|
||||
<string name="onboarding_chat_manage_ready">Pronto — nenhuma permissão do telefone é necessária.</string>
|
||||
<string name="onboarding_chat_alerts">Alertas do chat</string>
|
||||
<string name="onboarding_chat_alerts_description">Permita notificações do Android para que os alertas ativados cheguem em segundo plano.</string>
|
||||
<string name="onboarding_chat_alerts_ready">As notificações estão ativadas para este app.</string>
|
||||
<string name="onboarding_optional_features">Recursos opcionais</string>
|
||||
<string name="onboarding_optional_features_description">Câmera, microfone, assistente de notificações e ferramentas disponíveis do dispositivo ficam desativados até você escolhê-los.</string>
|
||||
<string name="onboarding_review_optional_permissions">Revisar permissões opcionais</string>
|
||||
<string name="onboarding_enable_chat_alerts">Ativar alertas do chat</string>
|
||||
<string name="onboarding_not_now">Agora não</string>
|
||||
<string name="onboarding_finish">Concluir configuração</string>
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">Conversa por voz ao vivo</string>
|
||||
<string name="chat_input_add_attachment">Adicionar anexo</string>
|
||||
@@ -619,11 +631,11 @@
|
||||
<string name="settings_profile_lock_desc">Fixe o app em um perfil de agente</string>
|
||||
<string name="settings_quick_controls">Controles rápidos</string>
|
||||
<string name="settings_persistent_connection">Conexão persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantém sua conexão com o Hermes aberta em segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conectar somente quando necessário · economiza bateria</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de turno concluído</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notifique quando uma resposta terminar enquanto o app estiver em segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sem alerta quando uma resposta em segundo plano terminar</string>
|
||||
<string name="settings_persistent_connection_desc">Manter a conexão mesmo sem nenhum chat em andamento</string>
|
||||
<string name="settings_connect_on_demand">Chats ativos permanecem conectados automaticamente · a conexão ociosa é fechada para economizar bateria</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sem alertas para atividade de chat em segundo plano</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Manter conectado em suspensão profunda</string>
|
||||
<string name="settings_keep_connected_battery_desc">O Android ainda pode pausar a conexão depois que a tela fica desligada por algum tempo (Doze). Permita o uso irrestrito da bateria para que a Conexão persistente continue funcionando em segundo plano.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Permitir uso irrestrito da bateria</string>
|
||||
@@ -652,8 +664,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Mostre suas mensagens recentes como chips tocáveis acima do editor para enviá-las novamente. Desativado por padrão.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Manter o teclado aberto ao enviar</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Permaneça no editor após enviar. Desative para fechar o teclado depois de cada mensagem enviada.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Notificar quando o Hermes terminar</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Publique uma notificação quando uma resposta for concluída enquanto o app estiver em segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat em segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartilhar o status do celular com o agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Inclua uma breve mensagem do sistema sobre o app e o celular em cada turno do chat</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissões</string>
|
||||
@@ -1118,7 +1130,7 @@
|
||||
<string name="dev_settings_relay_features">Recursos do Relay</string>
|
||||
<string name="dev_settings_relay_features_desc">Mostrar configurações do Servidor Relay e de pareamento para desenvolvimento do Bridge/Terminal</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Laboratório de voz em tempo real</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Abrir a bancada de testes do WebSocket do provedor em versões de desenvolvimento</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Verifique a configuração de voz em tempo real e execute um teste de microfone</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratório de voz em tempo real</string>
|
||||
<string name="dev_settings_lock_dev_options">Bloquear opções do desenvolvedor</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta seção e desativar recursos experimentais</string>
|
||||
@@ -1149,6 +1161,7 @@
|
||||
<string name="dev_settings_export_failed">Falha na exportação</string>
|
||||
<string name="dev_settings_imported">Configurações importadas</string>
|
||||
<string name="dev_settings_import_failed">Falha na importação — arquivo inválido</string>
|
||||
<string name="dev_settings_reset_failed">Falha ao redefinir</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Exportar backup sensível?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Este backup inclui conexões salvas, chaves da API, tokens de sessão do relay, IDs de dispositivos e cookies do painel. Qualquer pessoa com o arquivo poderá acessar seu servidor Hermes.</string>
|
||||
<string name="dev_settings_export_action">Exportar</string>
|
||||
@@ -2126,6 +2139,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirma na próxima mensagem</string>
|
||||
<string name="conn_info_connect">Conectar</string>
|
||||
<string name="conn_info_connected">Conectado</string>
|
||||
<string name="conn_info_active_configuration">Configuração ativa</string>
|
||||
<string name="conn_info_inherited">Herdado</string>
|
||||
<string name="conn_info_bypassed">Ignorado</string>
|
||||
<string name="conn_info_customize_identity">Personalizar identidade</string>
|
||||
<string name="conn_info_agent_passport">Passaporte do agente</string>
|
||||
<string name="conn_info_chat_override">Substituição do chat</string>
|
||||
<string name="conn_info_fast_tier">Nível rápido</string>
|
||||
<string name="conn_info_fast">Rápido</string>
|
||||
<string name="conn_info_context">Contexto</string>
|
||||
<string name="conn_info_profile_already_bypasses">As aprovações já são ignoradas pelo perfil.</string>
|
||||
<string name="conn_info_start_new_chat">Iniciar novo chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Escolha como as aprovações são aplicadas.</string>
|
||||
<string name="conn_info_approval_policy">Política de aprovação</string>
|
||||
<string name="conn_info_connecting">Conectando…</string>
|
||||
<string name="conn_info_connection">Conexão</string>
|
||||
<string name="conn_info_connection_state">Estado da conexão</string>
|
||||
@@ -2453,6 +2479,23 @@
|
||||
<string name="attachment_unmute">Ativar som</string>
|
||||
<string name="attachment_mute">Silenciar</string>
|
||||
<string name="attachment_title">Anexo</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d anexo</item>
|
||||
<item quantity="other">%1$d anexos</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Recolher anexos</string>
|
||||
<string name="attachment_group_expand">Expandir anexos</string>
|
||||
<string name="attachment_group_collapsed">Recolhido</string>
|
||||
<string name="attachment_group_expanded">Expandido</string>
|
||||
<string name="attachment_type_image">Imagem</string>
|
||||
<string name="attachment_type_video">Vídeo</string>
|
||||
<string name="attachment_type_audio">Áudio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Arquivo</string>
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
|
||||
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
|
||||
@@ -3165,4 +3208,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">Fala automaticamente as respostas do assistente em superficies Hermes que respeitam esta configuracao do host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla a sessao de fala ao vivo, nao o modelo de chat Hermes. Latest acompanha atualizacoes do provedor; um modelo versionado fica fixo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">A voz usada dentro da sessao ao vivo. Vozes integradas e personalizadas aparecem quando o provedor as anuncia.</string>
|
||||
<string name="dashboard_component_connected">conectado</string>
|
||||
<string name="dashboard_component_server_errors_5m">erros do servidor / 5 min</string>
|
||||
<string name="appearance_image_generation_style">Geração de imagens</string>
|
||||
<string name="appearance_image_generation_style_desc">Alterne entre as três animações de progresso ou mantenha um estilo em todas as gerações.</string>
|
||||
<string name="appearance_image_generation_rotate">Alternar</string>
|
||||
<string name="appearance_image_generation_grid">Grade</string>
|
||||
<string name="appearance_image_generation_sphere">Esfera</string>
|
||||
<string name="appearance_image_generation_nodes">Nós</string>
|
||||
<string name="dev_settings_image_generation_lab">Laboratório de geração de imagens</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Visualize os ciclos de geração e a revelação final da imagem</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Abrir o laboratório de geração de imagens</string>
|
||||
<string name="dashboard_native_signin_opening">Conclua o login no navegador e volte aqui.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Login pelo navegador cancelado.</string>
|
||||
<string name="dashboard_native_signin_requires_https">O login seguro pelo navegador exige um endereço HTTPS do painel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">O login seguro pelo navegador não está disponível para esta conexão. Atualize a conexão e tente novamente.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Ignore as solicitações de aprovação somente neste chat. A opção é redefinida quando a sessão muda.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">As aprovações do perfil estão desativadas, então este chat já ignora as solicitações. Escolha Manual ou Inteligente antes de usar a exceção por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprovação do perfil</string>
|
||||
<string name="conn_info_approval_mode_desc">Política persistente para este perfil do Hermes. Aplica-se a todos os chats e dispositivos.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Atualize o Hermes para escolher um modo de aprovação do perfil. O YOLO por chat continuará disponível.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Somente leitura para este perfil multiplexado. O modo atual aparece após o início da sessão do perfil; alterá-lo exige RPCs de configuração por perfil do upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Perguntar antes de cada chamada de ferramenta protegida.</string>
|
||||
<string name="conn_info_approval_mode_smart">Inteligente</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Perguntar apenas quando o Hermes detectar risco elevado.</string>
|
||||
<string name="conn_info_approval_mode_off">Desativado</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Ignorar permanentemente as aprovações deste perfil.</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">实时语音</string>
|
||||
<string name="onboarding_realtime_description">提供商原生的实时语音代理,以及按配置文件区分的语音提供商。</string>
|
||||
<string name="onboarding_review_permissions">查看权限</string>
|
||||
<string name="onboarding_finish_setup_title">完成设置</string>
|
||||
<string name="onboarding_finish_setup_description">Hermes 连接已就绪。现在选择这部手机可以使用的功能,或稍后在设置中更改。</string>
|
||||
<string name="onboarding_chat_manage_ready">已就绪 — 无需手机权限。</string>
|
||||
<string name="onboarding_chat_alerts">聊天提醒</string>
|
||||
<string name="onboarding_chat_alerts_description">允许 Android 通知,以便已启用的提醒可在后台送达。</string>
|
||||
<string name="onboarding_chat_alerts_ready">此应用的通知已启用。</string>
|
||||
<string name="onboarding_optional_features">可选功能</string>
|
||||
<string name="onboarding_optional_features_description">相机、麦克风、通知助手和可用的设备工具会保持关闭,直到你主动选择。</string>
|
||||
<string name="onboarding_review_optional_permissions">查看可选权限</string>
|
||||
<string name="onboarding_enable_chat_alerts">启用聊天提醒</string>
|
||||
<string name="onboarding_not_now">暂不</string>
|
||||
<string name="onboarding_finish">完成设置</string>
|
||||
|
||||
<!-- 聊天输入栏 -->
|
||||
<string name="chat_input_live_voice_hint">实时语音对话</string>
|
||||
@@ -660,11 +672,11 @@
|
||||
<string name="settings_profile_lock_desc">将应用固定到一个代理配置文件</string>
|
||||
<string name="settings_quick_controls">快捷控制</string>
|
||||
<string name="settings_persistent_connection">持久连接</string>
|
||||
<string name="settings_persistent_connection_desc">在后台保持与 Hermes 的连接</string>
|
||||
<string name="settings_connect_on_demand">仅按需连接 · 省电</string>
|
||||
<string name="settings_turn_complete_alerts">回合完成提醒</string>
|
||||
<string name="settings_turn_complete_alerts_desc">应用在后台时回复完成则通知</string>
|
||||
<string name="settings_turn_complete_alerts_off">后台回复完成时不提醒</string>
|
||||
<string name="settings_persistent_connection_desc">即使没有聊天运行也保持连接</string>
|
||||
<string name="settings_connect_on_demand">活跃聊天会自动保持连接 · 空闲时关闭连接以节省电量</string>
|
||||
<string name="settings_turn_complete_alerts">聊天提醒</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="settings_turn_complete_alerts_off">不提醒后台聊天活动</string>
|
||||
<string name="settings_keep_connected_deep_sleep">在深度睡眠中保持连接</string>
|
||||
<string name="settings_keep_connected_battery_desc">屏幕关闭一段时间后 Android 仍可能暂停连接(Doze)。允许不受限制的电池使用,让持久连接在后台继续工作。</string>
|
||||
<string name="settings_allow_unrestricted_battery">允许不受限制的电池</string>
|
||||
@@ -694,8 +706,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">在输入栏上方显示最近消息为可点击的标签以便重发。默认关闭。</string>
|
||||
<string name="chat_settings_keep_keyboard_open">发送时保持键盘打开</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">发送后留在输入栏。关闭则在每条消息发送后收起键盘。</string>
|
||||
<string name="chat_settings_notify_when_finishes">Hermes 完成时通知</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">应用在后台时回复完成则发送通知</string>
|
||||
<string name="chat_settings_notify_when_finishes">后台聊天提醒</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="chat_settings_share_phone_status">与代理分享手机状态</string>
|
||||
<string name="chat_settings_share_phone_status_desc">每轮聊天附带一条关于应用和手机的简短系统消息</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 权限</string>
|
||||
@@ -1173,7 +1185,7 @@
|
||||
<string name="dev_settings_relay_features">Relay 功能</string>
|
||||
<string name="dev_settings_relay_features_desc">显示 Relay 服务器和配对设置,用于 Bridge/Terminal 开发</string>
|
||||
<string name="dev_settings_realtime_voice_lab">实时语音实验室</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">为开发构建打开 provider websocket 测试台</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">检查实时语音设置并运行专项麦克风测试</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">打开实时语音实验室</string>
|
||||
<string name="dev_settings_lock_dev_options">锁定开发者选项</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">隐藏此分区并禁用实验性功能</string>
|
||||
@@ -1204,6 +1216,7 @@
|
||||
<string name="dev_settings_export_failed">导出失败</string>
|
||||
<string name="dev_settings_imported">设置已导入</string>
|
||||
<string name="dev_settings_import_failed">导入失败——文件无效</string>
|
||||
<string name="dev_settings_reset_failed">重置失败</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">导出敏感备份?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">此备份包含已保存的连接、API 密钥、Relay 会话令牌、设备 ID 和仪表盘 Cookie。任何持有该文件的人都可能访问您的 Hermes 服务器。</string>
|
||||
<string name="dev_settings_export_action">导出</string>
|
||||
@@ -2220,6 +2233,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">下条消息确认</string>
|
||||
<string name="conn_info_connect">连接</string>
|
||||
<string name="conn_info_connected">已连接</string>
|
||||
<string name="conn_info_active_configuration">当前配置</string>
|
||||
<string name="conn_info_inherited">继承</string>
|
||||
<string name="conn_info_bypassed">已绕过</string>
|
||||
<string name="conn_info_customize_identity">自定义身份</string>
|
||||
<string name="conn_info_agent_passport">智能体档案</string>
|
||||
<string name="conn_info_chat_override">对话覆盖</string>
|
||||
<string name="conn_info_fast_tier">快速层级</string>
|
||||
<string name="conn_info_fast">快速</string>
|
||||
<string name="conn_info_context">上下文</string>
|
||||
<string name="conn_info_profile_already_bypasses">此配置已绕过审批。</string>
|
||||
<string name="conn_info_start_new_chat">开始新对话</string>
|
||||
<string name="conn_info_approval_mode_short_desc">选择如何执行审批。</string>
|
||||
<string name="conn_info_approval_policy">审批策略</string>
|
||||
<string name="conn_info_connecting">连接中…</string>
|
||||
<string name="conn_info_connection">连接</string>
|
||||
<string name="conn_info_connection_state">连接状态</string>
|
||||
@@ -2571,6 +2597,22 @@
|
||||
<string name="attachment_unmute">取消静音</string>
|
||||
<string name="attachment_mute">静音</string>
|
||||
<string name="attachment_title">附件</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="other">%1$d 个附件</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">收起附件</string>
|
||||
<string name="attachment_group_expand">展开附件</string>
|
||||
<string name="attachment_group_collapsed">已收起</string>
|
||||
<string name="attachment_group_expanded">已展开</string>
|
||||
<string name="attachment_type_image">图片</string>
|
||||
<string name="attachment_type_video">视频</string>
|
||||
<string name="attachment_type_audio">音频</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">文本</string>
|
||||
<string name="attachment_type_file">文件</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay 意外关闭</string>
|
||||
@@ -3259,4 +3301,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
|
||||
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">实时会话中使用的语音。当提供商公布内置或自定义语音时,它们会显示出来。</string>
|
||||
<string name="dashboard_component_connected">已连接</string>
|
||||
<string name="dashboard_component_server_errors_5m">服务器错误 / 5 分钟</string>
|
||||
<string name="appearance_image_generation_style">图像生成</string>
|
||||
<string name="appearance_image_generation_style_desc">轮换使用三种进度动画,或让每次生成都使用同一种样式。</string>
|
||||
<string name="appearance_image_generation_rotate">轮换</string>
|
||||
<string name="appearance_image_generation_grid">网格</string>
|
||||
<string name="appearance_image_generation_sphere">球体</string>
|
||||
<string name="appearance_image_generation_nodes">节点</string>
|
||||
<string name="dev_settings_image_generation_lab">图像生成实验室</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">预览生成循环和最终图像显现效果</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">打开图像生成实验室</string>
|
||||
<string name="dashboard_native_signin_opening">请在浏览器中完成登录,然后返回此处。</string>
|
||||
<string name="dashboard_native_signin_cancelled">已取消浏览器登录。</string>
|
||||
<string name="dashboard_native_signin_requires_https">安全浏览器登录需要 HTTPS 控制面板地址。</string>
|
||||
<string name="dashboard_native_signin_unavailable">此连接无法使用安全浏览器登录。请刷新连接后重试。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">仅在此聊天中跳过批准提示。会在会话更改时重置。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">配置文件批准已关闭,因此此聊天已经会跳过提示。使用单聊天例外前,请选择手动或智能。</string>
|
||||
<string name="conn_info_approval_mode_title">配置文件批准模式</string>
|
||||
<string name="conn_info_approval_mode_desc">此 Hermes 配置文件的持久策略。适用于所有聊天和设备。</string>
|
||||
<string name="conn_info_approval_mode_unsupported">请更新 Hermes 以选择配置文件批准模式。单聊天 YOLO 仍可使用。</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">此多路复用配置文件为只读。配置文件会话启动后会显示当前模式;更改模式需要 upstream 提供按配置文件划分的配置 RPC。</string>
|
||||
<string name="conn_info_approval_mode_manual">手动</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">每次调用受保护工具前都询问。</string>
|
||||
<string name="conn_info_approval_mode_smart">智能</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">仅在 Hermes 检测到较高风险时询问。</string>
|
||||
<string name="conn_info_approval_mode_off">关闭</string>
|
||||
<string name="conn_info_approval_mode_off_desc">始终跳过此配置文件的批准。</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">Echtzeit</string>
|
||||
<string name="onboarding_realtime_description">Anbietereigener Echtzeit-Sprachagent und profilbezogene Sprachanbieter.</string>
|
||||
<string name="onboarding_review_permissions">Berechtigungen prüfen</string>
|
||||
<string name="onboarding_finish_setup_title">Einrichtung abschließen</string>
|
||||
<string name="onboarding_finish_setup_description">Deine Hermes-Verbindung ist bereit. Wähle jetzt aus, was dieses Telefon tun darf, oder ändere es später in den Einstellungen.</string>
|
||||
<string name="onboarding_chat_manage_ready">Bereit — keine Telefonberechtigung erforderlich.</string>
|
||||
<string name="onboarding_chat_alerts">Chat-Benachrichtigungen</string>
|
||||
<string name="onboarding_chat_alerts_description">Erlaube Android-Benachrichtigungen, damit aktivierte Hinweise dich im Hintergrund erreichen.</string>
|
||||
<string name="onboarding_chat_alerts_ready">Benachrichtigungen sind für diese App aktiviert.</string>
|
||||
<string name="onboarding_optional_features">Optionale Funktionen</string>
|
||||
<string name="onboarding_optional_features_description">Kamera, Mikrofon, Benachrichtigungsbegleiter und verfügbare Gerätewerkzeuge bleiben aus, bis du sie auswählst.</string>
|
||||
<string name="onboarding_review_optional_permissions">Optionale Berechtigungen prüfen</string>
|
||||
<string name="onboarding_enable_chat_alerts">Chat-Benachrichtigungen aktivieren</string>
|
||||
<string name="onboarding_not_now">Jetzt nicht</string>
|
||||
<string name="onboarding_finish">Einrichtung abschließen</string>
|
||||
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">Live-Sprachgespräch</string>
|
||||
@@ -660,11 +672,11 @@
|
||||
<string name="settings_profile_lock_desc">App auf ein Agentenprofil festlegen</string>
|
||||
<string name="settings_quick_controls">Schnellsteuerung</string>
|
||||
<string name="settings_persistent_connection">Dauerhafte Verbindung</string>
|
||||
<string name="settings_persistent_connection_desc">Verbindung zu Hermes im Hintergrund offen halten</string>
|
||||
<string name="settings_connect_on_demand">Nur bei Bedarf verbinden · spart Akku</string>
|
||||
<string name="settings_turn_complete_alerts">Hinweise bei Abschluss</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
|
||||
<string name="settings_turn_complete_alerts_off">Kein Hinweis, wenn eine Antwort im Hintergrund abgeschlossen wird</string>
|
||||
<string name="settings_persistent_connection_desc">Auch ohne laufenden Chat verbunden bleiben</string>
|
||||
<string name="settings_connect_on_demand">Aktive Chats bleiben automatisch verbunden · Leerlaufverbindung wird geschlossen, um Akku zu sparen</string>
|
||||
<string name="settings_turn_complete_alerts">Chat-Benachrichtigungen</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="settings_turn_complete_alerts_off">Keine Benachrichtigungen für Chat-Aktivität im Hintergrund</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Im Tiefschlaf verbunden bleiben</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android kann die Verbindung weiterhin pausieren, nachdem der Bildschirm eine Weile ausgeschaltet war (Doze). Erlaube uneingeschränkte Akkunutzung, damit die dauerhafte Verbindung im Hintergrund funktioniert.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Uneingeschränkte Akkunutzung erlauben</string>
|
||||
@@ -694,8 +706,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Letzte Nachrichten als antippbare Chips über dem Eingabefeld anzeigen, um sie erneut zu senden. Standardmäßig aus.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Tastatur nach dem Senden geöffnet lassen</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Nach dem Senden im Eingabefeld bleiben. Ausschalten, um die Tastatur nach jeder gesendeten Nachricht zu schließen.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Benachrichtigen, wenn Hermes fertig ist</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigung senden, wenn eine Antwort abgeschlossen wird, während die App im Hintergrund ist</string>
|
||||
<string name="chat_settings_notify_when_finishes">Chat-Benachrichtigungen im Hintergrund</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="chat_settings_share_phone_status">Smartphone-Status mit Agent teilen</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Bei jedem Chatdurchlauf eine kurze Systemnachricht über App und Smartphone einfügen</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + Berechtigungen</string>
|
||||
@@ -1176,7 +1188,7 @@
|
||||
<string name="dev_settings_relay_features">Relay-Funktionen</string>
|
||||
<string name="dev_settings_relay_features_desc">Relay-Server- und Kopplungseinstellungen für Bridge-/Terminal-Entwicklung anzeigen</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Echtzeit-Sprachlabor</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">WebSocket-Testumgebung des Anbieters für Entwicklungs-Builds öffnen</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Echtzeit-Sprachkonfiguration prüfen und einen gezielten Mikrofontest ausführen</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Echtzeit-Sprachlabor öffnen</string>
|
||||
<string name="dev_settings_lock_dev_options">Entwickleroptionen sperren</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Diesen Abschnitt ausblenden und experimentelle Funktionen deaktivieren</string>
|
||||
@@ -1207,6 +1219,7 @@
|
||||
<string name="dev_settings_export_failed">Export fehlgeschlagen</string>
|
||||
<string name="dev_settings_imported">Einstellungen importiert</string>
|
||||
<string name="dev_settings_import_failed">Import fehlgeschlagen — ungültige Datei</string>
|
||||
<string name="dev_settings_reset_failed">Zurücksetzen fehlgeschlagen</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Vertrauliche Sicherung exportieren?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Diese Sicherung enthält gespeicherte Verbindungen, API-Schlüssel, Relay-Sitzungstoken, Geräte-IDs und Dashboard-Cookies. Jede Person mit dieser Datei kann möglicherweise auf deinen Hermes-Server zugreifen.</string>
|
||||
<string name="dev_settings_export_action">Exportieren</string>
|
||||
@@ -2225,6 +2238,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Bei nächster Nachricht bestätigen</string>
|
||||
<string name="conn_info_connect">Verbinden</string>
|
||||
<string name="conn_info_connected">Verbunden</string>
|
||||
<string name="conn_info_active_configuration">Aktive Konfiguration</string>
|
||||
<string name="conn_info_inherited">Geerbt</string>
|
||||
<string name="conn_info_bypassed">Umgangen</string>
|
||||
<string name="conn_info_customize_identity">Identität anpassen</string>
|
||||
<string name="conn_info_agent_passport">Agentenpass</string>
|
||||
<string name="conn_info_chat_override">Chat-Überschreibung</string>
|
||||
<string name="conn_info_fast_tier">Schnellstufe</string>
|
||||
<string name="conn_info_fast">Schnell</string>
|
||||
<string name="conn_info_context">Kontext</string>
|
||||
<string name="conn_info_profile_already_bypasses">Genehmigungen werden bereits vom Profil umgangen.</string>
|
||||
<string name="conn_info_start_new_chat">Neuen Chat starten</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Wähle, wie Genehmigungen erzwungen werden.</string>
|
||||
<string name="conn_info_approval_policy">Genehmigungsrichtlinie</string>
|
||||
<string name="conn_info_connecting">Verbindung wird hergestellt…</string>
|
||||
<string name="conn_info_connection">Verbindung</string>
|
||||
<string name="conn_info_connection_state">Verbindungsstatus</string>
|
||||
@@ -2574,6 +2600,23 @@
|
||||
<string name="attachment_unmute">Ton an</string>
|
||||
<string name="attachment_mute">Stummschalten</string>
|
||||
<string name="attachment_title">Anhang</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d Anhang</item>
|
||||
<item quantity="other">%1$d Anhänge</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Anhänge einklappen</string>
|
||||
<string name="attachment_group_expand">Anhänge ausklappen</string>
|
||||
<string name="attachment_group_collapsed">Eingeklappt</string>
|
||||
<string name="attachment_group_expanded">Ausgeklappt</string>
|
||||
<string name="attachment_type_image">Bild</string>
|
||||
<string name="attachment_type_video">Video</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Text</string>
|
||||
<string name="attachment_type_file">Datei</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
|
||||
@@ -3325,4 +3368,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">Spricht Assistentenantworten automatisch auf Hermes-Oberflaechen, die diese Host-Einstellung beachten.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Steuert die Live-Sprachsitzung, nicht das Hermes-Chatmodell. Latest folgt Anbieter-Upgrades; ein versioniertes Modell bleibt festgelegt.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">Die Stimme innerhalb der Live-Sitzung. Eingebaute und benutzerdefinierte Stimmen erscheinen, wenn der Anbieter sie meldet.</string>
|
||||
<string name="dashboard_component_connected">verbunden</string>
|
||||
<string name="dashboard_component_server_errors_5m">Serverfehler / 5 Min.</string>
|
||||
<string name="appearance_image_generation_style">Bildgenerierung</string>
|
||||
<string name="appearance_image_generation_style_desc">Wechsle zwischen allen drei Fortschrittsanimationen oder verwende bei jeder Generierung denselben Stil.</string>
|
||||
<string name="appearance_image_generation_rotate">Wechseln</string>
|
||||
<string name="appearance_image_generation_grid">Raster</string>
|
||||
<string name="appearance_image_generation_sphere">Kugel</string>
|
||||
<string name="appearance_image_generation_nodes">Knoten</string>
|
||||
<string name="dev_settings_image_generation_lab">Bildgenerierungs-Labor</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Generierungsschleifen und die abschließende Bildenthüllung als Vorschau anzeigen</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Bildgenerierungs-Labor öffnen</string>
|
||||
<string name="dashboard_native_signin_opening">Schließe die Anmeldung im Browser ab und kehre dann hierher zurück.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Browser-Anmeldung abgebrochen.</string>
|
||||
<string name="dashboard_native_signin_requires_https">Die sichere Browser-Anmeldung erfordert eine HTTPS-Dashboard-Adresse.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Die sichere Browser-Anmeldung ist für diese Verbindung nicht verfügbar. Aktualisiere die Verbindung und versuche es erneut.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Umgehe Bestätigungsabfragen nur für diesen Chat. Wird beim Sitzungswechsel zurückgesetzt.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Profilbestätigungen sind deaktiviert, daher umgeht dieser Chat bereits Abfragen. Wähle Manuell oder Smart, bevor du die chatbezogene Ausnahme verwendest.</string>
|
||||
<string name="conn_info_approval_mode_title">Profil-Bestätigungsmodus</string>
|
||||
<string name="conn_info_approval_mode_desc">Dauerhafte Richtlinie für dieses Hermes-Profil. Gilt für alle Chats und Geräte.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Aktualisiere Hermes, um einen Profil-Bestätigungsmodus auszuwählen. Chatbezogenes YOLO bleibt verfügbar.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Schreibgeschützt für dieses multiplexte Profil. Der aktuelle Modus erscheint nach dem Start der Profilsitzung; Änderungen erfordern profilbezogene Konfigurations-RPCs von Upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manuell</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Vor jedem geschützten Werkzeugaufruf nachfragen.</string>
|
||||
<string name="conn_info_approval_mode_smart">Smart</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Nur nachfragen, wenn Hermes ein erhöhtes Risiko erkennt.</string>
|
||||
<string name="conn_info_approval_mode_off">Aus</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Bestätigungen für dieses Profil dauerhaft umgehen.</string>
|
||||
</resources>
|
||||
|
||||
@@ -51,6 +51,18 @@
|
||||
<string name="onboarding_realtime_label">En tiempo real</string>
|
||||
<string name="onboarding_realtime_description">Agente de voz en tiempo real nativo del proveedor y proveedores de voz con reconocimiento de perfil.</string>
|
||||
<string name="onboarding_review_permissions">Revisar permisos</string>
|
||||
<string name="onboarding_finish_setup_title">Finalizar configuración</string>
|
||||
<string name="onboarding_finish_setup_description">Tu conexión con Hermes está lista. Elige ahora lo que puede hacer este teléfono o cámbialo más tarde en Ajustes.</string>
|
||||
<string name="onboarding_chat_manage_ready">Listo — no se necesitan permisos del teléfono.</string>
|
||||
<string name="onboarding_chat_alerts">Alertas de chat</string>
|
||||
<string name="onboarding_chat_alerts_description">Permite las notificaciones de Android para que las alertas activadas te lleguen en segundo plano.</string>
|
||||
<string name="onboarding_chat_alerts_ready">Las notificaciones están activadas para esta aplicación.</string>
|
||||
<string name="onboarding_optional_features">Funciones opcionales</string>
|
||||
<string name="onboarding_optional_features_description">La cámara, el micrófono, el asistente de notificaciones y las herramientas disponibles del dispositivo permanecen desactivados hasta que los elijas.</string>
|
||||
<string name="onboarding_review_optional_permissions">Revisar permisos opcionales</string>
|
||||
<string name="onboarding_enable_chat_alerts">Activar alertas de chat</string>
|
||||
<string name="onboarding_not_now">Ahora no</string>
|
||||
<string name="onboarding_finish">Finalizar configuración</string>
|
||||
<string name="chat_input_live_voice_hint">Conversación de voz en vivo</string>
|
||||
<string name="chat_input_add_attachment">Agregar archivo adjunto</string>
|
||||
<string name="chat_input_browse_commands">Comandos de exploración</string>
|
||||
@@ -587,11 +599,11 @@
|
||||
<string name="settings_profile_lock_desc">Anclar la aplicación a un perfil de agente</string>
|
||||
<string name="settings_quick_controls">Controles rápidos</string>
|
||||
<string name="settings_persistent_connection">Conexión persistente</string>
|
||||
<string name="settings_persistent_connection_desc">Mantener abierta su conexión a Hermes en segundo plano</string>
|
||||
<string name="settings_connect_on_demand">Conexión solo bajo demanda · ahorra batería</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de turno completo</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notificar cuando finaliza una respuesta mientras la aplicación está en segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">No hay alerta cuando finaliza una respuesta en segundo plano</string>
|
||||
<string name="settings_persistent_connection_desc">Mantener la conexión aunque no haya ningún chat en curso</string>
|
||||
<string name="settings_connect_on_demand">Los chats activos siguen conectados automáticamente · la conexión inactiva se cierra para ahorrar batería</string>
|
||||
<string name="settings_turn_complete_alerts">Alertas de chat</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="settings_turn_complete_alerts_off">Sin alertas de actividad de chat en segundo plano</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Mantenlo conectado mientras duermes profundamente</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android todavía puede pausar la conexión una vez que la pantalla ha estado apagada por un tiempo (Doze). Permita la batería sin restricciones para que la conexión persistente siga funcionando en segundo plano.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Permitir batería sin restricciones</string>
|
||||
@@ -619,8 +631,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Muestre sus mensajes recientes como chips que se pueden tocar encima del redactor para enviarlos nuevamente. Desactivado de forma predeterminada.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Mantener el teclado abierto al enviar</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Permanece en el compositor después del envío. Desactívelo para descartar el teclado después de cada mensaje enviado.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Notificar cuando finalice Hermes</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Publicar una notificación cuando se complete una respuesta mientras la aplicación está en segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat en segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartir el estado del teléfono con el agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluya un breve mensaje del sistema sobre la aplicación y el teléfono en cada turno de chat.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permisos</string>
|
||||
@@ -1064,7 +1076,7 @@
|
||||
<string name="dev_settings_relay_features">Características de Relay</string>
|
||||
<string name="dev_settings_relay_features_desc">Mostrar el servidor Relay y la configuración de emparejamiento para el desarrollo de Bridge/Terminal</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Abra el banco de pruebas del proveedor websocket para compilaciones de desarrollo.</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Revisa la configuración de voz en tiempo real y ejecuta una prueba de micrófono</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Abrir laboratorio de voz en tiempo real</string>
|
||||
<string name="dev_settings_lock_dev_options">Bloquear opciones de desarrollador</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Ocultar esta sección y desactivar las funciones experimentales</string>
|
||||
@@ -1095,6 +1107,7 @@
|
||||
<string name="dev_settings_export_failed">Exportación fallida</string>
|
||||
<string name="dev_settings_imported">Configuraciones importadas</string>
|
||||
<string name="dev_settings_import_failed">Error al importar: archivo no válido</string>
|
||||
<string name="dev_settings_reset_failed">Error al restablecer</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">¿Exportar copia de seguridad confidencial?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">Esta copia de seguridad incluye conexiones guardadas, claves API, tokens de sesión relay, ID de dispositivos y cookies del panel. Cualquier persona que tenga el archivo podrá acceder a su servidor Hermes.</string>
|
||||
<string name="dev_settings_export_action">Exportar</string>
|
||||
@@ -2031,6 +2044,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirma en el siguiente mensaje</string>
|
||||
<string name="conn_info_connect">Conectar</string>
|
||||
<string name="conn_info_connected">Conectado</string>
|
||||
<string name="conn_info_active_configuration">Configuración activa</string>
|
||||
<string name="conn_info_inherited">Heredado</string>
|
||||
<string name="conn_info_bypassed">Omitido</string>
|
||||
<string name="conn_info_customize_identity">Personalizar identidad</string>
|
||||
<string name="conn_info_agent_passport">Pasaporte del agente</string>
|
||||
<string name="conn_info_chat_override">Anulación del chat</string>
|
||||
<string name="conn_info_fast_tier">Nivel rápido</string>
|
||||
<string name="conn_info_fast">Rápido</string>
|
||||
<string name="conn_info_context">Contexto</string>
|
||||
<string name="conn_info_profile_already_bypasses">El perfil ya omite las aprobaciones.</string>
|
||||
<string name="conn_info_start_new_chat">Iniciar nuevo chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Elige cómo se aplican las aprobaciones.</string>
|
||||
<string name="conn_info_approval_policy">Política de aprobación</string>
|
||||
<string name="conn_info_connecting">Conectando…</string>
|
||||
<string name="conn_info_connection">Conexión</string>
|
||||
<string name="conn_info_connection_state">Estado de conexión</string>
|
||||
@@ -2330,6 +2356,23 @@
|
||||
<string name="attachment_unmute">Dejar de silenciar</string>
|
||||
<string name="attachment_mute">Silenciar</string>
|
||||
<string name="attachment_title">Adjunto</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d archivo adjunto</item>
|
||||
<item quantity="other">%1$d archivos adjuntos</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Contraer archivos adjuntos</string>
|
||||
<string name="attachment_group_expand">Expandir archivos adjuntos</string>
|
||||
<string name="attachment_group_collapsed">Contraído</string>
|
||||
<string name="attachment_group_expanded">Expandido</string>
|
||||
<string name="attachment_type_image">Imagen</string>
|
||||
<string name="attachment_type_video">Vídeo</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Archivo</string>
|
||||
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
|
||||
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
|
||||
<string name="crash_dismiss">Descartar</string>
|
||||
@@ -3010,4 +3053,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">Habla automáticamente las respuestas del asistente en las superficies de Hermes que respetan este ajuste del host.</string>
|
||||
<string name="voice_settings_realtime_model_desc">Controla la sesión de voz en vivo, no el modelo de chat de Hermes. Latest sigue las actualizaciones del proveedor; un modelo con versión queda fijo.</string>
|
||||
<string name="voice_settings_realtime_voice_desc">La voz usada dentro de la sesión en vivo. Las voces integradas y personalizadas aparecen cuando el proveedor las anuncia.</string>
|
||||
<string name="dashboard_component_connected">conectado</string>
|
||||
<string name="dashboard_component_server_errors_5m">errores del servidor / 5 min</string>
|
||||
<string name="appearance_image_generation_style">Generación de imágenes</string>
|
||||
<string name="appearance_image_generation_style_desc">Alterna entre las tres animaciones de progreso o conserva un estilo para cada generación.</string>
|
||||
<string name="appearance_image_generation_rotate">Alternar</string>
|
||||
<string name="appearance_image_generation_grid">Cuadrícula</string>
|
||||
<string name="appearance_image_generation_sphere">Esfera</string>
|
||||
<string name="appearance_image_generation_nodes">Nodos</string>
|
||||
<string name="dev_settings_image_generation_lab">Laboratorio de generación de imágenes</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Previsualiza los ciclos de generación y la revelación final de la imagen</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Abrir el laboratorio de generación de imágenes</string>
|
||||
<string name="dashboard_native_signin_opening">Completa el inicio de sesión en el navegador y vuelve aquí.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Inicio de sesión en el navegador cancelado.</string>
|
||||
<string name="dashboard_native_signin_requires_https">El inicio de sesión seguro en el navegador requiere una dirección HTTPS del panel.</string>
|
||||
<string name="dashboard_native_signin_unavailable">El inicio de sesión seguro en el navegador no está disponible para esta conexión. Actualiza la conexión e inténtalo de nuevo.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Omite las solicitudes de aprobación solo para este chat. Se restablece al cambiar de sesión.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Las aprobaciones del perfil están desactivadas, por lo que este chat ya omite las solicitudes. Elige Manual o Inteligente antes de usar la excepción por chat.</string>
|
||||
<string name="conn_info_approval_mode_title">Modo de aprobación del perfil</string>
|
||||
<string name="conn_info_approval_mode_desc">Política persistente para este perfil de Hermes. Se aplica a todos los chats y dispositivos.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Actualiza Hermes para elegir un modo de aprobación del perfil. YOLO por chat seguirá disponible.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Solo lectura para este perfil multiplexado. El modo actual aparece al iniciar la sesión del perfil; cambiarlo requiere RPC de configuración por perfil de upstream.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Preguntar antes de cada llamada a una herramienta protegida.</string>
|
||||
<string name="conn_info_approval_mode_smart">Inteligente</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Preguntar solo cuando Hermes detecte un riesgo elevado.</string>
|
||||
<string name="conn_info_approval_mode_off">Desactivado</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Omitir permanentemente las aprobaciones para este perfil.</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">リアルタイム</string>
|
||||
<string name="onboarding_realtime_description">プロバイダーネイティブのリアルタイム音声エージェントとプロファイル認識音声プロバイダー。</string>
|
||||
<string name="onboarding_review_permissions">権限の確認</string>
|
||||
<string name="onboarding_finish_setup_title">セットアップを完了</string>
|
||||
<string name="onboarding_finish_setup_description">Hermes への接続準備ができました。このスマートフォンで許可する機能を選ぶか、後で設定から変更できます。</string>
|
||||
<string name="onboarding_chat_manage_ready">準備完了 — スマートフォンの権限は必要ありません。</string>
|
||||
<string name="onboarding_chat_alerts">チャット通知</string>
|
||||
<string name="onboarding_chat_alerts_description">有効にした通知をバックグラウンドでも受け取れるよう、Android の通知を許可します。</string>
|
||||
<string name="onboarding_chat_alerts_ready">このアプリの通知は有効です。</string>
|
||||
<string name="onboarding_optional_features">オプション機能</string>
|
||||
<string name="onboarding_optional_features_description">カメラ、マイク、通知コンパニオン、利用可能な端末ツールは、選択するまで無効のままです。</string>
|
||||
<string name="onboarding_review_optional_permissions">オプション権限を確認</string>
|
||||
<string name="onboarding_enable_chat_alerts">チャット通知を有効にする</string>
|
||||
<string name="onboarding_not_now">今はしない</string>
|
||||
<string name="onboarding_finish">セットアップを完了</string>
|
||||
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">ライブ音声会話</string>
|
||||
@@ -660,11 +672,11 @@
|
||||
<string name="settings_profile_lock_desc">アプリを 1 つのエージェント プロファイルに固定する</string>
|
||||
<string name="settings_quick_controls">クイックコントロール</string>
|
||||
<string name="settings_persistent_connection">永続的な接続</string>
|
||||
<string name="settings_persistent_connection_desc">Hermes への接続をバックグラウンドで開いたままにします</string>
|
||||
<string name="settings_connect_on_demand">オンデマンドのみに接続します · バッテリーを節約します</string>
|
||||
<string name="settings_turn_complete_alerts">ターン完了アラート</string>
|
||||
<string name="settings_turn_complete_alerts_desc">アプリがバックグラウンドで動作しているときに返信が完了したときに通知する</string>
|
||||
<string name="settings_turn_complete_alerts_off">バックグラウンドでの返信が終了してもアラートは表示されません</string>
|
||||
<string name="settings_persistent_connection_desc">チャットが実行されていないときも接続を維持</string>
|
||||
<string name="settings_connect_on_demand">アクティブなチャットは自動的に接続を維持 · アイドル時はバッテリー節約のため接続を閉じます</string>
|
||||
<string name="settings_turn_complete_alerts">チャット通知</string>
|
||||
<string name="settings_turn_complete_alerts_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="settings_turn_complete_alerts_off">バックグラウンドのチャット動作を通知しません</string>
|
||||
<string name="settings_keep_connected_deep_sleep">深い睡眠中も接続を維持する</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android は、画面がしばらくオフになった後でも接続を一時停止できます (Doze)。永続的な接続がバックグラウンドで動作し続けるように、無制限のバッテリーを許可します。</string>
|
||||
<string name="settings_allow_unrestricted_battery">無制限のバッテリーを許可する</string>
|
||||
@@ -694,8 +706,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">最近のメッセージをコンポーザーの上にタップ可能なチップとして表示し、再送信します。デフォルトではオフです。</string>
|
||||
<string name="chat_settings_keep_keyboard_open">送信時にキーボードを開いたままにする</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">送信後はコンポーザー内に留まります。メッセージを送信するたびにキーボードを閉じるには、オフにします。</string>
|
||||
<string name="chat_settings_notify_when_finishes">Hermes が終了したら通知する</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">アプリがバックグラウンドで動作中に返信が完了したときに通知を投稿する</string>
|
||||
<string name="chat_settings_notify_when_finishes">バックグラウンドのチャット通知</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="chat_settings_share_phone_status">電話のステータスをエージェントと共有する</string>
|
||||
<string name="chat_settings_share_phone_status_desc">すべてのチャット ターンにアプリと電話に関する短いシステム メッセージを含めます</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 権限</string>
|
||||
@@ -1189,7 +1201,7 @@
|
||||
<string name="dev_settings_relay_features">Relay の特徴</string>
|
||||
<string name="dev_settings_relay_features_desc">Relay サーバーと Bridge/ターミナル開発用のペアリング設定を表示</string>
|
||||
<string name="dev_settings_realtime_voice_lab">リアルタイム音声ラボ</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">開発ビルド用のプロバイダー WebSocket テストベンチを開きます</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">リアルタイム音声の設定を確認し、マイクの集中テストを実行します</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">リアルタイム音声ラボを開く</string>
|
||||
<string name="dev_settings_lock_dev_options">開発者向けオプションをロックする</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">このセクションを非表示にし、実験的な機能を無効にします</string>
|
||||
@@ -1220,6 +1232,7 @@
|
||||
<string name="dev_settings_export_failed">エクスポートに失敗しました</string>
|
||||
<string name="dev_settings_imported">インポートされた設定</string>
|
||||
<string name="dev_settings_import_failed">インポートに失敗しました - 無効なファイル</string>
|
||||
<string name="dev_settings_reset_failed">リセットに失敗しました</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">機密性の高いバックアップをエクスポートしますか?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">このバックアップには、保存された接続、API キー、Relay セッション トークン、デバイス ID、およびダッシュボード Cookie が含まれます。ファイルを持っている人は誰でも、Hermes サーバーにアクセスできる可能性があります。</string>
|
||||
<string name="dev_settings_export_action">輸出</string>
|
||||
@@ -2236,6 +2249,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">次のメッセージで確認します</string>
|
||||
<string name="conn_info_connect">接続する</string>
|
||||
<string name="conn_info_connected">接続済み</string>
|
||||
<string name="conn_info_active_configuration">有効な構成</string>
|
||||
<string name="conn_info_inherited">継承</string>
|
||||
<string name="conn_info_bypassed">バイパス</string>
|
||||
<string name="conn_info_customize_identity">アイデンティティをカスタマイズ</string>
|
||||
<string name="conn_info_agent_passport">エージェントパスポート</string>
|
||||
<string name="conn_info_chat_override">チャットの上書き</string>
|
||||
<string name="conn_info_fast_tier">高速ティア</string>
|
||||
<string name="conn_info_fast">高速</string>
|
||||
<string name="conn_info_context">コンテキスト</string>
|
||||
<string name="conn_info_profile_already_bypasses">承認はプロファイルですでにバイパスされています。</string>
|
||||
<string name="conn_info_start_new_chat">新しいチャットを開始</string>
|
||||
<string name="conn_info_approval_mode_short_desc">承認を適用する方法を選択します。</string>
|
||||
<string name="conn_info_approval_policy">承認ポリシー</string>
|
||||
<string name="conn_info_connecting">接続中…</string>
|
||||
<string name="conn_info_connection">繋がり</string>
|
||||
<string name="conn_info_connection_state">接続状態</string>
|
||||
@@ -2585,6 +2611,22 @@
|
||||
<string name="attachment_unmute">ミュートを解除する</string>
|
||||
<string name="attachment_mute">ミュート</string>
|
||||
<string name="attachment_title">アタッチメント</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="other">添付ファイル %1$d 件</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">添付ファイルを折りたたむ</string>
|
||||
<string name="attachment_group_expand">添付ファイルを展開する</string>
|
||||
<string name="attachment_group_collapsed">折りたたみ済み</string>
|
||||
<string name="attachment_group_expanded">展開済み</string>
|
||||
<string name="attachment_type_image">画像</string>
|
||||
<string name="attachment_type_video">動画</string>
|
||||
<string name="attachment_type_audio">音声</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">テキスト</string>
|
||||
<string name="attachment_type_file">ファイル</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
|
||||
@@ -3325,4 +3367,31 @@
|
||||
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
|
||||
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
|
||||
<string name="voice_settings_realtime_voice_desc">ライブセッション内で使う音声です。プロバイダーが公開している場合、組み込み音声とカスタム音声が表示されます。</string>
|
||||
<string name="dashboard_component_connected">接続済み</string>
|
||||
<string name="dashboard_component_server_errors_5m">サーバーエラー / 5分</string>
|
||||
<string name="appearance_image_generation_style">画像生成</string>
|
||||
<string name="appearance_image_generation_style_desc">3種類の進行アニメーションを順番に使うか、毎回同じスタイルを使用します。</string>
|
||||
<string name="appearance_image_generation_rotate">ローテーション</string>
|
||||
<string name="appearance_image_generation_grid">グリッド</string>
|
||||
<string name="appearance_image_generation_sphere">球体</string>
|
||||
<string name="appearance_image_generation_nodes">ノード</string>
|
||||
<string name="dev_settings_image_generation_lab">画像生成ラボ</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">生成ループと最終画像の表示をプレビューします</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">画像生成ラボを開く</string>
|
||||
<string name="dashboard_native_signin_opening">ブラウザでサインインを完了してから、ここに戻ってください。</string>
|
||||
<string name="dashboard_native_signin_cancelled">ブラウザでのサインインをキャンセルしました。</string>
|
||||
<string name="dashboard_native_signin_requires_https">安全なブラウザサインインには、HTTPSのダッシュボードアドレスが必要です。</string>
|
||||
<string name="dashboard_native_signin_unavailable">この接続では安全なブラウザサインインを利用できません。接続を更新して、もう一度お試しください。</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">このチャットでのみ承認確認を省略します。セッションが変わるとリセットされます。</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">プロフィールの承認がオフのため、このチャットではすでに確認を省略しています。チャット単位の例外を使う前に、手動またはスマートを選択してください。</string>
|
||||
<string name="conn_info_approval_mode_title">プロフィール承認モード</string>
|
||||
<string name="conn_info_approval_mode_desc">このHermesプロフィールに対する永続的なポリシーです。すべてのチャットとデバイスに適用されます。</string>
|
||||
<string name="conn_info_approval_mode_unsupported">プロフィール承認モードを選択するにはHermesを更新してください。チャット単位のYOLOは引き続き利用できます。</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">この多重化プロフィールでは読み取り専用です。現在のモードはプロフィールセッション開始後に表示され、変更にはupstreamのプロフィール別設定RPCが必要です。</string>
|
||||
<string name="conn_info_approval_mode_manual">手動</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">保護されたツール呼び出しのたびに確認します。</string>
|
||||
<string name="conn_info_approval_mode_smart">スマート</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Hermesが高いリスクを検出した場合にのみ確認します。</string>
|
||||
<string name="conn_info_approval_mode_off">オフ</string>
|
||||
<string name="conn_info_approval_mode_off_desc">このプロフィールの承認を常に省略します。</string>
|
||||
</resources>
|
||||
|
||||
@@ -56,6 +56,18 @@
|
||||
<string name="onboarding_realtime_label">Realtime</string>
|
||||
<string name="onboarding_realtime_description">Provider-native realtime voice agent and profile-aware voice providers.</string>
|
||||
<string name="onboarding_review_permissions">Review permissions</string>
|
||||
<string name="onboarding_finish_setup_title">Finish setup</string>
|
||||
<string name="onboarding_finish_setup_description">Your Hermes connection is ready. Choose what this phone can do now, or change it later in Settings.</string>
|
||||
<string name="onboarding_chat_manage_ready">Ready — no phone permission needed.</string>
|
||||
<string name="onboarding_chat_alerts">Chat alerts</string>
|
||||
<string name="onboarding_chat_alerts_description">Allow Android notifications so enabled alerts can reach you in the background.</string>
|
||||
<string name="onboarding_chat_alerts_ready">Notifications are enabled for this app.</string>
|
||||
<string name="onboarding_optional_features">Optional features</string>
|
||||
<string name="onboarding_optional_features_description">Camera, microphone, notification companion, and available device tools stay off until you choose them.</string>
|
||||
<string name="onboarding_review_optional_permissions">Review optional permissions</string>
|
||||
<string name="onboarding_enable_chat_alerts">Enable chat alerts</string>
|
||||
<string name="onboarding_not_now">Not now</string>
|
||||
<string name="onboarding_finish">Finish setup</string>
|
||||
|
||||
<!-- Chat input bar -->
|
||||
<string name="chat_input_live_voice_hint">Live voice conversation</string>
|
||||
@@ -190,6 +202,8 @@
|
||||
<string name="conn_label_session">Session</string>
|
||||
<string name="conn_label_route">Route</string>
|
||||
<string name="conn_label_status">Status</string>
|
||||
<string name="dashboard_component_connected">connected</string>
|
||||
<string name="dashboard_component_server_errors_5m">server errors / 5m</string>
|
||||
<string name="conn_label_connect">Connect</string>
|
||||
<string name="conn_label_connections">Connections</string>
|
||||
<string name="conn_detail_add_connection">Add a Vanilla Hermes API/dashboard connection</string>
|
||||
@@ -677,11 +691,11 @@
|
||||
<string name="settings_profile_lock_desc">Pin the app to one agent profile</string>
|
||||
<string name="settings_quick_controls">Quick Controls</string>
|
||||
<string name="settings_persistent_connection">Persistent connection</string>
|
||||
<string name="settings_persistent_connection_desc">Keeping your connection to Hermes open in the background</string>
|
||||
<string name="settings_connect_on_demand">Connect on demand only · saves battery</string>
|
||||
<string name="settings_turn_complete_alerts">Turn-complete alerts</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notify when a reply finishes while the app is in the background</string>
|
||||
<string name="settings_turn_complete_alerts_off">No alert when a backgrounded reply finishes</string>
|
||||
<string name="settings_persistent_connection_desc">Stay connected even when no chat is running</string>
|
||||
<string name="settings_connect_on_demand">Active chats stay connected automatically · idle connection closes to save battery</string>
|
||||
<string name="settings_turn_complete_alerts">Chat alerts</string>
|
||||
<string name="settings_turn_complete_alerts_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="settings_turn_complete_alerts_off">No alerts for background chat activity</string>
|
||||
<string name="settings_keep_connected_deep_sleep">Keep it connected in deep sleep</string>
|
||||
<string name="settings_keep_connected_battery_desc">Android can still pause the connection once the screen\'s been off a while (Doze). Allow unrestricted battery so Persistent connection keeps working in the background.</string>
|
||||
<string name="settings_allow_unrestricted_battery">Allow unrestricted battery</string>
|
||||
@@ -711,8 +725,8 @@
|
||||
<string name="chat_settings_recent_prompt_chips_desc">Show your recent messages as tappable chips above the composer to send them again. Off by default.</string>
|
||||
<string name="chat_settings_keep_keyboard_open">Keep keyboard open on send</string>
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
|
||||
<string name="chat_settings_notify_when_finishes">Notify when Hermes finishes</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Post a notification when a reply completes while the app is in the background</string>
|
||||
<string name="chat_settings_notify_when_finishes">Background chat alerts</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="chat_settings_share_phone_status">Share phone status with agent</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Include a short system message about the app and phone on every chat turn</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissions</string>
|
||||
@@ -1211,6 +1225,12 @@
|
||||
<string name="appearance_font">Font</string>
|
||||
<string name="appearance_font_desc">Sets the typeface across the whole app. Code and timestamps stay monospaced.</string>
|
||||
<string name="appearance_animation">Animation</string>
|
||||
<string name="appearance_image_generation_style">Image generation</string>
|
||||
<string name="appearance_image_generation_style_desc">Rotate through all three progress animations, or keep one style for every generation.</string>
|
||||
<string name="appearance_image_generation_rotate">Rotate</string>
|
||||
<string name="appearance_image_generation_grid">Grid</string>
|
||||
<string name="appearance_image_generation_sphere">Sphere</string>
|
||||
<string name="appearance_image_generation_nodes">Nodes</string>
|
||||
<string name="appearance_ascii_sphere">ASCII sphere</string>
|
||||
<string name="appearance_ascii_sphere_desc">Show animated sphere on empty chat screen and ambient mode</string>
|
||||
<string name="appearance_behind_messages">Behind messages</string>
|
||||
@@ -1270,8 +1290,11 @@
|
||||
<string name="dev_settings_relay_features">Relay features</string>
|
||||
<string name="dev_settings_relay_features_desc">Show Relay Server and Pairing settings for Bridge/Terminal development</string>
|
||||
<string name="dev_settings_realtime_voice_lab">Realtime voice lab</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Open the provider websocket testbench for dev builds</string>
|
||||
<string name="dev_settings_realtime_voice_lab_desc">Inspect realtime voice setup and run a focused microphone test</string>
|
||||
<string name="dev_settings_open_realtime_voice_lab_cd">Open realtime voice lab</string>
|
||||
<string name="dev_settings_image_generation_lab">Image generation lab</string>
|
||||
<string name="dev_settings_image_generation_lab_desc">Preview generation loops and the final image reveal</string>
|
||||
<string name="dev_settings_open_image_generation_lab_cd">Open image generation lab</string>
|
||||
<string name="dev_settings_lock_dev_options">Lock developer options</string>
|
||||
<string name="dev_settings_lock_dev_options_desc">Hide this section and disable experimental features</string>
|
||||
<string name="dev_settings_locked_toast">Developer options locked</string>
|
||||
@@ -1301,6 +1324,7 @@
|
||||
<string name="dev_settings_export_failed">Export failed</string>
|
||||
<string name="dev_settings_imported">Settings imported</string>
|
||||
<string name="dev_settings_import_failed">Import failed — invalid file</string>
|
||||
<string name="dev_settings_reset_failed">Reset failed</string>
|
||||
<string name="dev_settings_export_sensitive_backup_title">Export sensitive backup?</string>
|
||||
<string name="dev_settings_export_sensitive_backup_body">This backup includes saved connections, API keys, relay session tokens, device IDs, and dashboard cookies. Anyone with the file may be able to access your Hermes server.</string>
|
||||
<string name="dev_settings_export_action">Export</string>
|
||||
@@ -1838,6 +1862,10 @@
|
||||
<string name="dashboard_oauth_verifying">Verifying dashboard session…</string>
|
||||
<string name="dashboard_oauth_not_accepted">Sign-in was not accepted yet. Finish the dashboard flow to continue.</string>
|
||||
<string name="dashboard_oauth_verify_failed">Dashboard sign-in verification failed</string>
|
||||
<string name="dashboard_native_signin_opening">Complete sign-in in your browser, then return here.</string>
|
||||
<string name="dashboard_native_signin_cancelled">Browser sign-in cancelled.</string>
|
||||
<string name="dashboard_native_signin_requires_https">Secure browser sign-in requires an HTTPS dashboard address.</string>
|
||||
<string name="dashboard_native_signin_unavailable">Secure browser sign-in is unavailable for this connection. Refresh the connection and try again.</string>
|
||||
<string name="dashboard_close_signin">Close sign-in</string>
|
||||
|
||||
<!-- Error body -->
|
||||
@@ -2330,6 +2358,19 @@
|
||||
<string name="conn_info_confirms_on_next_message">Confirms on next message</string>
|
||||
<string name="conn_info_connect">Connect</string>
|
||||
<string name="conn_info_connected">Connected</string>
|
||||
<string name="conn_info_active_configuration">Active configuration</string>
|
||||
<string name="conn_info_inherited">Inherited</string>
|
||||
<string name="conn_info_bypassed">Bypassed</string>
|
||||
<string name="conn_info_customize_identity">Customize identity</string>
|
||||
<string name="conn_info_agent_passport">Agent Passport</string>
|
||||
<string name="conn_info_chat_override">Chat override</string>
|
||||
<string name="conn_info_fast_tier">Fast tier</string>
|
||||
<string name="conn_info_fast">Fast</string>
|
||||
<string name="conn_info_context">Context</string>
|
||||
<string name="conn_info_profile_already_bypasses">Approvals already bypassed by profile.</string>
|
||||
<string name="conn_info_start_new_chat">Start new chat</string>
|
||||
<string name="conn_info_approval_mode_short_desc">Choose how approvals are enforced.</string>
|
||||
<string name="conn_info_approval_policy">Approval policy</string>
|
||||
<string name="conn_info_connecting">Connecting…</string>
|
||||
<string name="conn_info_connection">Connection</string>
|
||||
<string name="conn_info_connection_state">Connection state</string>
|
||||
@@ -2450,7 +2491,19 @@
|
||||
<string name="conn_info_yes">Yes</string>
|
||||
<string name="conn_info_yes_hidden">Yes (hidden)</string>
|
||||
<string name="conn_info_yolo_mode_desc">Bypass approval prompts for tool calls.</string>
|
||||
<string name="conn_info_yolo_mode_desc_ephemeral">Bypass approval prompts for this chat only. Resets when the session changes.</string>
|
||||
<string name="conn_info_yolo_mode_profile_off">Profile approvals are Off, so this chat already bypasses prompts. Choose Manual or Smart before using the per-chat override.</string>
|
||||
<string name="conn_info_yolo_mode_title">YOLO mode</string>
|
||||
<string name="conn_info_approval_mode_title">Profile approval mode</string>
|
||||
<string name="conn_info_approval_mode_desc">Persistent policy for this Hermes profile. Applies across chats and devices.</string>
|
||||
<string name="conn_info_approval_mode_unsupported">Update Hermes to choose a profile approval mode. Per-chat YOLO remains available.</string>
|
||||
<string name="conn_info_approval_mode_profile_read_only">Read-only for this multiplexed profile. Its current mode appears after the profile session starts; changing it requires upstream profile-scoped config RPCs.</string>
|
||||
<string name="conn_info_approval_mode_manual">Manual</string>
|
||||
<string name="conn_info_approval_mode_manual_desc">Ask before every protected tool call.</string>
|
||||
<string name="conn_info_approval_mode_smart">Smart</string>
|
||||
<string name="conn_info_approval_mode_smart_desc">Ask only when Hermes detects elevated risk.</string>
|
||||
<string name="conn_info_approval_mode_off">Off</string>
|
||||
<string name="conn_info_approval_mode_off_desc">Persistently bypass approvals for this profile.</string>
|
||||
|
||||
<!-- EndpointsCard -->
|
||||
|
||||
@@ -2679,6 +2732,23 @@
|
||||
<string name="attachment_unmute">Unmute</string>
|
||||
<string name="attachment_mute">Mute</string>
|
||||
<string name="attachment_title">Attachment</string>
|
||||
<plurals name="attachment_group_count">
|
||||
<item quantity="one">%1$d attachment</item>
|
||||
<item quantity="other">%1$d attachments</item>
|
||||
</plurals>
|
||||
<string name="attachment_group_named">%1$s · %2$s</string>
|
||||
<string name="attachment_group_named_more">%1$s · %2$s +%3$d</string>
|
||||
<string name="attachment_group_typed_more">%1$s +%2$d</string>
|
||||
<string name="attachment_group_collapse">Collapse attachments</string>
|
||||
<string name="attachment_group_expand">Expand attachments</string>
|
||||
<string name="attachment_group_collapsed">Collapsed</string>
|
||||
<string name="attachment_group_expanded">Expanded</string>
|
||||
<string name="attachment_type_image">Image</string>
|
||||
<string name="attachment_type_video">Video</string>
|
||||
<string name="attachment_type_audio">Audio</string>
|
||||
<string name="attachment_type_pdf">PDF</string>
|
||||
<string name="attachment_type_text">Text</string>
|
||||
<string name="attachment_type_file">File</string>
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
|
||||
|
||||
@@ -126,6 +126,20 @@ class ChatTurnCheckpointStoreTest {
|
||||
startedAt = 1_002L,
|
||||
),
|
||||
),
|
||||
moaReferences = listOf(
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 1,
|
||||
count = 2,
|
||||
label = "advisor-a",
|
||||
text = "Safe advice",
|
||||
),
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 2,
|
||||
count = 2,
|
||||
label = "advisor-b",
|
||||
available = false,
|
||||
),
|
||||
),
|
||||
backgroundTask = ChatTurnBackgroundTaskCheckpoint(
|
||||
id = "run-1",
|
||||
title = "Research",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.coVerify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class DataManagerIoTest {
|
||||
@Test
|
||||
fun writeBackupFailsWhenProviderReturnsNoOutputStream() = runTest {
|
||||
val uri = mockk<Uri>()
|
||||
val resolver = mockk<ContentResolver>()
|
||||
val context = mockk<Context>()
|
||||
every { context.contentResolver } returns resolver
|
||||
every { resolver.openOutputStream(uri) } returns null
|
||||
|
||||
val success = DataManager(context).writeBackupToUri(uri, "{}")
|
||||
|
||||
assertFalse(success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreTreatsEmptyConnectionsAsReplacementSnapshot() = runTest {
|
||||
val context = mockk<Context>()
|
||||
val store = mockk<ConnectionStore>()
|
||||
every { context.filesDir } returns File("build/tmp/data-manager-test/files")
|
||||
coJustRun {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
|
||||
DataManager(context, store).restoreConnectionBackup(DataManager.AppBackup())
|
||||
|
||||
coVerify(exactly = 1) {
|
||||
store.replaceConnections(
|
||||
connections = emptyList(),
|
||||
activeConnectionId = null,
|
||||
startupConnectionId = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FeatureFlagsTest {
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() = runTest {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runTest {
|
||||
context.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingPreferenceUsesBuildDefault() = runTest {
|
||||
assertEquals(FeatureFlags.isDevBuild, FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitLockOverridesDebugBuildDefault() = runTest {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
|
||||
assertFalse(FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlockPersistsAfterExplicitLock() = runTest {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
|
||||
assertTrue(FeatureFlags.devOptionsUnlocked(context).first())
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ActiveTurnKeepAliveRegistryTest {
|
||||
@After
|
||||
fun tearDown() = ActiveTurnKeepAliveRegistry.resetForTest()
|
||||
|
||||
@Test
|
||||
fun siblingSessionsHoldIndependentLeases() {
|
||||
ActiveTurnKeepAliveRegistry.acquire("connection::victor::session-a")
|
||||
ActiveTurnKeepAliveRegistry.acquire("connection::default::session-b")
|
||||
ActiveTurnKeepAliveRegistry.setWaiting("connection::victor::session-a", true)
|
||||
|
||||
assertEquals(
|
||||
ActiveTurnKeepAliveRegistry.Snapshot(activeTurnCount = 2, waitingSessionCount = 1),
|
||||
ActiveTurnKeepAliveRegistry.snapshot.value,
|
||||
)
|
||||
|
||||
ActiveTurnKeepAliveRegistry.release("connection::victor::session-a")
|
||||
|
||||
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
|
||||
assertEquals(0, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
|
||||
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionRenameMovesRatherThanDuplicatesLease() {
|
||||
ActiveTurnKeepAliveRegistry.acquire("temporary")
|
||||
ActiveTurnKeepAliveRegistry.setWaiting("temporary", true)
|
||||
ActiveTurnKeepAliveRegistry.rename("temporary", "durable")
|
||||
|
||||
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.activeTurnCount)
|
||||
assertEquals(1, ActiveTurnKeepAliveRegistry.snapshot.value.waitingSessionCount)
|
||||
|
||||
ActiveTurnKeepAliveRegistry.release("durable")
|
||||
assertFalse(ActiveTurnKeepAliveRegistry.snapshot.value.required)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnMoaReferenceCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -15,6 +17,8 @@ import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -800,6 +804,57 @@ class ChatHandlerTest {
|
||||
assertEquals("2 background tasks completed", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_rendersAutoContinueAsNeutralSystemTimelineRow() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "continue-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("private continuation prompt"),
|
||||
displayKind = "auto_continue",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals(MessageRole.SYSTEM, msg.role)
|
||||
assertEquals("Continued after an interrupted turn", msg.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileInterimMessage_collapsesProvisionalFinalBubble() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "interim",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "candidate",
|
||||
timestamp = 1L,
|
||||
isStreaming = false,
|
||||
),
|
||||
)
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "provisional",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
handler.reconcileInterimMessage(
|
||||
interimMessageId = "interim",
|
||||
currentMessageId = "provisional",
|
||||
content = "candidate answer",
|
||||
)
|
||||
|
||||
val assistant = handler.messages.value.single()
|
||||
assertEquals("interim", assistant.id)
|
||||
assertEquals("candidate answer", assistant.content)
|
||||
assertTrue(assistant.isStreaming)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_skipsToolMessages() {
|
||||
val items = listOf(
|
||||
@@ -849,6 +904,231 @@ class ChatHandlerTest {
|
||||
assertEquals("", handler.messages.value[0].content)
|
||||
}
|
||||
|
||||
// --- loadMessageHistory: persisted user image references (HRUI-073) ---
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_liftsCaptionFirstPersistedImageRef() {
|
||||
val requested = mutableListOf<Pair<String, String>>()
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
requested += messageId to path
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-1",
|
||||
role = "user",
|
||||
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("What is this?", message.content)
|
||||
assertEquals(listOf("image-user-1" to "/tmp/cat.png"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_keepsImageOnlyTurnAndParsesQuotedSpacedPath() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-2",
|
||||
role = "user",
|
||||
content = JsonPrimitive(
|
||||
"@image:`/tmp/Hermes composer images/holiday photo.webp`"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("", handler.messages.value.single().content)
|
||||
assertEquals(
|
||||
listOf("/tmp/Hermes composer images/holiday photo.webp"),
|
||||
requested,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_extractsMultipleRefsInOrder() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-3",
|
||||
role = "user",
|
||||
content = JsonPrimitive(
|
||||
"Compare these\n@image:/tmp/a.png\n@image:\"/tmp/two images/b.jpg\""
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals("Compare these", handler.messages.value.single().content)
|
||||
assertEquals(listOf("/tmp/a.png", "/tmp/two images/b.jpg"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_extractsRefFromNativeVisionContentArray() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
val nativeVisionContent = buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "Describe this\n@image:/tmp/native.png")
|
||||
}
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image_url")
|
||||
put(
|
||||
"image_url",
|
||||
buildJsonObject { put("url", "data:image/png;base64,AAAA") },
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "image-user-4", role = "user", content = nativeVisionContent))
|
||||
)
|
||||
|
||||
assertEquals("Describe this", handler.messages.value.single().content)
|
||||
assertEquals(listOf("/tmp/native.png"), requested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_keepsMalformedUnknownAndInlineImageDirectivesAsText() {
|
||||
val requested = mutableListOf<String>()
|
||||
handler.onPersistedUserImageRequested = { _, path -> requested += path }
|
||||
val content = listOf(
|
||||
"@image:relative.png",
|
||||
"@image:`/tmp/unclosed.png",
|
||||
"@image:/etc/passwd",
|
||||
"mention @image:/tmp/inline.png here",
|
||||
).joinToString("\n")
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "image-user-5", role = "user", content = JsonPrimitive(content)))
|
||||
)
|
||||
|
||||
assertEquals(content, handler.messages.value.single().content)
|
||||
assertTrue(requested.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_canRenderPathFreeUnavailableImageState() {
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
handler.mutateMessage(messageId) { message ->
|
||||
message.copy(
|
||||
attachments = message.attachments + Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = path.substringAfterLast('/'),
|
||||
state = AttachmentState.FAILED,
|
||||
errorMessage = "Image unavailable on this connection",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "image-user-6",
|
||||
role = "user",
|
||||
content = JsonPrimitive("@image:/tmp/deleted.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("", message.content)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
|
||||
assertEquals("deleted.png", message.attachments.single().fileName)
|
||||
assertFalse(message.attachments.single().errorMessage.orEmpty().contains("/tmp/"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_coldResumeDispatchesPersistedImageOnlyOnceAcrossReloads() {
|
||||
var requestCount = 0
|
||||
handler.onPersistedUserImageRequested = { messageId, path ->
|
||||
requestCount++
|
||||
handler.mutateMessage(messageId) { message ->
|
||||
message.copy(
|
||||
attachments = message.attachments + Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = "resume.png",
|
||||
state = AttachmentState.FAILED,
|
||||
errorMessage = "Image unavailable on this connection",
|
||||
relayToken = path,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
val history = listOf(
|
||||
MessageItem(
|
||||
id = "image-user-cold",
|
||||
role = "user",
|
||||
content = JsonPrimitive("@image:/tmp/resume.png"),
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(history)
|
||||
handler.loadMessageHistory(history)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals(1, requestCount)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals(AttachmentState.FAILED, message.attachments.single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_immediateReloadCarriesLocalImageWithoutDuplicateFetch() {
|
||||
handler.addUserMessage(
|
||||
ChatMessage(
|
||||
id = "optimistic-image",
|
||||
role = MessageRole.USER,
|
||||
content = "What is this?",
|
||||
timestamp = 1L,
|
||||
attachments = listOf(
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = "base64-pixels",
|
||||
fileName = "cat.png",
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
var requestCount = 0
|
||||
handler.onPersistedUserImageRequested = { _, _ -> requestCount++ }
|
||||
val history = listOf(
|
||||
MessageItem(
|
||||
id = "server-image",
|
||||
role = "user",
|
||||
content = JsonPrimitive("What is this?\n@image:/tmp/cat.png"),
|
||||
)
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(history)
|
||||
handler.loadMessageHistory(history)
|
||||
|
||||
val message = handler.messages.value.single()
|
||||
assertEquals("server-image", message.id)
|
||||
assertEquals("What is this?", message.content)
|
||||
assertEquals(1, message.attachments.size)
|
||||
assertEquals("base64-pixels", message.attachments.single().content)
|
||||
assertEquals(0, requestCount)
|
||||
}
|
||||
|
||||
// --- loadMessageHistory: outbound attachment preservation (GAP 1) ---
|
||||
|
||||
@Test
|
||||
@@ -1737,6 +2017,20 @@ class ChatHandlerTest {
|
||||
completedAt = 7L,
|
||||
),
|
||||
),
|
||||
moaReferences = listOf(
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 1,
|
||||
count = 2,
|
||||
label = "advisor-a",
|
||||
text = "Recovered advice",
|
||||
),
|
||||
ChatTurnMoaReferenceCheckpoint(
|
||||
index = 2,
|
||||
count = 2,
|
||||
label = "advisor-b",
|
||||
available = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
turnStatus = "Running terminal",
|
||||
priorUserMessageCount = 1,
|
||||
@@ -1755,10 +2049,107 @@ class ChatHandlerTest {
|
||||
assertFalse(restored.toolCalls[0].isComplete)
|
||||
assertTrue(restored.toolCalls[1].isComplete)
|
||||
assertEquals(true, restored.toolCalls[1].success)
|
||||
assertEquals(listOf(1, 2), restored.moaReferences.map { it.index })
|
||||
assertEquals("Recovered advice", restored.moaReferences.first().text)
|
||||
assertFalse(restored.moaReferences.last().available)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertEquals("Running terminal", handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onMoaReference_upsertsByCanonicalIndexAndResetsOnNewSequence() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Second"),
|
||||
)
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "First"),
|
||||
)
|
||||
assertEquals(listOf(1), handler.messages.value.single().moaReferences.map { it.index })
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Second"),
|
||||
)
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "First"),
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2), handler.messages.value.single().moaReferences.map { it.index })
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(2, 2, "advisor-b", "Updated second"),
|
||||
)
|
||||
assertEquals(
|
||||
"Updated second",
|
||||
handler.messages.value.single().moaReferences.single { it.index == 2 }.text,
|
||||
)
|
||||
|
||||
handler.onMoaReference(
|
||||
"assistant-live",
|
||||
GatewayMoaReference(1, 2, "advisor-a", "New first"),
|
||||
)
|
||||
|
||||
val reset = handler.messages.value.single().moaReferences
|
||||
assertEquals(listOf(1), reset.map { it.index })
|
||||
assertEquals("New first", reset.single().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_doesNotPersistMoaReferenceBlocks() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Answer",
|
||||
timestamp = 1L,
|
||||
moaReferences = listOf(
|
||||
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Answer"))),
|
||||
)
|
||||
|
||||
assertTrue(handler.messages.value.single().moaReferences.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_preservesMoaReferencesWhileMatchingTurnIsStillLive() {
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Partial",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
moaReferences = listOf(
|
||||
com.hermesandroid.relay.data.MoaReference(1, 1, "advisor", "Transient"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(MessageItem(id = "assistant-live", role = "assistant", content = JsonPrimitive("Partial"))),
|
||||
)
|
||||
|
||||
assertEquals("Transient", handler.messages.value.single().moaReferences.single().text)
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun createUserMessage(id: String, content: String) = ChatMessage(
|
||||
|
||||
+4
-1
@@ -686,6 +686,7 @@ class DashboardApiClientTest {
|
||||
name = "Local",
|
||||
baseUrl = "https://llm.example/v1",
|
||||
model = "qwen",
|
||||
models = listOf("qwen", "qwen-vl", " qwen ", ""),
|
||||
apiKey = "never-persist-this",
|
||||
)
|
||||
|
||||
@@ -701,7 +702,9 @@ class DashboardApiClientTest {
|
||||
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
|
||||
val save = server.takeRequest()
|
||||
assertEquals("/api/providers/custom-endpoints", save.path)
|
||||
assertTrue(save.body.readUtf8().contains("never-persist-this"))
|
||||
val saveBody = save.body.readUtf8()
|
||||
assertTrue(saveBody.contains("never-persist-this"))
|
||||
assertTrue(saveBody.contains(""""models":["qwen","qwen-vl"]"""))
|
||||
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
|
||||
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
|
||||
|
||||
+414
-1
@@ -63,6 +63,9 @@ class GatewayClientHarness(
|
||||
|
||||
@Volatile
|
||||
var recoveryInflightStreaming: Boolean? = null
|
||||
var recoveryInflightError: String? = null
|
||||
var recoveryInflightRecoverable: Boolean = false
|
||||
var recoveryAutoContinueAttempt: Int? = null
|
||||
|
||||
@Volatile
|
||||
var recoveryQueuedUser: String? = null
|
||||
@@ -93,6 +96,12 @@ class GatewayClientHarness(
|
||||
@Volatile
|
||||
var reasoningDisplay = "hide"
|
||||
|
||||
@Volatile
|
||||
var approvalMode = "smart"
|
||||
|
||||
/** Config keys rejected with the older-gateway unknown-key response. */
|
||||
val unsupportedConfigKeys: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
@Volatile
|
||||
var askResponseStatus = "ok"
|
||||
|
||||
@@ -141,6 +150,23 @@ class GatewayClientHarness(
|
||||
)
|
||||
return
|
||||
}
|
||||
val configKey = (params["key"] as? JsonPrimitive)?.contentOrNull
|
||||
if (
|
||||
(method == "config.get" || method == "config.set") &&
|
||||
configKey in unsupportedConfigKeys
|
||||
) {
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id.toLong())
|
||||
put("error", buildJsonObject {
|
||||
put("code", 4002)
|
||||
put("message", "unknown config key: $configKey")
|
||||
})
|
||||
}.toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
val result: JsonObject? = when (method) {
|
||||
"session.create" -> buildJsonObject {
|
||||
put("session_id", "live-1")
|
||||
@@ -250,6 +276,7 @@ class GatewayClientHarness(
|
||||
put("value", reasoningEffort)
|
||||
put("display", reasoningDisplay)
|
||||
}
|
||||
"approvals.mode" -> buildJsonObject { put("value", approvalMode) }
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
|
||||
@@ -268,6 +295,18 @@ class GatewayClientHarness(
|
||||
put("key", "fast")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
|
||||
}
|
||||
"yolo" -> buildJsonObject {
|
||||
put("key", "yolo")
|
||||
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "0")
|
||||
}
|
||||
"approvals.mode" -> {
|
||||
approvalMode =
|
||||
(params["value"] as? JsonPrimitive)?.contentOrNull ?: approvalMode
|
||||
buildJsonObject {
|
||||
put("key", "approvals.mode")
|
||||
put("value", approvalMode)
|
||||
}
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
@@ -299,16 +338,27 @@ class GatewayClientHarness(
|
||||
put("info", buildJsonObject { put("project", project) })
|
||||
}
|
||||
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
|
||||
if (recoveryRunning || recoveryInflightStreaming != null) {
|
||||
if (recoveryRunning || recoveryInflightStreaming != null || recoveryInflightError != null) {
|
||||
put("inflight", buildJsonObject {
|
||||
put("user", "research this")
|
||||
put("assistant", recoveryAssistant)
|
||||
put("streaming", inflightStreaming)
|
||||
recoveryInflightError?.let { error ->
|
||||
put("status", "error")
|
||||
put("error", error)
|
||||
put("recoverable", recoveryInflightRecoverable)
|
||||
}
|
||||
})
|
||||
}
|
||||
recoveryQueuedUser?.let { user ->
|
||||
put("queued", buildJsonObject { put("user", user) })
|
||||
}
|
||||
recoveryAutoContinueAttempt?.let { attempt ->
|
||||
put("auto_continue", buildJsonObject {
|
||||
put("attempt", attempt)
|
||||
put("interrupted_at", 1_700_000_000.0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun recoveryResult(sessionId: String): JsonObject = recoveryPayload(sessionId)
|
||||
@@ -420,12 +470,14 @@ class GatewayChatClientTest {
|
||||
val sessionIds = ConcurrentLinkedQueue<String>()
|
||||
val errors = ConcurrentLinkedQueue<String>()
|
||||
val interactions = ConcurrentLinkedQueue<GatewayAsk>()
|
||||
val interactionResolutions = ConcurrentLinkedQueue<GatewayAskExpiry>()
|
||||
val toolStarts = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val toolDone = ConcurrentLinkedQueue<Pair<String, String?>>()
|
||||
|
||||
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
|
||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
||||
val moaReferences = ConcurrentLinkedQueue<GatewayMoaReference>()
|
||||
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
||||
val reconcileRequests = AtomicInteger(0)
|
||||
val completeLatch = CountDownLatch(1)
|
||||
@@ -446,8 +498,10 @@ class GatewayChatClientTest {
|
||||
onError = { errors += it; completeLatch.countDown() },
|
||||
onToolGenerating = { toolGenerating += it ?: "" },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onMoaReference = { moaReferences += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
onInteractionExpired = { },
|
||||
onInteractionResolved = { interactionResolutions += it },
|
||||
onStatusUpdate = { _, _ -> },
|
||||
onStatusClear = { },
|
||||
)
|
||||
@@ -489,6 +543,14 @@ class GatewayChatClientTest {
|
||||
client = buildClient(rpcTimeoutMs, promptSubmitTimeoutMs, turnIdleTimeoutMs)
|
||||
}
|
||||
|
||||
private fun waitUntil(timeoutMs: Long = 2_000L, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!condition() && System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(10)
|
||||
}
|
||||
assertTrue("condition did not settle within ${timeoutMs}ms", condition())
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
harness = GatewayClientHarness()
|
||||
@@ -1640,6 +1702,146 @@ class GatewayChatClientTest {
|
||||
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval mode get and set use profile config without session yolo scope`() {
|
||||
harness.approvalMode = "smart"
|
||||
|
||||
val fetched = runBlocking { client.getApprovalMode() }
|
||||
val updated = runBlocking { client.setApprovalMode(GatewayApprovalMode.Off) }
|
||||
|
||||
assertEquals(GatewayApprovalMode.Smart, fetched.getOrThrow())
|
||||
assertEquals(GatewayApprovalMode.Off, updated.getOrThrow())
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Supported,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
assertEquals(GatewayApprovalMode.Off, client.serverApprovalMode.value)
|
||||
val getRpc = harness.awaitRpc("config.get")
|
||||
assertEquals("approvals.mode", (getRpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
val setRpc = harness.awaitRpc("config.set")
|
||||
assertEquals("approvals.mode", (setRpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("off", (setRpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertFalse(setRpc.containsKey("scope"))
|
||||
assertFalse(setRpc.containsKey("session_id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session info reconciles known approval modes and ignores unknown values`() {
|
||||
val recorder = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject {
|
||||
put("approval_mode", "manual")
|
||||
put("desktop_contract", 3)
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
waitUntil { client.serverApprovalMode.value == GatewayApprovalMode.Manual }
|
||||
assertEquals(GatewayApprovalModeCapability.Supported, client.approvalModeCapability.value)
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("approval_mode", "future-mode") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(30)
|
||||
assertEquals(GatewayApprovalMode.Manual, client.serverApprovalMode.value)
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("desktop_contract", 2) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
waitUntil {
|
||||
client.approvalModeCapability.value ==
|
||||
GatewayApprovalModeCapability.Unsupported
|
||||
}
|
||||
assertEquals(GatewayApprovalMode.Manual, client.serverApprovalMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older gateway rejection disables only approval mode capability`() {
|
||||
harness.unsupportedConfigKeys += "approvals.mode"
|
||||
|
||||
val first = runBlocking { client.getApprovalMode() }
|
||||
val configGetsAfterFirst = harness.rpcLog.count { (method, _) -> method == "config.get" }
|
||||
val second = runBlocking { client.getApprovalMode() }
|
||||
|
||||
assertTrue(first.isFailure)
|
||||
assertTrue(second.isFailure)
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Unsupported,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
assertEquals(
|
||||
configGetsAfterFirst,
|
||||
harness.rpcLog.count { (method, _) -> method == "config.get" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiplexed profile approval mode is read only until upstream scopes config rpc`() {
|
||||
client.sessionProfileProvider = { "work" }
|
||||
|
||||
val fetched = runBlocking { client.getApprovalMode() }
|
||||
val updated = runBlocking { client.setApprovalMode(GatewayApprovalMode.Manual) }
|
||||
|
||||
assertTrue(fetched.isFailure)
|
||||
assertTrue(updated.isFailure)
|
||||
assertTrue(
|
||||
fetched.exceptionOrNull()?.message.orEmpty().contains("read-only"),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
harness.rpcLog.count { (method, _) ->
|
||||
method == "config.get" || method == "config.set"
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
GatewayApprovalModeCapability.Unknown,
|
||||
client.approvalModeCapability.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale session info cannot overwrite approval mode after session clear`() {
|
||||
harness.approvalMode = "smart"
|
||||
assertEquals(GatewayApprovalMode.Smart, runBlocking { client.getApprovalMode() }.getOrThrow())
|
||||
|
||||
val recorder = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
client.clearSession()
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject { put("approval_mode", "off") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(30)
|
||||
|
||||
assertEquals(GatewayApprovalMode.Smart, client.serverApprovalMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reasoning settings update targets live session when present`() {
|
||||
val r = Recorder()
|
||||
@@ -1694,6 +1896,25 @@ class GatewayChatClientTest {
|
||||
assertFalse(rpc.containsKey("scope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yolo update targets live session with ephemeral session scope`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val result = runBlocking { client.setYolo(true) }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(result.getOrThrow())
|
||||
val rpc = harness.awaitRpc("config.set")
|
||||
assertEquals("yolo", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("1", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("session", (rpc["scope"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
// --- Edit & regenerate ---
|
||||
|
||||
@Test
|
||||
@@ -1958,6 +2179,110 @@ class GatewayChatClientTest {
|
||||
recovery.handle!!.detach()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn exposes retained terminal failure without live handle`() {
|
||||
harness.recoveryInflightStreaming = false
|
||||
harness.recoveryAssistant = "partial answer"
|
||||
harness.recoveryInflightError = "provider failed"
|
||||
harness.recoveryInflightRecoverable = true
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertFalse(recovery.running)
|
||||
assertFalse(recovery.hasPendingWork)
|
||||
assertEquals("error", recovery.inflight?.status)
|
||||
assertEquals("provider failed", recovery.inflight?.error)
|
||||
assertTrue(recovery.inflight?.recoverable == true)
|
||||
assertNull(recovery.handle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto continue buffers message start racing resume acknowledgement`() {
|
||||
runBlocking {
|
||||
harness.recoveryAutoContinueAttempt = 1
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
val recorder = Recorder()
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
assertEquals("session.resume", ack.method)
|
||||
val liveId = (harness.recoveryResult("stored-42")
|
||||
.getValue("session_id") as JsonPrimitive).content
|
||||
ack.ws.send(harness.eventFrame("message.start", null, liveId))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "continued answer") },
|
||||
liveId,
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult(liveId))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (recorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(1, recovery.autoContinue?.attempt)
|
||||
assertTrue(recovery.hasPendingWork)
|
||||
assertEquals(listOf("continued answer"), recorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resume race delivers auto continue events through exactly one owner`() {
|
||||
runBlocking {
|
||||
// Keep the recovered live id warm so the early message.start could be
|
||||
// accepted by normal unsolicited routing while session.resume is also
|
||||
// buffering it. Recovery must exclusively claim the frame instead.
|
||||
assertTrue(client.prewarmAwait("stored-42"))
|
||||
harness.recoveryAutoContinueAttempt = 1
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
val recorder = Recorder()
|
||||
client.setUnsolicitedTurnProvider {
|
||||
GatewayInboundTurnRegistration(recorder.callbacks) { true }
|
||||
}
|
||||
|
||||
val pending = async(Dispatchers.IO) {
|
||||
client.recoverTurn(
|
||||
"stored-42",
|
||||
null,
|
||||
recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
val ack = harness.awaitPendingAck()
|
||||
assertEquals("session.resume", ack.method)
|
||||
val liveId = (harness.recoveryResult("stored-42")
|
||||
.getValue("session_id") as JsonPrimitive).content
|
||||
ack.ws.send(harness.eventFrame("message.start", null, liveId))
|
||||
ack.ws.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "continued once") },
|
||||
liveId,
|
||||
),
|
||||
)
|
||||
harness.releaseAck(ack, harness.recoveryResult(liveId))
|
||||
|
||||
val recovery = pending.await()
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (recorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
|
||||
assertEquals(listOf("continued once"), recorder.textDeltas.toList())
|
||||
recovery.handle?.detach()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn keeps inflight and queued resume live`() {
|
||||
harness.recoveryInflightStreaming = true
|
||||
@@ -2234,6 +2559,94 @@ class GatewayChatClientTest {
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.interrupt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detached interaction is retained and replayed when exact session reopens`() {
|
||||
val original = Recorder()
|
||||
val backgroundEvents = ConcurrentLinkedQueue<GatewayBackgroundInteractionEvent>()
|
||||
client.setBackgroundInteractionListener(backgroundEvents::add)
|
||||
client.sessionProfileProvider = { "work" }
|
||||
client.sendTurn(null, "long task", null, original.callbacks) {
|
||||
original.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
assertTrue(client.backgroundActiveTurn())
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"approval.request",
|
||||
buildJsonObject { put("command", "redacted command") },
|
||||
"live-1",
|
||||
),
|
||||
)
|
||||
repeat(50) {
|
||||
if (backgroundEvents.isNotEmpty()) return@repeat
|
||||
Thread.sleep(20)
|
||||
}
|
||||
val requested = backgroundEvents.single() as GatewayBackgroundInteractionEvent.Requested
|
||||
assertEquals("20260612_120000_abc123", requested.storedSessionId)
|
||||
assertEquals("work", requested.profile)
|
||||
assertEquals(GatewayAsk.Kind.APPROVAL, requested.ask.kind)
|
||||
|
||||
harness.recoveryRunning = true
|
||||
val resumed = Recorder()
|
||||
runBlocking {
|
||||
client.recoverTurn(
|
||||
storedId = "20260612_120000_abc123",
|
||||
preferredLiveId = "live-1",
|
||||
callbacks = resumed.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
repeat(50) {
|
||||
if (resumed.interactions.isNotEmpty()) return@repeat
|
||||
Thread.sleep(20)
|
||||
}
|
||||
assertEquals(GatewayAsk.Kind.APPROVAL, resumed.interactions.single().kind)
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "continued") },
|
||||
"live-1",
|
||||
),
|
||||
)
|
||||
repeat(50) {
|
||||
if (resumed.interactionResolutions.isNotEmpty()) return@repeat
|
||||
Thread.sleep(20)
|
||||
}
|
||||
assertEquals(
|
||||
GatewayAskExpiry(GatewayAsk.Kind.APPROVAL, null),
|
||||
resumed.interactionResolutions.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal read request is answered empty without user interaction`() {
|
||||
val recorder = Recorder()
|
||||
client.sendTurn(null, "inspect terminal", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"terminal.read.request",
|
||||
buildJsonObject {
|
||||
put("request_id", "terminal-1")
|
||||
put("start", 0)
|
||||
put("count", 20)
|
||||
},
|
||||
"live-1",
|
||||
),
|
||||
)
|
||||
|
||||
val response = harness.awaitRpc("terminal.read.respond")
|
||||
assertEquals(JsonPrimitive("terminal-1"), response["request_id"])
|
||||
assertEquals(JsonPrimitive(""), response["text"])
|
||||
assertTrue(recorder.interactions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `background turn reconnects shared socket and reports completion`() {
|
||||
val original = Recorder()
|
||||
|
||||
+12
-4
@@ -5,8 +5,9 @@ import org.junit.Test
|
||||
|
||||
/**
|
||||
* Resolution matrix for [resolveStreamingEndpointPreference] — the gateway
|
||||
* tier sits above the capability-preferred SSE endpoint, but only for "auto"
|
||||
* and only when the dashboard probe reports Ready.
|
||||
* tier sits above the capability-preferred SSE endpoint for "auto". An
|
||||
* unresolved cold-start probe remains on Gateway until it produces a
|
||||
* definitive fallback verdict.
|
||||
*/
|
||||
class GatewayEndpointResolutionTest {
|
||||
|
||||
@@ -35,9 +36,16 @@ class GatewayEndpointResolutionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto falls back to capability preference for every non-ready state`() {
|
||||
fun `auto stays on gateway while cold-start availability is unresolved`() {
|
||||
assertEquals(
|
||||
"gateway",
|
||||
resolveStreamingEndpointPreference("auto", GatewayAvailability.Unknown, fullCaps),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto falls back after a definitive non-ready verdict`() {
|
||||
listOf(
|
||||
GatewayAvailability.Unknown,
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported,
|
||||
|
||||
+212
-6
@@ -19,6 +19,7 @@ class GatewayEventMapperTest {
|
||||
private class Recorder {
|
||||
val textDeltas = mutableListOf<String>()
|
||||
val interimMessages = mutableListOf<Pair<String, Boolean>>()
|
||||
val reconciledInterims = mutableListOf<String>()
|
||||
val thinkingDeltas = mutableListOf<String>()
|
||||
val toolStarts = mutableListOf<Pair<String, String>>()
|
||||
val toolDones = mutableListOf<Pair<String, String?>>()
|
||||
@@ -26,8 +27,10 @@ class GatewayEventMapperTest {
|
||||
val toolOutputRisks = mutableListOf<GatewayToolOutputRisk>()
|
||||
val toolGenerating = mutableListOf<String?>()
|
||||
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
|
||||
val moaReferences = mutableListOf<GatewayMoaReference>()
|
||||
val interactions = mutableListOf<GatewayAsk>()
|
||||
val interactionExpiries = mutableListOf<GatewayAskExpiry>()
|
||||
val interactionResolutions = mutableListOf<GatewayAskExpiry>()
|
||||
val statusUpdates = mutableListOf<Pair<String?, String>>()
|
||||
val statusClears = mutableListOf<String>()
|
||||
val sessionIds = mutableListOf<String>()
|
||||
@@ -44,6 +47,7 @@ class GatewayEventMapperTest {
|
||||
onStart = { starts++ },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
|
||||
onInterimReconciled = { text -> reconciledInterims += text },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||
onToolCallDone = { id, preview -> toolDones += id to preview },
|
||||
@@ -56,8 +60,10 @@ class GatewayEventMapperTest {
|
||||
onError = { errors += it },
|
||||
onToolGenerating = { toolGenerating += it },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onMoaReference = { moaReferences += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
onInteractionExpired = { interactionExpiries += it },
|
||||
onInteractionResolved = { interactionResolutions += it },
|
||||
onStatusUpdate = { kind, text -> statusUpdates += kind to text },
|
||||
onStatusClear = { statusClears += it },
|
||||
)
|
||||
@@ -229,6 +235,93 @@ class GatewayEventMapperTest {
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed complete equal to interim reconciles one bubble`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed complete extending interim replaces it with full final`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate answer"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate answer"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-previewed truncated final replaces longer interim`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.interim",
|
||||
obj("""{"text":"candidate answer","already_streamed":false}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"candidate"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("candidate"), r.reconciledInterims)
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal error complete preserves partial and reports failed status`() {
|
||||
val r = Recorder()
|
||||
val mapper = GatewayEventMapper(r.callbacks)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj(
|
||||
"""{"text":"partial answer","status":"error","error":"provider failed","partial":true,"recoverable":true}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("partial answer"), r.textDeltas)
|
||||
assertEquals(listOf("error" to "provider failed"), r.statusUpdates)
|
||||
assertEquals(1, r.completes)
|
||||
assertTrue(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal error complete without text renders error fallback`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"status":"error","error":"agent build failed","recoverable":true}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Error: agent build failed"), r.textDeltas)
|
||||
assertEquals(listOf("error" to "agent build failed"), r.statusUpdates)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `already streamed interim seals without replaying text`() {
|
||||
val r = Recorder()
|
||||
@@ -682,24 +775,137 @@ class GatewayEventMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sudo secret clarify and approval expiry events preserve correlation contract`() {
|
||||
fun `interaction expiry events preserve correlation contract`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("clarify.expire", obj("""{"request_id":"r1"}"""))
|
||||
mapper.onEvent("sudo.expire", obj("""{"request_id":"r2"}"""))
|
||||
mapper.onEvent("secret.expire", obj("""{"request_id":"r3"}"""))
|
||||
mapper.onEvent("clarify.expire", obj("""{"request_id":"r4"}"""))
|
||||
mapper.onEvent("approval.expire", obj("""{"request_id":"ignored"}"""))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayAsk.Kind.CLARIFY,
|
||||
GatewayAsk.Kind.SUDO,
|
||||
GatewayAsk.Kind.SECRET,
|
||||
GatewayAsk.Kind.CLARIFY,
|
||||
GatewayAsk.Kind.APPROVAL,
|
||||
),
|
||||
r.interactionExpiries.map { it.kind },
|
||||
)
|
||||
assertEquals(listOf("r2", "r3", "r4", null), r.interactionExpiries.map { it.requestId })
|
||||
assertEquals(listOf("r1", "r2", "r3", null), r.interactionExpiries.map { it.requestId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replayed request is deduplicated and resumed turn resolves it`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
val request = obj("""{"request_id":"r1","question":"Choose","choices":["A","B"]}""")
|
||||
|
||||
mapper.onEvent("clarify.request", request)
|
||||
mapper.onEvent("clarify.request", request)
|
||||
mapper.onEvent("message.delta", obj("""{"text":"Continuing"}"""))
|
||||
|
||||
assertEquals(1, r.interactions.size)
|
||||
assertEquals(
|
||||
GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, "r1"),
|
||||
r.interactionResolutions.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal read request is renderer plumbing not an actionable interaction`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent(
|
||||
"terminal.read.request",
|
||||
obj("""{"request_id":"terminal-1","start":0,"count":20}"""),
|
||||
)
|
||||
|
||||
assertTrue(r.interactions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moa progress uses one transient slot and transitions to aggregating`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent("moa.progress", obj("""{"refs_done":2,"refs_total":3,"label":"advisor-b"}"""))
|
||||
mapper.onEvent("moa.phase", obj("""{"phase":"aggregator","refs_done":3,"refs_total":3}"""))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: 2/3 advisors complete",
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: aggregating…",
|
||||
),
|
||||
r.statusUpdates,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy moa aggregating maps to the same transient phase`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent("moa.aggregating", obj("""{"aggregator":"local:aggregate"}"""))
|
||||
|
||||
assertEquals(
|
||||
GatewayEventMapper.MOA_STATUS_KIND to "MoA: aggregating…",
|
||||
r.statusUpdates.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `moa references retain safe blocks and neutralize failure sentinels`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":2,"count":3,"label":"advisor-b","text":"Useful second opinion"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":1,"count":3,"label":"advisor-a","text":" [failed: private provider detail]"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":3,"count":3,"label":"advisor-c","text":"[skipped: interrupted by user]"}"""),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayMoaReference(2, 3, "advisor-b", "Useful second opinion"),
|
||||
GatewayMoaReference(1, 3, "advisor-a", "", available = false),
|
||||
GatewayMoaReference(3, 3, "advisor-c", "", available = false),
|
||||
),
|
||||
r.moaReferences,
|
||||
)
|
||||
assertTrue(r.moaReferences.filterNot { it.available }.all { it.text.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all failed moa references surface only neutral unavailable state`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":1,"count":2,"label":"advisor-a","text":"[failed: secret detail]"}"""),
|
||||
)
|
||||
mapper.onEvent(
|
||||
"moa.reference",
|
||||
obj("""{"index":2,"count":2,"label":"advisor-b","text":"[skipped: recursive preset]"}"""),
|
||||
)
|
||||
|
||||
assertEquals(listOf(1, 2), r.moaReferences.mapNotNull { it.index })
|
||||
assertTrue(r.moaReferences.all { !it.available && it.text.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message output clears active moa status`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("moa.progress", obj("""{"refs_done":1,"refs_total":2}"""))
|
||||
mapper.onEvent("message.delta", obj("""{"text":"Final answer"}"""))
|
||||
|
||||
assertEquals(listOf(GatewayEventMapper.MOA_STATUS_KIND), r.statusClears)
|
||||
}
|
||||
|
||||
// --- Forward compat ---
|
||||
@@ -728,10 +934,10 @@ class GatewayEventMapperTest {
|
||||
"reasoning.delta", "thinking.delta", "message.delta", "message.interim", "message.start",
|
||||
"message.complete", "error", "clarify.request", "approval.request",
|
||||
"sudo.request", "secret.request", "reasoning.available",
|
||||
"sudo.expire", "secret.expire", "clarify.expire", "approval.expire",
|
||||
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
|
||||
"tool.generating", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
"tool.output_risk", "moa.reference", "moa.aggregating",
|
||||
"tool.output_risk", "moa.reference", "moa.progress", "moa.phase", "moa.aggregating",
|
||||
).forEach { type ->
|
||||
// message.complete/error end the turn; use a fresh mapper for each
|
||||
mapperWith(Recorder()).onEvent(type, null)
|
||||
|
||||
@@ -230,6 +230,8 @@ class HermesApiClientTest {
|
||||
"run_events_sse": true,
|
||||
"session_resources": true,
|
||||
"session_chat_streaming": true,
|
||||
"model_options": true,
|
||||
"session_model_lock": true,
|
||||
"skills_api": true
|
||||
},
|
||||
"endpoints": {
|
||||
@@ -237,6 +239,8 @@ class HermesApiClientTest {
|
||||
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
||||
"sessions": {"method": "GET", "path": "/api/sessions"},
|
||||
"session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"},
|
||||
"model_options": {"method": "GET", "path": "/api/model/options"},
|
||||
"session_model_lock": {"method": "POST", "path": "/api/sessions/{session_id}/model"},
|
||||
"skills": {"method": "GET", "path": "/v1/skills"},
|
||||
"toolsets": {"method": "GET", "path": "/v1/toolsets"}
|
||||
}
|
||||
@@ -249,9 +253,137 @@ class HermesApiClientTest {
|
||||
assertEquals(true, capabilities?.sessionsChatStream)
|
||||
assertEquals(true, capabilities?.portable)
|
||||
assertEquals(true, capabilities?.runs)
|
||||
assertEquals(true, capabilities?.modelOptions)
|
||||
assertEquals(true, capabilities?.sessionModelLock)
|
||||
assertEquals("sessions", capabilities?.preferredChatEndpoint())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerModelOptions_preserveAuthenticatedAndUnavailableInventory() {
|
||||
val parsed = parseApiProviderModelOptionsBody(
|
||||
Json { ignoreUnknownKeys = true },
|
||||
"""
|
||||
{
|
||||
"model": "grok-4.3",
|
||||
"provider": "xai",
|
||||
"providers": [
|
||||
{
|
||||
"slug": "xai",
|
||||
"name": "xAI",
|
||||
"authenticated": true,
|
||||
"is_current": true,
|
||||
"models": ["grok-4.3", "grok-4.2"],
|
||||
"unavailable_models": ["grok-4.2"],
|
||||
"free_tier": true,
|
||||
"total_models": 2
|
||||
},
|
||||
{
|
||||
"slug": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"authenticated": false,
|
||||
"models": ["claude-opus-4-6"]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("grok-4.3", parsed?.currentModel)
|
||||
assertEquals("xai", parsed?.currentProvider)
|
||||
assertEquals(listOf("grok-4.3", "grok-4.2"), parsed?.providers?.first()?.models)
|
||||
assertEquals(listOf("grok-4.2"), parsed?.providers?.first()?.unavailableModels)
|
||||
assertTrue(parsed?.providers?.first()?.authenticated == true)
|
||||
assertFalse(parsed?.providers?.last()?.authenticated == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerModelOptions_requireProviderEnvelope() {
|
||||
assertNull(parseApiProviderModelOptionsBody(Json, """{"data":[]}"""))
|
||||
assertNull(parseApiProviderModelOptionsBody(Json, "not-json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelLockAck_requiresExplicitRequestedRouteAndAcceptedState() {
|
||||
val ack = parseApiModelLockAck(
|
||||
Json,
|
||||
"""
|
||||
{
|
||||
"object": "hermes.session.model_lock",
|
||||
"session_id": "session-1",
|
||||
"runtime": {
|
||||
"requested": {"model": "grok-4.3", "provider": "xai"},
|
||||
"effective": {"model": "grok-4.3", "provider": "xai"},
|
||||
"model_lock": "accepted"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("session-1", ack?.sessionId)
|
||||
assertEquals("grok-4.3", ack?.model)
|
||||
assertEquals("xai", ack?.provider)
|
||||
assertEquals("accepted", ack?.state)
|
||||
assertEquals("grok-4.3", ack?.effectiveModel)
|
||||
assertEquals("xai", ack?.effectiveProvider)
|
||||
assertNull(parseApiModelLockAck(Json, """{"session_id":"session-1"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelOptionsWithoutSessionLockUsesLegacyHintContract() {
|
||||
val capabilities = ServerCapabilities(
|
||||
sessionsApi = true,
|
||||
sessionsChatStream = true,
|
||||
runs = false,
|
||||
portable = true,
|
||||
healthy = true,
|
||||
modelOptions = true,
|
||||
sessionModelLock = false,
|
||||
)
|
||||
|
||||
assertEquals(ApiModelRoutingStrategy.LEGACY_HINT, apiModelRoutingStrategy(capabilities))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalRuntimeMustConfirmExactEffectiveRoute() {
|
||||
val expected = ApiModelSelectionAck.Locked(
|
||||
sessionId = "session-1",
|
||||
model = "fast-route",
|
||||
provider = "openai",
|
||||
effectiveModel = "gpt-5-mini",
|
||||
effectiveProvider = "openai",
|
||||
)
|
||||
val confirmed = Json.parseToJsonElement(
|
||||
"""{"model_lock":"confirmed","effective":{"model":"gpt-5-mini","provider":"openai"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
val wrongProvider = Json.parseToJsonElement(
|
||||
"""{"model_lock":"confirmed","effective":{"model":"gpt-5-mini","provider":"azure"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
val merelyAccepted = Json.parseToJsonElement(
|
||||
"""{"model_lock":"accepted","effective":{"model":"gpt-5-mini","provider":"openai"}}""",
|
||||
) as kotlinx.serialization.json.JsonObject
|
||||
|
||||
assertTrue(confirmedRuntimeMatches(confirmed, expected))
|
||||
assertFalse(confirmedRuntimeMatches(wrongProvider, expected))
|
||||
assertFalse(confirmedRuntimeMatches(merelyAccepted, expected))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedLockOmitsTurnModelWhileLegacyFallbackKeepsHint() {
|
||||
assertNull(
|
||||
sessionTurnModelHint(
|
||||
ApiModelSelectionAck.Locked("session-1", "grok-4.3", "xai"),
|
||||
"grok-4.3",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"fast-route",
|
||||
sessionTurnModelHint(
|
||||
ApiModelSelectionAck.LegacyModelHint("fast-route"),
|
||||
"fast-route",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseCapabilitiesBody_returnsNullForUnrelatedJson() {
|
||||
val body = """{"status":"ok"}"""
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ModelOptionsResponseFenceTest {
|
||||
@Test
|
||||
fun acceptsOnlySameGenerationAndProfile() {
|
||||
assertTrue(isCurrentModelOptionsResponse(4, 4, "connection::alpha", "connection::alpha"))
|
||||
assertFalse(isCurrentModelOptionsResponse(3, 4, "connection::alpha", "connection::alpha"))
|
||||
assertFalse(isCurrentModelOptionsResponse(4, 4, "connection::alpha", "connection::beta"))
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NativeDashboardAuthTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var store: MemoryNativeTokenStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
store = MemoryNativeTokenStore()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capabilityGate_requiresAdvertisedNativeFlow() {
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
|
||||
assertFalse(client.supportsNativePkce(DashboardStatus(authRequired = true)))
|
||||
assertTrue(
|
||||
client.supportsNativePkce(
|
||||
DashboardStatus(authRequired = true, authFlows = listOf("cookie", "native_pkce")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun beginAuthorization_usesS256StateAndStrictLoopbackRedirect() {
|
||||
val client = NativeDashboardAuthClient(server.url("/prefix").toString(), store)
|
||||
val authorization = client.beginAuthorization(
|
||||
redirectUri = "http://127.0.0.1:43123/callback",
|
||||
provider = "nous",
|
||||
)
|
||||
val url = java.net.URI(authorization.authorizationUrl)
|
||||
val query = url.rawQuery.split("&").associate {
|
||||
val pair = it.split("=", limit = 2)
|
||||
java.net.URLDecoder.decode(pair[0], "UTF-8") to
|
||||
java.net.URLDecoder.decode(pair[1], "UTF-8")
|
||||
}
|
||||
|
||||
assertEquals("/prefix/auth/native/authorize", url.path)
|
||||
assertEquals("S256", query["code_challenge_method"])
|
||||
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
|
||||
assertEquals("nous", query["provider"])
|
||||
assertTrue(query.getValue("state").length >= 32)
|
||||
assertTrue(query.getValue("code_challenge").length >= 43)
|
||||
assertNotEquals(query["state"], query["code_challenge"])
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun beginAuthorization_rejectsHostnameLoopback() {
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
.beginAuthorization("http://localhost:43123/callback")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_validatesStateAndStoresTokens() {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"access","refresh_token":"refresh","expires_at":2000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
val tokens = client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=one-time-code&state=${authorization.state}",
|
||||
)
|
||||
|
||||
assertEquals("access", tokens.accessToken)
|
||||
assertEquals(tokens, store.load())
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/auth/native/token", request.path)
|
||||
val payload = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
|
||||
assertEquals("one-time-code", payload.getValue("code").jsonPrimitive.content)
|
||||
val verifier = payload.getValue("code_verifier").jsonPrimitive.content
|
||||
val expectedChallenge = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.toByteArray(Charsets.US_ASCII))
|
||||
.toByteString()
|
||||
.base64Url()
|
||||
val authorizeChallenge = java.net.URI(authorization.authorizationUrl).rawQuery
|
||||
.split("&")
|
||||
.first { it.startsWith("code_challenge=") }
|
||||
.substringAfter("=")
|
||||
.let { java.net.URLDecoder.decode(it, Charsets.UTF_8) }
|
||||
assertEquals(expectedChallenge, authorizeChallenge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_rejectsWrongStateWithoutNetworkOrStorage() {
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
|
||||
val result = runCatching {
|
||||
client.exchangeCallback(authorization, "/callback?code=attacker-code&state=wrong")
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(0, server.requestCount)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_doesNotRestoreTokensAfterSessionClear() {
|
||||
val responseStarted = CountDownLatch(1)
|
||||
val releaseResponse = CountDownLatch(1)
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
responseStarted.countDown()
|
||||
check(releaseResponse.await(5, TimeUnit.SECONDS))
|
||||
return MockResponse().setBody(
|
||||
"""{"access_token":"late","refresh_token":"late-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
val failure = AtomicReference<Throwable?>()
|
||||
val exchange = Thread {
|
||||
runCatching {
|
||||
client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=late-code&state=${authorization.state}",
|
||||
)
|
||||
}.exceptionOrNull()?.let(failure::set)
|
||||
}.apply { start() }
|
||||
|
||||
assertTrue(responseStarted.await(5, TimeUnit.SECONDS))
|
||||
client.clearStoredSession()
|
||||
releaseResponse.countDown()
|
||||
exchange.join(5_000)
|
||||
|
||||
assertFalse(exchange.isAlive)
|
||||
assertTrue(failure.get() is java.io.IOException)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exchangeCallback_doesNotCommitAfterAttemptCancellation() {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"cancelled","refresh_token":"refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
|
||||
|
||||
val result = runCatching {
|
||||
client.exchangeCallback(
|
||||
authorization,
|
||||
"/callback?code=code&state=${authorization.state}",
|
||||
commitAllowed = { false },
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(null, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trustedBearerPolicy_rejectsCleartextDashboardRoute() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "must-not-leak",
|
||||
refreshToken = "must-not-refresh",
|
||||
expiresAt = 1,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
|
||||
val bearer = trustedDashboardBearerAuthOrNull(
|
||||
candidate = "http://hermes.local:9119",
|
||||
trusted = "http://hermes.local:9119",
|
||||
tokenStoreProvider = { store },
|
||||
)
|
||||
|
||||
assertEquals(null, bearer)
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bearerAuth_refreshesNearExpiryAndAuthenticatesTicketRequest() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "old-access",
|
||||
refreshToken = "refresh",
|
||||
expiresAt = 1005,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"new-access","refresh_token":"new-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
),
|
||||
)
|
||||
server.enqueue(MockResponse().setBody("""{"ticket":"ticket","ttl_seconds":30}"""))
|
||||
val client = DashboardApiClient(
|
||||
server.url("/").toString(),
|
||||
DashboardApiClient.defaultClient(
|
||||
bearerAuth = DashboardBearerAuth(
|
||||
server.url("/").toString(),
|
||||
store,
|
||||
clockSeconds = { 1000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = kotlinx.coroutines.runBlocking { client.requestWsTicket().getOrThrow() }
|
||||
|
||||
assertEquals("ticket", result.ticket)
|
||||
val refresh = server.takeRequest()
|
||||
assertEquals("/auth/native/refresh", refresh.path)
|
||||
assertFalse(refresh.headers.names().contains("Authorization"))
|
||||
val ticket = server.takeRequest()
|
||||
assertEquals("Bearer new-access", ticket.getHeader("Authorization"))
|
||||
assertEquals("new-refresh", store.load()?.refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hostileSetupOrigin_neverReceivesActiveConnectionBearer() {
|
||||
store.save(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "must-not-leak",
|
||||
refreshToken = "refresh",
|
||||
expiresAt = 3000,
|
||||
),
|
||||
)
|
||||
server.enqueue(MockResponse().setBody("""{"auth_required":false}"""))
|
||||
val hostileUrl = server.url("/attacker").toString()
|
||||
val bearer = trustedDashboardBearerAuthOrNull(
|
||||
candidate = hostileUrl,
|
||||
trusted = "https://trusted.example/hermes",
|
||||
tokenStoreProvider = { store },
|
||||
)
|
||||
val client = DashboardApiClient(
|
||||
hostileUrl,
|
||||
DashboardApiClient.defaultClient(bearerAuth = bearer),
|
||||
)
|
||||
|
||||
kotlinx.coroutines.runBlocking { client.getStatus().getOrThrow() }
|
||||
|
||||
assertEquals(null, bearer)
|
||||
assertEquals(null, server.takeRequest().getHeader("Authorization"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentClients_rotateSingleUseRefreshTokenExactlyOnce() {
|
||||
val shared = AtomicReference<NativeDashboardTokens?>(
|
||||
NativeDashboardTokens(
|
||||
accessToken = "old-access",
|
||||
refreshToken = "single-use-refresh",
|
||||
expiresAt = 1005,
|
||||
provider = "nous",
|
||||
),
|
||||
)
|
||||
val refreshCalls = AtomicInteger()
|
||||
val ticketCalls = AtomicInteger()
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse = when (request.path) {
|
||||
"/auth/native/refresh" -> {
|
||||
refreshCalls.incrementAndGet()
|
||||
MockResponse().setBody(
|
||||
"""{"access_token":"new-access","refresh_token":"rotated-refresh","expires_at":3000,"provider":"nous","user_id":"u"}""",
|
||||
)
|
||||
}
|
||||
"/api/auth/ws-ticket" -> {
|
||||
ticketCalls.incrementAndGet()
|
||||
if (request.getHeader("Authorization") == "Bearer new-access") {
|
||||
MockResponse().setBody("""{"ticket":"ticket","ttl_seconds":30}""")
|
||||
} else {
|
||||
MockResponse().setResponseCode(401)
|
||||
}
|
||||
}
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
val storeA = SharedMemoryNativeTokenStore("connection-a", shared)
|
||||
val storeB = SharedMemoryNativeTokenStore("connection-a", shared)
|
||||
val clientA = dashboardClientWithBearer(storeA)
|
||||
val clientB = dashboardClientWithBearer(storeB)
|
||||
val start = CountDownLatch(1)
|
||||
val done = CountDownLatch(2)
|
||||
val failures = java.util.Collections.synchronizedList(mutableListOf<Throwable>())
|
||||
|
||||
listOf(clientA, clientB).forEach { client ->
|
||||
Thread {
|
||||
try {
|
||||
start.await()
|
||||
kotlinx.coroutines.runBlocking { client.requestWsTicket().getOrThrow() }
|
||||
} catch (error: Throwable) {
|
||||
failures += error
|
||||
} finally {
|
||||
done.countDown()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
start.countDown()
|
||||
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(failures.toString(), failures.isEmpty())
|
||||
assertEquals(1, refreshCalls.get())
|
||||
assertEquals(2, ticketCalls.get())
|
||||
assertEquals("rotated-refresh", shared.get()?.refreshToken)
|
||||
}
|
||||
|
||||
private fun dashboardClientWithBearer(store: NativeDashboardTokenStore): DashboardApiClient =
|
||||
DashboardApiClient(
|
||||
server.url("/").toString(),
|
||||
DashboardApiClient.defaultClient(
|
||||
bearerAuth = DashboardBearerAuth(
|
||||
server.url("/").toString(),
|
||||
store,
|
||||
clockSeconds = { 1000 },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private class MemoryNativeTokenStore : NativeDashboardTokenStore {
|
||||
override val coordinationKey: String = "memory-${System.identityHashCode(this)}"
|
||||
private var tokens: NativeDashboardTokens? = null
|
||||
override fun load(): NativeDashboardTokens? = tokens
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
this.tokens = tokens
|
||||
}
|
||||
override fun clear() {
|
||||
tokens = null
|
||||
}
|
||||
}
|
||||
|
||||
private class SharedMemoryNativeTokenStore(
|
||||
override val coordinationKey: String,
|
||||
private val shared: AtomicReference<NativeDashboardTokens?>,
|
||||
) : NativeDashboardTokenStore {
|
||||
override fun load(): NativeDashboardTokens? = shared.get()
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
shared.set(tokens)
|
||||
}
|
||||
override fun clear() {
|
||||
shared.set(null)
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.net.Socket
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NativeDashboardSignInCoordinatorTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var store: CoordinatorTokenStore
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
store = CoordinatorTokenStore()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_bindsBeforeLaunch_forwardsProviderAndExchangesValidCallback() = runBlocking {
|
||||
server.enqueue(tokenResponse())
|
||||
val authClient = NativeDashboardAuthClient(server.url("/").toString(), store)
|
||||
val coordinator = NativeDashboardSignInCoordinator(authClient)
|
||||
|
||||
val tokens = completeSignIn(coordinator, provider = "google")
|
||||
|
||||
assertEquals("access-1", tokens.accessToken)
|
||||
assertEquals(tokens, store.tokens)
|
||||
val authorizeRequest = server.takeRequest()
|
||||
assertEquals("/auth/native/token", authorizeRequest.path)
|
||||
assertTrue(authorizeRequest.body.readUtf8().contains("\"code\":\"code-1\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_ignoresWrongStateThenAcceptsValidCallback() = runBlocking {
|
||||
server.enqueue(tokenResponse())
|
||||
val coordinator = NativeDashboardSignInCoordinator(
|
||||
NativeDashboardAuthClient(server.url("/").toString(), store),
|
||||
)
|
||||
|
||||
coroutineScope {
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
val result = async {
|
||||
coordinator.signIn("github") { authorizationUrl.complete(it) }
|
||||
}
|
||||
val authorize = URI(authorizationUrl.await())
|
||||
val redirect = URI(query(authorize)["redirect_uri"]!!)
|
||||
val state = query(authorize)["state"]!!
|
||||
|
||||
val rejected = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=attacker&state=wrong",
|
||||
)
|
||||
assertTrue(rejected.startsWith("HTTP/1.1 400"))
|
||||
assertFalse(result.isCompleted)
|
||||
|
||||
val accepted = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=code-1&state=$state",
|
||||
)
|
||||
assertTrue(accepted.startsWith("HTTP/1.1 200"))
|
||||
assertEquals("access-1", result.await().accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signIn_timeoutClosesEphemeralListener() = runBlocking {
|
||||
val coordinator = NativeDashboardSignInCoordinator(
|
||||
authClient = NativeDashboardAuthClient(server.url("/").toString(), store),
|
||||
timeoutMillis = 100,
|
||||
)
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
|
||||
assertThrows(java.io.IOException::class.java) {
|
||||
runBlocking {
|
||||
coordinator.signIn("google") { authorizationUrl.complete(it) }
|
||||
}
|
||||
}
|
||||
val redirect = URI(query(URI(authorizationUrl.await()))["redirect_uri"]!!)
|
||||
assertThrows(Exception::class.java) {
|
||||
Socket("127.0.0.1", redirect.port).use { }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun redirectMode_requiresExactCapability_andNativeTransportRequiresHttps() {
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.NativePkce,
|
||||
dashboardRedirectAuthMode(listOf("cookie", "native_pkce")),
|
||||
)
|
||||
assertEquals(
|
||||
DashboardRedirectAuthMode.WebView,
|
||||
dashboardRedirectAuthMode(listOf("cookie", "NATIVE_PKCE")),
|
||||
)
|
||||
assertTrue(isNativeDashboardTransportEligible("https://hermes.example.test/prefix"))
|
||||
assertTrue(isNativeDashboardTransportEligible("http://127.0.0.1:9119"))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://hermes.local:9119"))
|
||||
}
|
||||
|
||||
private suspend fun completeSignIn(
|
||||
coordinator: NativeDashboardSignInCoordinator,
|
||||
provider: String,
|
||||
): NativeDashboardTokens = coroutineScope {
|
||||
val authorizationUrl = CompletableDeferred<String>()
|
||||
val result = async {
|
||||
coordinator.signIn(provider) { authorizationUrl.complete(it) }
|
||||
}
|
||||
val authorize = URI(authorizationUrl.await())
|
||||
val authorizeQuery = query(authorize)
|
||||
assertEquals(provider, authorizeQuery["provider"])
|
||||
assertEquals("S256", authorizeQuery["code_challenge_method"])
|
||||
val redirect = URI(authorizeQuery["redirect_uri"]!!)
|
||||
assertEquals("127.0.0.1", redirect.host)
|
||||
assertTrue(redirect.port > 0)
|
||||
val response = sendCallback(
|
||||
redirect,
|
||||
"/callback?code=code-1&state=${authorizeQuery["state"]}",
|
||||
)
|
||||
assertTrue(response.startsWith("HTTP/1.1 200"))
|
||||
result.await()
|
||||
}
|
||||
|
||||
private fun sendCallback(redirect: URI, target: String): String =
|
||||
Socket("127.0.0.1", redirect.port).use { socket ->
|
||||
socket.getOutputStream().write(
|
||||
"GET $target HTTP/1.1\r\nHost: 127.0.0.1:${redirect.port}\r\n\r\n"
|
||||
.toByteArray(StandardCharsets.US_ASCII),
|
||||
)
|
||||
socket.getOutputStream().flush()
|
||||
BufferedReader(InputStreamReader(socket.getInputStream())).readLine()
|
||||
}
|
||||
|
||||
private fun query(uri: URI): Map<String, String> =
|
||||
uri.rawQuery.orEmpty()
|
||||
.split('&')
|
||||
.filter(String::isNotBlank)
|
||||
.associate { part ->
|
||||
val pieces = part.split('=', limit = 2)
|
||||
URLDecoder.decode(pieces[0], StandardCharsets.UTF_8) to
|
||||
URLDecoder.decode(pieces.getOrElse(1) { "" }, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun tokenResponse(): MockResponse = MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"access_token": "access-1",
|
||||
"refresh_token": "refresh-1",
|
||||
"expires_at": 4102444800,
|
||||
"provider": "google",
|
||||
"user_id": "user-1"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
private class CoordinatorTokenStore : NativeDashboardTokenStore {
|
||||
override val coordinationKey = "coordinator-test"
|
||||
var tokens: NativeDashboardTokens? = null
|
||||
|
||||
override fun load(): NativeDashboardTokens? = tokens
|
||||
override fun save(tokens: NativeDashboardTokens) {
|
||||
this.tokens = tokens
|
||||
}
|
||||
override fun clear() {
|
||||
tokens = null
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -152,7 +152,7 @@ class StandardHermesVoiceClientTest {
|
||||
socketFactory: (Request, WebSocketListener) -> WebSocket,
|
||||
): StandardHermesVoiceClient = StandardHermesVoiceClient(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
okHttpClient = DashboardApiClient.defaultClient(),
|
||||
dashboardHttpClientProvider = { DashboardApiClient.defaultClient() },
|
||||
dashboardUrlProvider = { server.url("/").toString() },
|
||||
webSocketFactory = socketFactory,
|
||||
)
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
|
||||
class InteractionRequestNotifierTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var manager: NotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager.cancelAll()
|
||||
shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
manager.cancelAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backgroundAndSettingAndPermissionGatesAreExplicit() {
|
||||
assertTrue(InteractionRequestNotifier.shouldPost(true, false, true))
|
||||
assertFalse(InteractionRequestNotifier.shouldPost(false, false, true))
|
||||
assertFalse(InteractionRequestNotifier.shouldPost(true, true, true))
|
||||
assertFalse(InteractionRequestNotifier.shouldPost(true, false, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestReplayReplacesOneNotificationAndDeepLinksToExactSession() {
|
||||
val ask = approval(command = "rm -rf /private/path")
|
||||
|
||||
assertTrue(post(ask, SESSION_ID, PROFILE))
|
||||
assertTrue(post(ask, SESSION_ID, PROFILE))
|
||||
|
||||
val active = manager.activeNotifications.single()
|
||||
assertEquals(InteractionRequestNotifier.notificationTag(SESSION_ID, ask, PROFILE), active.tag)
|
||||
val tapIntent = shadowOf(active.notification.contentIntent).savedIntent
|
||||
assertEquals(
|
||||
InteractionRequestNotifier.chatRoute(SESSION_ID, PROFILE),
|
||||
tapIntent.getStringExtra(MainActivity.EXTRA_NAV_ROUTE),
|
||||
)
|
||||
assertEquals(
|
||||
InteractionRequestNotifier.requestKey(SESSION_ID, ask, PROFILE),
|
||||
tapIntent.data?.lastPathSegment,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copyAndPublicVersionNeverExposePromptCommandOrSecretMetadata() {
|
||||
val secret = GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = "secret-1",
|
||||
text = "Paste the production payment token",
|
||||
envVar = "PAYMENT_TOKEN",
|
||||
timeoutSeconds = 300,
|
||||
)
|
||||
|
||||
assertTrue(post(secret, SESSION_ID))
|
||||
|
||||
val notification = manager.activeNotifications.single().notification
|
||||
val privateText = notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString()
|
||||
val expandedText = notification.extras
|
||||
.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||
.toString()
|
||||
val privateTitle = notification.extras.getCharSequence(Notification.EXTRA_TITLE).toString()
|
||||
val publicText = notification.publicVersion.extras
|
||||
.getCharSequence(Notification.EXTRA_TEXT)
|
||||
.toString()
|
||||
val visibleCopy = "$privateTitle $privateText $expandedText $publicText"
|
||||
assertFalse(visibleCopy.contains(secret.text))
|
||||
assertFalse(visibleCopy.contains(secret.envVar!!))
|
||||
assertTrue(visibleCopy.contains("Open Hermes"))
|
||||
assertTrue(expandedText.contains("Profile: Server default"))
|
||||
assertEquals("Respond securely", notification.actions.single().title)
|
||||
assertEquals(Notification.VISIBILITY_PRIVATE, notification.visibility)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sameStoredSessionInTwoProfilesKeepsIndependentNotificationSlots() {
|
||||
val ask = approval(command = "private")
|
||||
|
||||
assertTrue(post(ask, SESSION_ID, "victor"))
|
||||
assertTrue(post(ask, SESSION_ID, "server-default"))
|
||||
|
||||
assertEquals(2, manager.activeNotifications.size)
|
||||
assertEquals(
|
||||
2,
|
||||
manager.activeNotifications.map { it.tag }.distinct().size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvedOrExpiredRequestCancelsOnlyItsStableSlot() {
|
||||
val first = approval(command = "first")
|
||||
val second = GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = "clarify-2",
|
||||
text = "second",
|
||||
timeoutSeconds = 300,
|
||||
)
|
||||
assertTrue(post(first, SESSION_ID))
|
||||
assertTrue(post(second, SESSION_ID))
|
||||
assertEquals(2, manager.activeNotifications.size)
|
||||
|
||||
InteractionRequestNotifier.cancel(context, SESSION_ID, first)
|
||||
|
||||
val remaining = manager.activeNotifications.single()
|
||||
assertEquals(InteractionRequestNotifier.notificationTag(SESSION_ID, second), remaining.tag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appRelaunchCleanupRemovesOnlyInteractionChannelNotifications() {
|
||||
val ask = approval(command = "private")
|
||||
assertTrue(post(ask, SESSION_ID))
|
||||
val unrelated = Notification.Builder(context, "other-channel")
|
||||
.setSmallIcon(com.hermesandroid.relay.R.mipmap.ic_launcher)
|
||||
.setContentTitle("Unrelated")
|
||||
.build()
|
||||
manager.createNotificationChannel(
|
||||
android.app.NotificationChannel(
|
||||
"other-channel",
|
||||
"Other",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
),
|
||||
)
|
||||
manager.notify(99, unrelated)
|
||||
|
||||
InteractionRequestNotifier.cancelAll(context)
|
||||
|
||||
assertEquals(1, manager.activeNotifications.size)
|
||||
assertEquals(99, manager.activeNotifications.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingAndroidPermissionSkipsPosting() {
|
||||
shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
|
||||
assertFalse(post(approval(command = "hidden"), SESSION_ID))
|
||||
assertTrue(manager.activeNotifications.isEmpty())
|
||||
}
|
||||
|
||||
private fun post(ask: GatewayAsk, sessionId: String, profile: String? = null): Boolean =
|
||||
InteractionRequestNotifier.notify(
|
||||
context = context,
|
||||
sessionId = sessionId,
|
||||
ask = ask,
|
||||
profile = profile,
|
||||
alertsEnabled = true,
|
||||
appForeground = false,
|
||||
)
|
||||
|
||||
private fun approval(command: String) = GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
requestId = null,
|
||||
text = command,
|
||||
timeoutSeconds = 0,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SESSION_ID = "session with spaces/and/slashes"
|
||||
private const val PROFILE = "work profile"
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class CollapsibleAttachmentGroupTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `summary keeps filename type and count for any attachment state`() {
|
||||
val summary = attachmentGroupSummary(
|
||||
listOf(
|
||||
Attachment(
|
||||
contentType = "application/pdf",
|
||||
content = "",
|
||||
fileName = "report.pdf",
|
||||
state = AttachmentState.FAILED,
|
||||
),
|
||||
Attachment(
|
||||
contentType = "application/octet-stream",
|
||||
content = "",
|
||||
state = AttachmentState.LOADING,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
AttachmentGroupSummary(
|
||||
count = 2,
|
||||
firstName = "report.pdf",
|
||||
firstType = AttachmentRenderMode.PDF,
|
||||
remainingCount = 1,
|
||||
),
|
||||
summary,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapsed group stays collapsed when attachment lifecycle updates`() {
|
||||
var attachments by mutableStateOf(
|
||||
listOf(
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = "",
|
||||
fileName = "result.png",
|
||||
state = AttachmentState.LOADING,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
CollapsibleAttachmentGroup(
|
||||
messageKey = "stable-message",
|
||||
attachments = attachments,
|
||||
) {
|
||||
Text("Attachment preview")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Attachment preview").assertExists()
|
||||
compose.onNodeWithContentDescription("Collapse attachments").assertExists()
|
||||
compose.onNodeWithTag("attachment-group-toggle-stable-message").performClick()
|
||||
compose.onNodeWithText("Attachment preview").assertDoesNotExist()
|
||||
compose.onNodeWithContentDescription("Expand attachments").assertExists()
|
||||
|
||||
compose.runOnIdle {
|
||||
attachments = attachments.map {
|
||||
it.copy(
|
||||
content = "loaded",
|
||||
cachedUri = "content://media/result",
|
||||
state = AttachmentState.LOADED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Attachment preview").assertDoesNotExist()
|
||||
compose.onNodeWithTag("attachment-group-toggle-stable-message").performClick()
|
||||
compose.onNodeWithText("Attachment preview").assertExists()
|
||||
}
|
||||
}
|
||||
+50
@@ -3,11 +3,18 @@ package com.hermesandroid.relay.ui.components
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImageGenerationPlaceholderTest {
|
||||
|
||||
@Test
|
||||
fun `duration label is stable and clamps negative elapsed time`() {
|
||||
assertEquals("12.4s", formatGenerationDuration(12_440))
|
||||
assertEquals("0.0s", formatGenerationDuration(-100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active image generation uses diffusion placeholder`() {
|
||||
val active = ToolCall(
|
||||
@@ -119,4 +126,47 @@ class ImageGenerationPlaceholderTest {
|
||||
assertTrue(early < resolved)
|
||||
assertTrue(reset < resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rubiks sphere animates exactly one outer slice at a time`() {
|
||||
(0..100).forEach { frame ->
|
||||
val angles = rubiksSliceAngles(frame / 100f)
|
||||
val activeSlices = listOf(angles.topY, angles.frontZ, angles.rightX)
|
||||
.count { kotlin.math.abs(it) > 0.0001f }
|
||||
|
||||
assertTrue("overlapping slices at frame $frame", activeSlices <= 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rotate preference cycles all image generation styles`() {
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.LatentGrid,
|
||||
resolveImageGenerationVisualStyle("rotate", 0),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.ParticleOrb,
|
||||
resolveImageGenerationVisualStyle("rotate", 1),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.Constellation,
|
||||
resolveImageGenerationVisualStyle("rotate", 2),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.LatentGrid,
|
||||
resolveImageGenerationVisualStyle("rotate", 3),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinned image generation preference ignores rotation index`() {
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.ParticleOrb,
|
||||
resolveImageGenerationVisualStyle("sphere", 99),
|
||||
)
|
||||
assertEquals(
|
||||
ImageGenerationVisualStyle.Constellation,
|
||||
resolveImageGenerationVisualStyle("nodes", 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -28,12 +29,17 @@ class ImageGenerationPlaceholderUiTest {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
ImageGenerationPlaceholder(Modifier.padding(16.dp))
|
||||
ImageGenerationPlaceholder(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
phaseOverride = 0.5f,
|
||||
elapsedOverrideMillis = 12_400,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.mainClock.advanceTimeBy(2_400)
|
||||
compose.onNodeWithContentDescription("Rendering image").assertExists()
|
||||
compose.onNodeWithText("12.4s").assertExists()
|
||||
compose.onRoot().captureRoboImage("build/verification-shots/image-generation-placeholder.png")
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import android.os.Build
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class OnboardingPermissionPolicyTest {
|
||||
|
||||
@Test
|
||||
fun permissionSetup_followsSuccessfulConnection() {
|
||||
assertEquals(
|
||||
OnboardingPage.Permissions,
|
||||
standardOnboardingPages[standardOnboardingPages.indexOf(OnboardingPage.Connect) + 1],
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun android13WithoutNotificationGrant_requestsPermission() {
|
||||
assertEquals(
|
||||
OnboardingNotificationAction.RequestPermission,
|
||||
onboardingNotificationAction(
|
||||
sdkInt = Build.VERSION_CODES.TIRAMISU,
|
||||
notificationsPermitted = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grantedNotificationPermission_finishesSetup() {
|
||||
assertEquals(
|
||||
OnboardingNotificationAction.Finish,
|
||||
onboardingNotificationAction(
|
||||
sdkInt = Build.VERSION_CODES.TIRAMISU,
|
||||
notificationsPermitted = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preAndroid13_finishesWithoutRuntimeNotificationPrompt() {
|
||||
assertEquals(
|
||||
OnboardingNotificationAction.Finish,
|
||||
onboardingNotificationAction(
|
||||
sdkInt = Build.VERSION_CODES.S_V2,
|
||||
notificationsPermitted = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.hermesandroid.relay.ui.onboarding
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xxhdpi")
|
||||
class PermissionSetupPageTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun missingNotifications_showsOneTapRecommendationAndOptionalReview() {
|
||||
var permissionRequests = 0
|
||||
var finishes = 0
|
||||
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
PermissionSetupPage(
|
||||
notificationAction = OnboardingNotificationAction.RequestPermission,
|
||||
onEnableNotifications = { permissionRequests += 1 },
|
||||
onReviewPermissions = {},
|
||||
onFinish = { finishes += 1 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText("Finish setup").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Ready — no phone permission needed.").assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Review optional permissions")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
.assertIsEnabled()
|
||||
composeTestRule
|
||||
.onNodeWithText("Enable chat alerts")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
composeTestRule
|
||||
.onNodeWithText("Not now")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals(1, permissionRequests)
|
||||
assertEquals(1, finishes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun grantedNotifications_showsFinishWithoutPermissionPrompt() {
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
PermissionSetupPage(
|
||||
notificationAction = OnboardingNotificationAction.Finish,
|
||||
onEnableNotifications = {},
|
||||
onReviewPermissions = {},
|
||||
onFinish = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Notifications are enabled for this app.")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Enable chat alerts").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Not now").assertDoesNotExist()
|
||||
composeTestRule
|
||||
.onNode(hasText("Finish setup") and hasClickAction())
|
||||
.performScrollTo()
|
||||
.assertIsEnabled()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealth
|
||||
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
|
||||
import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -9,6 +11,30 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardManageParityTest {
|
||||
@Test
|
||||
fun componentHealthLines_surfaceDegradedDetailsWithoutChangingReachability() {
|
||||
val lines = dashboardComponentHealthLines(
|
||||
DashboardComponentHealthRollup(
|
||||
supported = true,
|
||||
overall = "degraded",
|
||||
components = listOf(
|
||||
DashboardComponentHealth(
|
||||
name = "platforms",
|
||||
status = "degraded",
|
||||
configured = 3,
|
||||
connected = 1,
|
||||
unhandled5xxCount5m = 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("platforms: degraded · 1/3 connected · 2 server errors / 5m"),
|
||||
lines,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oauthMcpRow_exposesAuthenticateOnlyForAuthoritativeOauthField() {
|
||||
val oauth = Json.parseToJsonElement(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AssistantSpeechCursorTest {
|
||||
@Test
|
||||
fun `speaks every assistant bubble created during one tool run`() {
|
||||
val history = listOf(message("old", MessageRole.ASSISTANT, "Previous answer."))
|
||||
val cursor = AssistantSpeechCursor(history)
|
||||
|
||||
val interim = message(
|
||||
id = "interim",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "I'll check that.",
|
||||
streaming = false,
|
||||
)
|
||||
val first = cursor.poll(history + interim)
|
||||
assertEquals(listOf("I'll check that."), first.deltas.map { it.text })
|
||||
assertTrue(first.hasTurnAssistant)
|
||||
|
||||
val final = message(
|
||||
id = "final",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "The check is complete.",
|
||||
streaming = false,
|
||||
)
|
||||
val second = cursor.poll(history + interim + final)
|
||||
assertEquals(listOf("The check is complete."), second.deltas.map { it.text })
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("I'll check that.\n\nThe check is complete.", second.aggregateText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history id adoption preserves stable ui identity without replay`() {
|
||||
val live = message(
|
||||
id = "client-id",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Already spoken.",
|
||||
uiKey = "stable-bubble",
|
||||
)
|
||||
val cursor = AssistantSpeechCursor(listOf(live))
|
||||
val reconciled = live.copy(id = "server-id", uiKey = "stable-bubble")
|
||||
|
||||
val batch = cursor.poll(listOf(reconciled))
|
||||
|
||||
assertTrue(batch.deltas.isEmpty())
|
||||
assertFalse(batch.hasTurnAssistant)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new bubble requests a speech boundary without trailing punctuation`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
val interim = message("interim", MessageRole.ASSISTANT, "Let me check")
|
||||
val final = message("final", MessageRole.ASSISTANT, "Done.")
|
||||
|
||||
val first = cursor.poll(listOf(interim))
|
||||
val second = cursor.poll(listOf(interim, final))
|
||||
|
||||
assertFalse(first.deltas.single().startsNewBubble)
|
||||
assertTrue(second.deltas.single().startsNewBubble)
|
||||
assertEquals("Done.", second.deltas.single().text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `baseline history and repeated emissions are never replayed`() {
|
||||
val history = listOf(message("old", MessageRole.ASSISTANT, "Previous answer."))
|
||||
val cursor = AssistantSpeechCursor(history)
|
||||
|
||||
assertTrue(cursor.poll(history).deltas.isEmpty())
|
||||
assertFalse(cursor.poll(history).hasTurnAssistant)
|
||||
|
||||
val current = history + message("new", MessageRole.ASSISTANT, "Fresh reply.")
|
||||
assertEquals(listOf("Fresh reply."), cursor.poll(current).deltas.map { it.text })
|
||||
assertTrue(cursor.poll(current).deltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only strict suffix growth is spoken after transcript reconciliation`() {
|
||||
val cursor = AssistantSpeechCursor(emptyList())
|
||||
|
||||
cursor.poll(listOf(message("answer", MessageRole.ASSISTANT, "Working on")))
|
||||
val grown = cursor.poll(
|
||||
listOf(message("answer", MessageRole.ASSISTANT, "Working on it now.")),
|
||||
)
|
||||
assertEquals(listOf(" it now."), grown.deltas.map { it.text })
|
||||
|
||||
val rewritten = cursor.poll(
|
||||
listOf(message("answer", MessageRole.ASSISTANT, "Done.")),
|
||||
)
|
||||
assertTrue(rewritten.deltas.isEmpty())
|
||||
assertEquals("Done.", rewritten.aggregateText)
|
||||
}
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
streaming: Boolean = false,
|
||||
uiKey: String = id,
|
||||
) = ChatMessage(
|
||||
id = id,
|
||||
role = role,
|
||||
content = content,
|
||||
timestamp = 1L,
|
||||
isStreaming = streaming,
|
||||
uiKey = uiKey,
|
||||
)
|
||||
}
|
||||
+34
@@ -177,6 +177,40 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(gatewayClient.hasActiveTurn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayRichCardActionStaysOnGatewayInsteadOfDrainingThroughSessionsApi() {
|
||||
viewModel.sseFallbackEndpoint = "sessions"
|
||||
val cardMessageId = "card-message"
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = cardMessageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.onTextDelta(
|
||||
cardMessageId,
|
||||
"""
|
||||
CARD:{"type":"approval_request","id":"test-card","actions":[{"label":"Approve","value":"approve","mode":"send_text"}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
handler.onTurnComplete(cardMessageId)
|
||||
val card = handler.messages.value.single { it.id == cardMessageId }.cards.single()
|
||||
val apiRequestsBeforeAction = apiServer.requestCount
|
||||
|
||||
viewModel.dispatchCardAction(
|
||||
messageId = cardMessageId,
|
||||
cardKey = card.id!!,
|
||||
action = card.actions.single(),
|
||||
)
|
||||
|
||||
val submit = gatewayHarness.awaitRpc("prompt.submit")
|
||||
assertEquals("approve", (submit["text"] as JsonPrimitive).content)
|
||||
assertEquals(apiRequestsBeforeAction, apiServer.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyPersonalityCatalogLoadsAndSurvivesRefreshFailure() {
|
||||
viewModel.updateApiClient(null)
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.LooperMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
@LooperMode(LooperMode.Mode.PAUSED)
|
||||
class ConnectionViewModelColdStartTest {
|
||||
private lateinit var application: Application
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
runBlocking {
|
||||
application.relayDataStore.edit { it.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `API fallback stays absent before persisted connection hydration`() {
|
||||
val viewModel = ConnectionViewModel(application)
|
||||
|
||||
assertEquals("", viewModel.apiServerUrl.value)
|
||||
assertEquals("", viewModel.effectiveApiServerUrl.value)
|
||||
assertNull(viewModel.apiClient.value)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,31 @@ import org.junit.Test
|
||||
|
||||
class EffectiveDashboardRouteTest {
|
||||
|
||||
@Test
|
||||
fun `discovered API route stays dormant when fallback is not configured`() {
|
||||
val tailscale = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
)
|
||||
|
||||
assertEquals("", resolveEffectiveApiServerUrl("", tailscale))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected API route wins after fallback is configured`() {
|
||||
val tailscale = EndpointCandidate(
|
||||
role = "tailscale",
|
||||
priority = 1,
|
||||
api = ApiEndpoint("100.71.8.56", 8642),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"http://100.71.8.56:8642",
|
||||
resolveEffectiveApiServerUrl("http://192.168.1.20:8642", tailscale),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected route dashboard wins over explicit primary dashboard`() {
|
||||
val connection = connection(
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceTurnSessionFenceTest {
|
||||
@Test
|
||||
fun `pending new chat rejects switch to unrelated existing session`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = null)
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
|
||||
assertTrue(fence.accepts(sessionId = null, messages = emptyList()))
|
||||
assertFalse(
|
||||
fence.accepts(
|
||||
sessionId = "existing-session",
|
||||
messages = listOf(user("other-user")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending new chat adopts only session containing submitted turn`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = null)
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
val messages = listOf(user("voice-user"))
|
||||
|
||||
assertTrue(fence.accepts(sessionId = "new-session", messages = messages))
|
||||
assertTrue(fence.accepts(sessionId = "new-session", messages = emptyList()))
|
||||
assertFalse(fence.accepts(sessionId = "different-session", messages = messages))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing session is fixed for complete voice turn`() {
|
||||
val fence = VoiceTurnSessionFence(initialSessionId = "active")
|
||||
fence.bindSubmittedUser("voice-user")
|
||||
|
||||
assertTrue(fence.accepts(sessionId = "active", messages = emptyList()))
|
||||
assertFalse(fence.accepts(sessionId = null, messages = emptyList()))
|
||||
assertFalse(fence.accepts(sessionId = "other", messages = emptyList()))
|
||||
}
|
||||
|
||||
private fun user(uiKey: String) = ChatMessage(
|
||||
id = "id-$uiKey",
|
||||
role = MessageRole.USER,
|
||||
content = "Voice request",
|
||||
timestamp = 1L,
|
||||
uiKey = uiKey,
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.hermesandroid.relay.viewmodel.connection
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class UpstreamTransportControllerAuthClientTest {
|
||||
@Test
|
||||
fun dashboardHttpClient_isReusedUntilRouteChangesThenDisposed() {
|
||||
var dashboardUrl = "https://hermes.example.test"
|
||||
val controller = UpstreamTransportController(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
activeConnectionIdProvider = { null },
|
||||
dashboardUrlProvider = { dashboardUrl },
|
||||
gatewayKeepAliveProvider = { false },
|
||||
)
|
||||
|
||||
val first = controller.dashboardHttpClientForActive(dashboardUrl)
|
||||
val reused = controller.dashboardHttpClientForActive(dashboardUrl)
|
||||
assertSame(first, reused)
|
||||
|
||||
dashboardUrl = "https://hermes.example.test/alternate"
|
||||
val moved = controller.dashboardHttpClientForActive(dashboardUrl)
|
||||
assertNotSame(first, moved)
|
||||
assertTrue(first.dispatcher.executorService.isShutdown)
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -1,27 +1,30 @@
|
||||
# Voice Settings design QA
|
||||
# Agent Passport design QA
|
||||
|
||||
Status: **passed**
|
||||
|
||||
## Compared
|
||||
|
||||
- Source: `voice-audit/mockup-04-recommended-hybrid.png`
|
||||
- Implementation: `voice-audit/implementation-03-provider-preview-refined.png`
|
||||
- Viewport: Android `Medium_Phone`, 1080 x 2400 px (about 390 dp wide), font scale 1.0
|
||||
- State: Output tab, xAI Grok TTS, Eve selected and actively previewing
|
||||
- Source: `C:\Users\Bailey\.codex\generated_images\019f6640-e6dc-7c33-8aca-a1610e2a19f0\call_JzVlJKlG8KWTJRo2xup7oTlq.png`
|
||||
- Implementation: `C:\Users\Bailey\.codex\visualizations\2026\07\25\agent-drawer-audit\passport-qa-final.png`
|
||||
- Source size: 852 x 1846 px
|
||||
- Device viewport: Android, 1080 x 2340 px, font scale 1.0
|
||||
- State: Agent tab, Victor pinned profile, disconnected gateway
|
||||
|
||||
## Resolved findings
|
||||
|
||||
- **P2 - Group boundaries were too weak.** Provider, model/voice, and language/quality surfaces now use the product's rounded Material cards with outline-token borders and a subtle surface-variant fill.
|
||||
- **P2 - Preview state was too tall.** The existing voice waveform now accepts a caller-provided height; inline preview uses a compact 28 dp waveform while the full voice overlay keeps its 56 dp default.
|
||||
- **P2 - Long catalogs displaced the core controls.** The selected/recommended voices remain inline, while the full catalog opens from a compact `View all voices` action.
|
||||
- **P2 - Secondary controls competed with selection.** Latency, fallback, expressive tags, and manual IDs now stay inside the collapsed Language & quality section.
|
||||
- **P3 - Active tab lacked the target emphasis.** The selected segment now uses the theme primary/on-primary colors.
|
||||
- **P2 - Header hierarchy and identity treatment differed from the selected concept.** The sheet now uses the Agent Passport title, large profile card, circular avatar, pin-style profile chip, status line, and four-column metrics strip.
|
||||
- **P2 - Configuration controls lacked the concept's card hierarchy.** Personality, model, and reasoning are grouped into one outlined Active configuration card with matching icon medallions and row affordances.
|
||||
- **P2 - Safety controls did not match the selected inline treatment.** Approval status, chat override, and fast tier now share one outlined Safety & speed card with compact segmented controls.
|
||||
- **P2 - Primary action looked detached and visually unfinished.** Start new chat now uses the selected full-width purple-to-relay gradient, white outlined chat icon, and a single clean label.
|
||||
- **P2 - Nested sheet and content gestures caused visible vertical bounce.** Sheet drag gestures are disabled for this scrollable detail surface and overscroll is suppressed; repeated device swipes keep the sheet anchored while the content scrolls.
|
||||
|
||||
## Final review
|
||||
|
||||
- Layout and hierarchy match the selected hybrid: summary, Output/Listening/Advanced tabs, grouped provider, grouped model/voice, inline play/stop, active waveform, and collapsed language/quality.
|
||||
- Interactions are wired: tabs switch sections, provider/model/voice selections update the draft, only one preview can play, the active play button becomes Stop, and full catalogs remain accessible.
|
||||
- Accessibility: native Material controls retain semantics and practical tap targets; the active preview has explicit play/stop descriptions; the screen remains vertically scrollable for large text and long provider catalogs.
|
||||
- Accepted native adaptation: Android exposed dropdowns and dynamic account-backed catalogs use more vertical space than the static concept. The hierarchy and core actions remain intact without clipping or overlap.
|
||||
- Layout and hierarchy match the selected Agent Passport direction: profile identity, metrics, Agent/Session tabs, active configuration, safety and speed, primary chat action, and identity customization.
|
||||
- Interactions remain wired: profile selection, configuration rows, Agent/Session tabs, approval and fast controls, new chat, and identity customization.
|
||||
- Runtime values remain live rather than copied from the static concept; connection state, message count, route label, provider, and model can therefore differ from the reference.
|
||||
- Native Android adaptation preserves system insets and scrollability on the taller 1080 x 2340 device viewport.
|
||||
|
||||
No open P0, P1, or P2 findings.
|
||||
|
||||
final result: passed
|
||||
|
||||
@@ -211,6 +211,68 @@ Use this reference for Quest visual tone, but do not force the phone layout into
|
||||
- Status logs can become compact floating diagnostic panels.
|
||||
- QR pairing should use the same state model and log language, adapted to headset camera/input constraints.
|
||||
|
||||
## Motion Language
|
||||
|
||||
Motion should explain an agent state change. It is not ambient decoration.
|
||||
For generative work, the preferred visual grammar is:
|
||||
|
||||
`unresolved material → recognizable structure → verified success → controlled dissolution`
|
||||
|
||||
The first and last frames of a loop must match exactly. Construction and
|
||||
deconstruction should use related trajectories so the animation feels
|
||||
reversible rather than resetting. Keep the background stable across the seam;
|
||||
moving lights, unmatched point positions, and phase discontinuities make even a
|
||||
technically continuous loop appear to flash.
|
||||
|
||||
### Image-generation progress
|
||||
|
||||
The image-generation treatments establish the current reference:
|
||||
|
||||
- **Grid:** pixels diffuse into and out of a clean, symmetrical field. Preserve
|
||||
the smooth pixel-wave materialization; do not expose the background through a
|
||||
nearly complete grid in a way that makes the surface look torn or dirty.
|
||||
- **Sphere:** sparse depth particles are pulled into a structured spherical
|
||||
surface and released along the same paths. The resolved form is divided into
|
||||
thirds and may perform Rubik-like outer-slice turns. Each turn owns exactly
|
||||
one valid outer layer at a time; overlapping slice animations read as broken
|
||||
geometry. Avoid jellyfish motion, wavy path lines, or a simple spin-and-breathe
|
||||
loop.
|
||||
- **Nodes:** orphan nodes and short path fragments converge into an exact,
|
||||
symmetrical lattice. Edges connect progressively only as their endpoints
|
||||
resolve. Once the complete topology is valid, show one clean success sweep
|
||||
and a restrained settle pulse before deconstruction.
|
||||
|
||||
Appearance offers **Rotate**, **Grid**, **Sphere**, and **Nodes**. Rotate is the
|
||||
default and assigns one complete treatment per generation in repeating order;
|
||||
it must not swap styles while a single image is being generated.
|
||||
|
||||
Particles should be structural material with an origin and destination, not a
|
||||
generic wallpaper effect. Each treatment can use different material—pixels,
|
||||
depth dust, nodes, or path fragments—while retaining the same
|
||||
construct/verify/deconstruct story.
|
||||
|
||||
### Result handoff
|
||||
|
||||
The progress animation and generated result are one continuous surface:
|
||||
|
||||
1. Advance the active animation to a stable resolved keyframe.
|
||||
2. Keep that resolved form mounted beneath the result.
|
||||
3. Materialize the real result through geometry related to the progress motif.
|
||||
4. Let the result take visual ownership before removing the progress layer.
|
||||
|
||||
Do not insert a blank frame, rebuild the surrounding bubble, hard-swap the
|
||||
content, or stop the animation at an arbitrary phase. The preferred image
|
||||
reveal is a smooth diagonal pixel wave, long enough for the materialization to
|
||||
be legible but without delaying access to the result.
|
||||
|
||||
### Iteration and verification
|
||||
|
||||
Animation work should use a debug-only lab reachable from Developer Options.
|
||||
The lab should offer deterministic style selection, speed controls, restart,
|
||||
and an explicit result reveal. It must not ship in release variants. Use the
|
||||
fast sideload build/deploy loop for visual tuning, then run the full Android
|
||||
gate only after the motion is accepted.
|
||||
|
||||
## Implementation Notes For Future Sessions
|
||||
|
||||
When implementing this reference:
|
||||
|
||||
+51
-1
@@ -977,7 +977,16 @@ The plan called for adding a `// VOICE HOOK` callback to `ChatViewModel` so `Voi
|
||||
- Observes the same state the chat UI observes — no divergence risk.
|
||||
- Transcribed user text routes through the existing `chatVm.sendMessage(text)` path, so voice utterances appear as normal user messages in chat history. Load the session on another device and you see the transcript.
|
||||
|
||||
**Trade-off documented in a KDoc comment:** relies on the "last `isStreaming=true` message is the current turn" invariant. If `ChatViewModel` ever streams multiple assistant messages concurrently (multi-agent hand-off, for example) this needs a dedicated per-turn flow. Flagged for Phase 3+ review.
|
||||
**Follow-up (2026-07-25):** the observer no longer relies on a single
|
||||
`isStreaming=true` assistant message. A per-turn cursor follows every new
|
||||
assistant bubble until the run-level `ChatViewModel.isStreaming` state ends,
|
||||
so tool handoffs and the final answer are narrated in order. The cursor fences
|
||||
the pre-turn stable UI identities and submitted user-turn/session identity,
|
||||
and only speaks strict content suffix growth, preventing StateFlow/history
|
||||
reconciliation or a pending-new-chat session switch from replaying old or
|
||||
rewritten text. Each newly observed assistant bubble also inserts a speech
|
||||
boundary, so an interim fragment without punctuation cannot run into the final
|
||||
answer.
|
||||
|
||||
### Alternatives explicitly rejected
|
||||
|
||||
@@ -2120,6 +2129,9 @@ stable identity independent of endpoint URLs.
|
||||
- **Routing is automatic.** Chat prefers Dashboard/Gateway and falls back to the
|
||||
API server only when configured and usable. Users choose a transport only in
|
||||
advanced diagnostics or compatibility settings, not during normal setup.
|
||||
Endpoint discovery may advertise a conventional API route, but does not enable
|
||||
that optional fallback unless the connection has persisted API configuration;
|
||||
cold-start state remains unconfigured until that persisted value is hydrated.
|
||||
|
||||
**Product flow.** Normal onboarding asks for one Hermes address, discovers the
|
||||
Dashboard/Gateway, authenticates through its supported provider, and finishes
|
||||
@@ -2149,3 +2161,41 @@ An API endpoint or Relay can be added later without recreating the connection.
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayChatClient.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt`
|
||||
- `docs/upstream-surface-matrix.md`
|
||||
|
||||
---
|
||||
|
||||
## ADR 39 — Android dashboard redirect auth uses native PKCE
|
||||
|
||||
**Status:** Accepted (2026-07-25).
|
||||
|
||||
**Context.** Android originally completed redirect-provider dashboard sign-in
|
||||
inside a WebView and imported cookies. Current upstream Gateway can advertise a
|
||||
native authorization-code flow with PKCE, bearer refresh, and WebSocket ticket
|
||||
support. That contract allows the provider to use the user's browser session
|
||||
without exposing browser cookies to the app.
|
||||
|
||||
**Decision.** When `/api/status.auth_flows` contains `native_pkce`, Android uses
|
||||
an AndroidX Custom Tab and a lifecycle-owned callback bound to literal
|
||||
`127.0.0.1` on an OS-assigned port. The selected provider, S256 challenge,
|
||||
redirect URI, and CSRF state are sent to upstream. Verifier/state remain only
|
||||
in the sign-in coroutine; callback input is bounded and state-validated before
|
||||
errors or codes are accepted. Tokens are encrypted per connection and attached
|
||||
only to the exact trusted dashboard base. Native bearer exchange requires
|
||||
HTTPS, except literal loopback development. Missing capability selects the
|
||||
legacy cookie/WebView flow; native failures do not silently downgrade.
|
||||
|
||||
All dashboard consumers share the same authenticated client policy: Gateway
|
||||
chat and tickets, Manage and cold prewarm, standard voice, and voice config.
|
||||
Local sign-out clears cookies and native tokens and closes the cached Gateway
|
||||
socket.
|
||||
|
||||
**Consequences.**
|
||||
|
||||
- Redirect-provider sign-in remains in the app task while using the system
|
||||
browser's provider session and security posture.
|
||||
- Process death or cancellation discards the ephemeral authorization and simply
|
||||
requires a new attempt.
|
||||
- Plain-LAN HTTP dashboards must be upgraded to HTTPS before native bearer auth
|
||||
is offered.
|
||||
- Older upstream versions remain usable through the explicitly identified
|
||||
WebView compatibility path.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
|
||||
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
|
||||
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -61,7 +61,7 @@
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
|
||||
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
|
||||
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -85,7 +85,7 @@
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
|
||||
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
|
||||
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -109,7 +109,7 @@
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
|
||||
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
|
||||
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "5bd4e324513379be98126b6b78d4f5bfd94e9fcbc81a3cd949c2b6f20948617d",
|
||||
"main": "535984df5230a1120622c384f76056611c4edc73599b6e7f2311827c776eedd7",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -133,7 +133,7 @@
|
||||
"docs_source_sha256": {
|
||||
"index.md": "bbaa33578cd9d8963150ed861e7a0bd489d3938b7c6c2d572beea725f319e136",
|
||||
"guide/quick-start.md": "1a5c4c89a992bd57c1e93702b270a466675c7f65fb8ebe20968febc621877049",
|
||||
"guide/getting-started.md": "fd646f9c6ad11a3da148c6026fa9479045af8a0ce09457f11e385961b301ec15",
|
||||
"guide/getting-started.md": "d1d348b2ff7c05d639cddf33d14c1a2e4cac26e072fcd2b2988e37ea694d4bbd",
|
||||
"guide/release-tracks.md": "390982de1e1430bed0b8a0e854431c97e3368cf04e477197eb5cb893db225f51",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
|
||||
+14
-13
@@ -14,9 +14,9 @@ It's not a hosted AI service. It's a companion app for the Hermes agent you run,
|
||||
|
||||
QUICK START
|
||||
|
||||
1. Run hermes-agent with its API server enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and enter your server's address (for example [http://192.168.1.100:8642](http://192.168.1.100:8642)).
|
||||
3. The setup wizard checks what your server supports and shows a readiness card — then you're talking.
|
||||
1. Run hermes-agent with its Dashboard/Gateway enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and select the nearby Dashboard, or enter its address and custom port.
|
||||
3. Sign in through the Dashboard when prompted. The setup wizard verifies Chat, sessions, Manage, and voice before finishing.
|
||||
|
||||
No server yet? Tap "Try the demo" on the setup screen to explore the app offline — a sample conversation, no login or server required.
|
||||
|
||||
@@ -24,7 +24,7 @@ A plain Hermes install is enough. Chat, management, and voice all work with no p
|
||||
|
||||
HOW IT WORKS
|
||||
|
||||
Chat streams directly from your Hermes API Server in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and more voice engines.
|
||||
Chat, sessions, Manage, and voice use the Hermes Dashboard/Gateway with one sign-in. A headless API server remains an automatic compatibility fallback. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and more voice engines.
|
||||
|
||||
GOOGLE PLAY BUILD
|
||||
|
||||
@@ -85,11 +85,12 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.4.9 - Clearer Hermes connections
|
||||
v1.5.0 - Hermes, always in reach
|
||||
|
||||
* Connect through the Hermes dashboard with one sign-in.
|
||||
* Setup now explains nearby, remote, Tailscale, custom-port, and optional Relay paths.
|
||||
* Server default consistently displays Hermes' pinned active profile.
|
||||
* Secure Dashboard sign-in and a clearer Agent Passport.
|
||||
* Active background chats with actionable approval and question alerts.
|
||||
* Better attachments, image generation, voice, routing, and recovery.
|
||||
* Optional permission guidance that never blocks chat.
|
||||
```
|
||||
|
||||
## Category
|
||||
@@ -156,12 +157,12 @@ This app is a client for a Hermes server the user runs themselves, so a fresh in
|
||||
|
||||
### Foreground service permissions
|
||||
|
||||
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for `GatewayKeepAliveService`, backing the opt-in **Persistent connection** feature (off by default). At submission, complete **App content → Foreground service permissions** for `specialUse`:
|
||||
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for `GatewayKeepAliveService`. It protects user-started active chat turns automatically and also backs the opt-in **Persistent connection** feature for idle connectivity. At submission, complete **App content → Foreground service permissions** for `specialUse`:
|
||||
|
||||
- **Use case:** maintains a persistent connection to the user's own Hermes agent server so the assistant stays responsive and delivers replies while the app is backgrounded.
|
||||
- **Use case:** keeps a user-started Hermes turn connected until it finishes or pauses for user input, including multiple concurrent sessions; optionally maintains the idle connection when the user enables Persistent connection.
|
||||
- **Why a foreground service:** it's a real-time, user-initiated streaming connection that must survive Doze / background execution limits; `dataSync` is force-stopped after a 6-hour/day cap on Android 15, so `specialUse` is the only fit for "stay connected."
|
||||
- **User control:** off by default; enabled only via *Settings → Quick Controls → Persistent connection*; shows an ongoing notification with a **Turn off** action; ends when the app is swiped from recents.
|
||||
- Google usually asks for a short screen recording of the toggle + notification.
|
||||
- **User control:** the service starts when the user sends a chat message and stops after all active turns settle. Continuous idle retention is off by default and enabled only via *Settings → Quick Controls → Persistent connection*. The ongoing notification shows the active/waiting session count; its **Turn off always-on** action disables idle retention without interrupting active work. Swiping the app from recents ends it.
|
||||
- Google usually asks for a short screen recording of a backgrounded active turn, the ongoing notification, and the optional persistent toggle.
|
||||
|
||||
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
|
||||
|
||||
@@ -172,7 +173,7 @@ No data collection or sharing to declare: no telemetry, ads, or third-party anal
|
||||
### Sensitive / runtime permissions in the Play build
|
||||
|
||||
- `RECORD_AUDIO` — Voice mode, requested at use.
|
||||
- `POST_NOTIFICATIONS` — turn-complete + keep-alive notifications, requested on API 33+.
|
||||
- `POST_NOTIFICATIONS` — chat input, turn-complete, and keep-alive notifications, requested on API 33+.
|
||||
- `CAMERA` — QR pairing / attachments, requested at use.
|
||||
- Notification listener (companion) — user-enabled in system settings.
|
||||
|
||||
|
||||
+24
-2
@@ -170,6 +170,17 @@ Phone control — mirrors upstream relay protocol.
|
||||
|
||||
### 3.3 Auth Flow
|
||||
|
||||
Dashboard/Gateway redirect providers use the upstream native PKCE contract when
|
||||
`GET /api/status` advertises `native_pkce`. Android opens the selected provider
|
||||
in a Custom Tab and owns a single ephemeral callback on
|
||||
`http://127.0.0.1:<os-assigned-port>/callback`. PKCE verifier and CSRF state
|
||||
exist only for that sign-in coroutine. Access and refresh tokens are encrypted
|
||||
per connection and are attached only to the exact trusted dashboard base for
|
||||
Manage, Gateway tickets, and standard voice. Native exchange is allowed only
|
||||
for HTTPS dashboard addresses (plus literal loopback for development). A
|
||||
gateway without the capability uses the legacy cookie/WebView flow; a failed
|
||||
native attempt never silently downgrades.
|
||||
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
|
||||
As of **v3 (ADR 24)**, the QR can also carry an ordered list of **endpoint candidates** (`lan` / `tailscale` / `public` / operator-defined roles). A single pairing covers every network the phone might be on — the phone picks the highest-priority reachable candidate at connect time and re-probes on network change. The single-URL top-level fields still appear in v3 QRs for backward compatibility; old phones ignore `endpoints` via `ignoreUnknownKeys = true`, new phones prefer `endpoints` and fall back to the top-level URL when the array is absent. See [`docs/remote-access.md`](remote-access.md) for the operator-facing setup per mode.
|
||||
@@ -378,6 +389,7 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Empty state** — Logo + "Start a conversation" + suggestion chips that populate input
|
||||
- **Agent sheet — Profile section (v0.6.0, updated 2026-05-18)** — upstream Hermes profiles auto-discovered by the relay at `~/.hermes/profiles/*/`. Selecting one routes chat/session calls to that profile's advertised `api_server_url` when present, giving proper Hermes isolation for sessions, memory, tools, provider auth, and SOUL/default model. If no profile API route is advertised, the app falls back to overlaying `model` + `SOUL.md` (as `system_message`) on the active Connection's API server. Selection is persisted per Connection/profile context. Hidden when the server advertises no profiles. See `docs/decisions.md` §21.
|
||||
- **Agent sheet — Personality section** — personalities fetched from `GET /api/config` (`config.agent.personalities`). Shows server default (from `config.display.personality`) + all configured. Active personality name shown on assistant chat bubbles.
|
||||
- **Agent sheet — Approval controls** — gateway contract v3 exposes the profile-persisted `approvals.mode` policy (`manual` / `smart` / `off`) separately from YOLO. The launch/default profile gets the three-way control; multiplexed non-launch profiles reconcile `session.info.approval_mode` read-only until upstream config RPCs honor profile scope. The existing YOLO switch remains an explicit per-session override and never silently writes profile configuration. Older gateways keep chat and YOLO available while the profile control explains that an upstream update is required.
|
||||
- **Streaming dots** — animated pulsing 3-dot indicator replaces static "streaming..." text
|
||||
- Displays: streaming delta text, tool progress cards (auto-expand while running, auto-collapse on complete), thinking/reasoning blocks (collapsible), per-message token counts + cost
|
||||
|
||||
@@ -551,11 +563,21 @@ MEDIA:hermes-relay://<url-safe-16-byte-token>
|
||||
2. `ChatViewModel` inserts a LOADING `Attachment` with `relayToken` set immediately (message updates via `ChatHandler.mutateMessage`).
|
||||
3. On Wi-Fi, or on cellular when `autoFetchOnCellular` is true: `RelayHttpClient.fetchMedia(token)` issues `GET /media/{token}` with the bearer header. URL is derived by swapping `ws://`→`http://`, `wss://`→`https://` on the stored relay URL.
|
||||
4. Bytes are checked against `maxInboundSizeMb`. If oversize → FAILED placeholder. Otherwise `MediaCacheWriter` writes them to `context.cacheDir/hermes-media/<sha1>.<ext>` with LRU eviction by mtime (capped at `cachedMediaCapMb`) and returns a `content://` URI via `FileProvider.getUriForFile(context, "${applicationId}.fileprovider", file)`.
|
||||
5. The Attachment is flipped to LOADED with `cachedUri` set. `InboundAttachmentCard` dispatches by `(state × renderMode)`: `IMAGE` renders inline via `BitmapFactory.decodeByteArray` + `asImageBitmap`; `VIDEO`/`AUDIO`/`PDF`/`TEXT`/`GENERIC` render as tap-to-open file cards firing `ACTION_VIEW` with `FLAG_GRANT_READ_URI_PERMISSION` on the cached URI.
|
||||
5. The Attachment is flipped to LOADED with `cachedUri` set. `InboundAttachmentCard` dispatches by `(state × renderMode)`: `IMAGE` renders inline via `BitmapFactory.decodeByteArray` + `asImageBitmap`; `VIDEO`/`AUDIO`/`PDF`/`TEXT`/`GENERIC` render as tap-to-open file cards firing `ACTION_VIEW` with `FLAG_GRANT_READ_URI_PERMISSION` on the cached URI. Every message attachment group, including galleries and LOADING/FAILED cards, sits behind a compact collapse/expand header keyed by the message's stable UI identity. Collapsing changes presentation only: the header keeps the attachment count, first name/type, and restore affordance visible while existing retry, fetch, viewer, share, and save behavior remains mounted again after expansion.
|
||||
6. On cellular with `autoFetchOnCellular` off: the attachment stays in LOADING state with `errorMessage = "Tap to download"`, and `manualFetchAttachment()` re-runs the fetch ignoring the cellular gate.
|
||||
|
||||
**Fallback when relay isn't running:** the tool's `register_media()` call fails (connection refused / timeout / non-200) → tool logs a warning and returns the legacy bare-path form (`MEDIA:/tmp/...`). The phone's `onUnavailableMediaMarker` handler inserts a FAILED Attachment with `errorMessage = "Image unavailable — relay offline"`. Matches current behavior; placeholder is tidier than raw marker text.
|
||||
|
||||
Persisted USER history may also contain upstream-owned `@image:<absolute-path>`
|
||||
directive lines. Android recognizes only bounded, full-line image directives
|
||||
(including upstream's backtick/single-quote/double-quote path wrapping), removes
|
||||
recognized host paths from visible text, and reconstructs at most eight
|
||||
attachments. A paired Relay may resolve those paths through its authenticated
|
||||
media route; a vanilla or unavailable route renders a path-free failed
|
||||
attachment. Inline, relative, malformed, non-image, and unknown directives stay
|
||||
as text and never trigger a fetch. Client-local outbound attachments win during
|
||||
the immediate post-send reload, preventing a duplicate fetch/gallery entry.
|
||||
|
||||
**Known gap — session replay across relay restarts:** the `MediaRegistry` is in-memory. Restarting the relay invalidates all tokens. A user scrolling back into a session from yesterday sees FAILED placeholders for any now-stale token. Phone-side persistent cache (indexed by token or content hash) is the planned fix; filed as a DEVLOG follow-up.
|
||||
|
||||
**Known gap — auto-fetch threshold slider isn't enforced today.** The Settings → Inbound media → auto-fetch threshold knob is persisted but the fetch path currently only checks the cellular toggle + the hard max cap. Forward-compatibility placeholder; real enforcement needs a HEAD preflight or post-hoc byte rejection.
|
||||
@@ -836,7 +858,7 @@ utilities.
|
||||
- `VoiceRecorder` (`AudioRecord` / WAV / 16 kHz mono PCM) exposes both a STT upload file and raw PCM bytes for the realtime websocket input events.
|
||||
- `VoicePlayer` (Media3 ExoPlayer + Visualizer) remains the fallback `/voice/synthesize` playback surface.
|
||||
- `RealtimePcmPlayer` streams `/voice/output/*`, `/voice/realtime/*`, and `/voice/realtime-agent/*` PCM deltas directly to `AudioTrack`.
|
||||
- `VoiceViewModel` state machine (`Idle / Listening / Transcribing / Thinking / Speaking / Error`). Assistant text is sanitized (markdown / tool-annotations / URLs / emoji-set stripped) on each delta before a coalescing chunker (`MIN_COALESCE_LEN=40`, `MAX_BUFFER_LEN=400` secondary-break escape, 800 ms timer flush) emits sentence-scale chunks. The default queue calls `/voice/output/*` for exact renderer PCM playback; failed output turns fall back to the existing `/voice/synthesize` synth/play workers. The same stream observer watches Hermes-owned `ToolCall` state and speaks bounded status lines for running tools; execution, approval, and tool results remain in the Hermes chat/relay loop.
|
||||
- `VoiceViewModel` state machine (`Idle / Listening / Transcribing / Thinking / Speaking / Error`). Assistant text is sanitized (markdown / tool-annotations / URLs / emoji-set stripped) on each delta before a coalescing chunker (`MIN_COALESCE_LEN=40`, `MAX_BUFFER_LEN=400` secondary-break escape, 800 ms timer flush) emits sentence-scale chunks. The observer aggregates every assistant bubble created by one Hermes run, including interim tool handoffs and the final answer, and finishes speech only when the run-level stream ends. Stable bubble identity and submitted-turn/session fences prevent StateFlow/history reconciliation or a pending-new-chat session switch from speaking stale or duplicate text. Bubble boundaries flush incomplete prior text so adjacent narration cannot run together. The default queue calls `/voice/output/*` for exact renderer PCM playback; failed output turns fall back to the existing `/voice/synthesize` synth/play workers. The same stream observer watches Hermes-owned `ToolCall` state and speaks bounded status lines for running tools; execution, approval, and tool results remain in the Hermes chat/relay loop.
|
||||
- Server-side, `/voice/synthesize` runs a matching sanitizer (`plugin/relay/tts_sanitizer.py`) before handing text to the upstream `text_to_speech_tool` — defense-in-depth for any client that doesn't pre-sanitize.
|
||||
- **Barge-in** (opt-in, default off). While in `Speaking`, a `BargeInListener` runs a duplex `AudioRecord` (16 kHz mono PCM, `VOICE_COMMUNICATION` source) feeding 32 ms frames through a Silero VAD (`com.github.gkonovalov:android-vad:silero`). `AcousticEchoCanceler` + `NoiseSuppressor` attach to the ExoPlayer audio session so TTS output doesn't retrigger VAD. A single raw speech frame → `VoicePlayer.duck()` (volume 0.3f) with a 500 ms un-duck watchdog. `N` consecutive frames (2–3, sensitivity-tuned) → `interruptSpeaking()` (same cancellation path V4 wired for user taps). A 600 ms watchdog on `VoiceRecorder.amplitude` then decides: if the user keeps talking, new turn proceeds normally; if silence wins AND `resumeAfterInterruption=true`, `VoiceViewModel` re-enqueues the unplayed chunks from `spokenChunks[lastInterruptedAtChunkIndex+1..]` and flips back to `Speaking`. Settings UI exposes `BargeInPreferences` (enabled / sensitivity ∈ `Off/Low/Default/High` / resume) with an `AcousticEchoCanceler.isAvailable()`-driven compatibility badge.
|
||||
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
|
||||
|
||||
@@ -15,6 +15,7 @@ security-crypto = "1.1.0"
|
||||
tink-android = "1.23.0"
|
||||
lifecycle = "2.11.0"
|
||||
activity-compose = "1.13.0"
|
||||
browser = "1.9.0"
|
||||
appcompat = "1.7.1"
|
||||
core-ktx = "1.19.0"
|
||||
datastore = "1.2.1"
|
||||
@@ -54,6 +55,7 @@ lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-view
|
||||
|
||||
# Activity
|
||||
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
||||
browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
|
||||
# Core
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run fast Android checks before pushing a PR update.
|
||||
|
||||
The command checks the primary Play debug variant and focused CI unit tests in
|
||||
one Gradle invocation. Hosted CI remains the exhaustive all-variant gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
FOCUSED_TESTS = (
|
||||
"com.hermesandroid.relay.network.ArchitectureBoundaryTest",
|
||||
"com.hermesandroid.relay.network.relay.RelayUrlDeriverTest",
|
||||
"com.hermesandroid.relay.viewmodel.ConnectionSwitchTest",
|
||||
"com.hermesandroid.relay.util.ServerAddressTest",
|
||||
"com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest",
|
||||
"com.hermesandroid.relay.data.AppLanguageTest",
|
||||
"com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest",
|
||||
"com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest",
|
||||
"com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest",
|
||||
"com.hermesandroid.relay.voice.VoiceCommandInterpreterTest",
|
||||
"com.hermesandroid.relay.data.VoiceModePresetTest",
|
||||
"com.hermesandroid.relay.ui.components.BackgroundTaskCardTest",
|
||||
"com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest",
|
||||
"com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest",
|
||||
"com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest",
|
||||
"com.hermesandroid.relay.ui.screens.ChatUnreadStateTest",
|
||||
)
|
||||
REPOSITORY_CHECKS = (
|
||||
"check-android-locales.py",
|
||||
"check-user-docs-locales.py",
|
||||
"check-android-collection-apis.py",
|
||||
"check-version-tracks.py",
|
||||
)
|
||||
|
||||
|
||||
def run(label: str, command: list[str], env: dict[str, str]) -> None:
|
||||
print(f"\n==> {label}", flush=True)
|
||||
completed = subprocess.run(command, cwd=REPO_ROOT, env=env, check=False)
|
||||
if completed.returncode:
|
||||
raise SystemExit(f"{label} failed with exit code {completed.returncode}")
|
||||
|
||||
|
||||
def android_environment() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
if env.get("ANDROID_HOME") or env.get("ANDROID_SDK_ROOT"):
|
||||
return env
|
||||
|
||||
if sys.platform == "win32":
|
||||
local_app_data = env.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
sdk = pathlib.Path(local_app_data) / "Android" / "Sdk"
|
||||
if sdk.is_dir():
|
||||
env["ANDROID_HOME"] = str(sdk)
|
||||
env["ANDROID_SDK_ROOT"] = str(sdk)
|
||||
print(f"Using Android SDK at {sdk}")
|
||||
return env
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--skip-lint", action="store_true", help="Skip Android lint")
|
||||
parser.add_argument("--skip-tests", action="store_true", help="Skip the focused unit-test shard")
|
||||
args = parser.parse_args()
|
||||
|
||||
env = android_environment()
|
||||
for script in REPOSITORY_CHECKS:
|
||||
run(script, [sys.executable, str(REPO_ROOT / "scripts" / script)], env)
|
||||
|
||||
tasks: list[str] = []
|
||||
if not args.skip_lint:
|
||||
tasks.append(":app:lintGooglePlayDebug")
|
||||
if not args.skip_tests:
|
||||
tasks.append(":app:testSideloadDebugUnitTest")
|
||||
if not tasks:
|
||||
print("\nAndroid repository checks passed.")
|
||||
return 0
|
||||
|
||||
wrapper = REPO_ROOT / ("gradlew.bat" if sys.platform == "win32" else "gradlew")
|
||||
gradle = [
|
||||
str(wrapper),
|
||||
"--console=plain",
|
||||
"--configuration-cache",
|
||||
"-Dorg.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8",
|
||||
*tasks,
|
||||
]
|
||||
if not args.skip_tests:
|
||||
for test_name in FOCUSED_TESTS:
|
||||
gradle.extend(("--tests", test_name))
|
||||
run("Google Play lint and focused tests", gradle, env)
|
||||
print("\nAndroid pre-push checks passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user