Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a6bf0ce6 | ||
|
|
9e96111813 | ||
|
|
42efc01d58 | ||
|
|
dc93d5eab2 | ||
|
|
cafbadc583 | ||
|
|
607c26660c | ||
|
|
3882b857e3 | ||
|
|
81c186c6ba | ||
|
|
4ad0709fbe | ||
|
|
0324c9f5dd | ||
|
|
f8c31f8b59 | ||
|
|
abb4bc0ced | ||
|
|
07b683fbb5 | ||
|
|
759ac490c9 | ||
|
|
150e375284 | ||
|
|
1f49f08dbe | ||
|
|
e96aa435f2 | ||
|
|
592affca40 | ||
|
|
7e5123b22f | ||
|
|
ad3786fb0b | ||
|
|
8bd67e3363 | ||
|
|
17cf12fff9 | ||
|
|
b55e798c3b | ||
|
|
a128e910f1 | ||
|
|
a7cbf377a0 | ||
|
|
9435d43b04 | ||
|
|
52170f76ea | ||
|
|
9871b0cb7c | ||
|
|
14500096c6 | ||
|
|
1ea84630d2 | ||
|
|
d98e0b113b | ||
|
|
ea2110fa14 | ||
|
|
43357a387d | ||
|
|
336475e451 | ||
|
|
13492610a8 | ||
|
|
4e75564d33 | ||
|
|
50adab99fa | ||
|
|
284f19d62d | ||
|
|
1302da4846 | ||
|
|
dc8e076836 | ||
|
|
ce5f9c7c98 | ||
|
|
ad4e9d7b81 | ||
|
|
774ab90ad5 | ||
|
|
b296b56bce | ||
|
|
a74ad0738f | ||
|
|
6bb186ee2b | ||
|
|
db4a6f37c7 | ||
|
|
0f19deae7f | ||
|
|
fa2d17338d | ||
|
|
bc2f2b0010 | ||
|
|
dcc8d54916 | ||
|
|
a1cc34e649 | ||
|
|
47e27f6ac9 | ||
|
|
e7f882f8b5 | ||
|
|
5e53d5cfd1 | ||
|
|
b21b9c225c | ||
|
|
971a9a7e39 | ||
|
|
635aaa44e7 | ||
|
|
e088469dbf | ||
|
|
2d202bdeac | ||
|
|
798441c1e1 | ||
|
|
b424678f44 | ||
|
|
d5210bcd5e | ||
|
|
6458a854a8 | ||
|
|
65fe37a2ba | ||
|
|
9ac10cf645 | ||
|
|
bd904e26e4 | ||
|
|
ba5a2c82a5 | ||
|
|
6119f3ba79 | ||
|
|
2bcdca09e9 | ||
|
|
e2e9cc72b7 | ||
|
|
ca555fd617 | ||
|
|
3b5e61e149 | ||
|
|
1d25985aca | ||
|
|
500cc83de6 | ||
|
|
7d3c761c6a | ||
|
|
2e097035fb | ||
|
|
280c20dbca | ||
|
|
86a570e72d | ||
|
|
cbe811fb52 | ||
|
|
ac2508c5ce | ||
|
|
2ad7de4cc9 | ||
|
|
5eb5d198f4 | ||
|
|
7a86b9cee7 | ||
|
|
60f93994f5 | ||
|
|
52e0e38a81 | ||
|
|
471595240b | ||
|
|
061512d330 | ||
|
|
1c59f44ad7 | ||
|
|
269a2b5b36 | ||
|
|
a6879aac6f | ||
|
|
a04930c946 | ||
|
|
b71fea701f | ||
|
|
af54cdddb3 | ||
|
|
a717278e95 | ||
|
|
19d5237ebb | ||
|
|
6fbb21f437 | ||
|
|
c7f54321ad | ||
|
|
e6b9933d46 | ||
|
|
c27ee439ef | ||
|
|
68fce1b1da | ||
|
|
4547031bba | ||
|
|
41e9acf4c2 | ||
|
|
a5f671c06c | ||
|
|
5372fc1fef | ||
|
|
6c5ecbb028 | ||
|
|
bd5a1c3335 | ||
|
|
2c740c9f04 | ||
|
|
3ec89680ed | ||
|
|
42860d38cd | ||
|
|
ef4b3bdb6c | ||
|
|
44bbb16cd3 | ||
|
|
1f5b7e68fc | ||
|
|
4bb8d6fa7b | ||
|
|
6395e73927 | ||
|
|
c956232b96 | ||
|
|
9814cdca55 | ||
|
|
0c337c9384 | ||
|
|
273e3f5aff | ||
|
|
ac1f42b7d5 | ||
|
|
88591fca89 | ||
|
|
75feb55adf | ||
|
|
f4b366389b | ||
|
|
2ec7b7aac9 |
@@ -17,6 +17,7 @@ function classifyCiPaths(paths) {
|
||||
android: forceAll || under(['app/', 'gradle/']) || exact([
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-capabilities.py', 'scripts/tests/check_android_capabilities_test.py',
|
||||
'scripts/check-android-collection-apis.py', 'scripts/check-android-native-compat.py',
|
||||
'scripts/check-android-release-notes.py',
|
||||
'scripts/android_release_artifacts.py',
|
||||
|
||||
@@ -128,3 +128,6 @@ assert.match(releaseTrainWorkflow, /name: Hermes-Relay Coordinated Release Appro
|
||||
assert.match(releaseTrainWorkflow, /Coordinated Android approval is stable-only/);
|
||||
|
||||
console.log('CI path classification tests passed.');
|
||||
|
||||
assert.deepEqual(classifyCiPaths(['scripts/check-android-capabilities.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/tests/check_android_capabilities_test.py']), { ...none, android: true });
|
||||
|
||||
@@ -166,6 +166,9 @@ jobs:
|
||||
- name: Build debug APKs
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
|
||||
|
||||
- name: Verify packaged native compatibility
|
||||
run: |
|
||||
python3 scripts/check-android-native-compat.py \
|
||||
@@ -203,6 +206,9 @@ jobs:
|
||||
- name: Build release bundles and APKs
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Verify Play release capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayRelease
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
@@ -34,6 +34,8 @@ on:
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- "scripts/check-android-capabilities.py"
|
||||
- "scripts/tests/check_android_capabilities_test.py"
|
||||
- "scripts/check-android-native-compat.py"
|
||||
- "scripts/check-android-release-notes.py"
|
||||
- "scripts/tests/check_android_native_compat_test.py"
|
||||
@@ -73,6 +75,11 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Validate Play capability boundary
|
||||
run: |
|
||||
python3 scripts/check-android-capabilities.py
|
||||
python3 -m unittest scripts.tests.check_android_capabilities_test
|
||||
|
||||
- name: Validate translation catalogs
|
||||
run: python3 scripts/check-android-locales.py
|
||||
|
||||
@@ -117,6 +124,9 @@ jobs:
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
|
||||
|
||||
- name: Verify packaged ONNX Runtime compatibility
|
||||
run: |
|
||||
python3 scripts/check-android-native-compat.py \
|
||||
@@ -173,11 +183,21 @@ jobs:
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.network.shared.PluginProxyTransportTest \
|
||||
--tests '*GatewayChatClientTest*retarget*' \
|
||||
--tests '*RelayVoiceClientRoutingTest.proxyProviderOwnsBothVoiceSessionAndWebSocketRequests' \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayHttpClientDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.ui.components.GatewayRoutesAccessPresentationTest \
|
||||
--tests com.hermesandroid.relay.ui.components.EndpointsCardCompactLayoutTest \
|
||||
--tests com.hermesandroid.relay.ui.screens.ConnectionDetailPresentationTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--tests com.hermesandroid.relay.util.ServerAddressTest \
|
||||
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.data.AppLanguageTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.VoiceInboundCompletionTest \
|
||||
--tests com.hermesandroid.relay.voice.VoiceViewModelBargeInTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
|
||||
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
|
||||
|
||||
@@ -103,6 +103,10 @@ jobs:
|
||||
python -m pytest \
|
||||
plugin/tests/test_manifest_compatibility.py \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_secure_proxy.py \
|
||||
plugin/tests/test_secure_proxy_contract.py \
|
||||
plugin/tests/test_secure_link_setup.py \
|
||||
plugin/tests/test_secure_proxy_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
|
||||
@@ -8,6 +8,15 @@ on:
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "docs/media/desktop-ui-screenshots.json"
|
||||
- "assets/screenshots/desktop-ui/**"
|
||||
- "desktop/tray/ui/**"
|
||||
- "desktop/tray/scripts/*.mjs"
|
||||
- "desktop/tray/package-lock.json"
|
||||
- "desktop/tray/index.html"
|
||||
- "desktop/tray/icons/icon-256.png"
|
||||
- "desktop/src/endpoint.ts"
|
||||
- "desktop/src/transportSecurity.ts"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
push:
|
||||
branches: [main, dev]
|
||||
@@ -17,6 +26,15 @@ on:
|
||||
- "assets/screenshots/03_voice.png"
|
||||
- "assets/screenshots/06_manage.png"
|
||||
- "docs/media/screenshots.json"
|
||||
- "docs/media/desktop-ui-screenshots.json"
|
||||
- "assets/screenshots/desktop-ui/**"
|
||||
- "desktop/tray/ui/**"
|
||||
- "desktop/tray/scripts/*.mjs"
|
||||
- "desktop/tray/package-lock.json"
|
||||
- "desktop/tray/index.html"
|
||||
- "desktop/tray/icons/icon-256.png"
|
||||
- "desktop/src/endpoint.ts"
|
||||
- "desktop/src/transportSecurity.ts"
|
||||
- ".github/workflows/ci-website.yml"
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -106,6 +106,9 @@ jobs:
|
||||
:app:assembleSideloadRelease \
|
||||
--console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayRelease
|
||||
|
||||
- name: Scan final release DEX
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
+58
-1
@@ -6,15 +6,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Provider usage shows Grok subscription periods, product usage, and on-demand credit state for hosts signed in with `xai-oauth`. Android shows SuperGrok by default when no provider visibility choice is saved.
|
||||
- Guided Secure Link setup in Dashboard and the Desktop Relay pane, with shared read-only host CLI checks, restart instructions, and signed pairing handoff.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
|
||||
- Desktop tray notices, screenshot evidence, and grant prompts stay reusable after dismissal; screenshot evidence keeps the most recently selected image. (#606)
|
||||
- Proactive phone Thread messages render relay-token and host-path media as attachments while preserving multiline text; notification previews omit media markers. (#485)
|
||||
- Desktop computer screenshots attach validated image bytes to the host tool result instead of returning base64 as plain text.
|
||||
- Relay-owned media uploads are removed on token expiry, eviction, and shutdown; media activity logs omit tokens, file paths, and screenshot bytes.
|
||||
- Plugin screenshot and navigation tools resolve Android's authenticated media token, attach the actual bounded image to host vision, and keep legacy inline screenshots readable. (#593)
|
||||
- Android Chat can open the model picker before the first turn, loads Gateway models when opened, and distinguishes loading, unavailable, and empty catalogs.
|
||||
- Secure Link configuration failures leave ordinary Relay available; route details and pairing previews resolve the advertised service namespaces.
|
||||
- Dashboard pairing QR codes support larger certificate-bearing Secure Link invites.
|
||||
- Secure Link preserves Gateway ticket authentication and Dashboard login paths, bounds rewritten responses, and serves compatible health information without additional loopback probes.
|
||||
- Android Secure Link enforces the paired certificate pin for HTTP, Gateway, and voice traffic, retains the correct TLS policy during Gateway route changes, and displays the active HTTPS Dashboard route.
|
||||
- Android cold start restores the saved Appearance palette and platform light/dark mode before the first app frame.
|
||||
- Android Gateway onboarding verifies Dashboard access without overstating Chat or voice readiness, explains common authentication setup failures, and requires exact-address consent before using HTTP. Custom Dashboard ports are accepted and shown throughout setup and route editing. (#604)
|
||||
- Android safely settles Gateway foreground-service starts before stopping local retention, preventing the startup/shutdown race reported in #603. Turning off always-on connectivity preserves active turns.
|
||||
- Android Standard Voice speaks live background completions in its active conversation after the original reply finishes. Stop and conversation changes discard pending speech. (#545)
|
||||
|
||||
## [Android 1.17.0] - 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- Optional voice controls over other apps in Google Play, with contextual permission setup, a persistent Stop voice notification, and session shutdown on screen lock or permission loss. Phone control remains sideload-only.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android shows standalone response cards without an outer bubble, uses subtler assistant surfaces, and places delivery status beside message timestamps.
|
||||
- Android answers upstream Clarify batches one question at a time, with independent choices, custom answers, and confirmed progress preserved across reconnects. (#474)
|
||||
- Android context previews mark phone status and turn context as unavailable in Gateway chats instead of claiming they are sent. Settings clarify that automatic phone-status sharing applies to API-only chats. (#556)
|
||||
- Android shows Hermes profile display names and groups the resolved server default under its agent identity, while preserving explicit profile selection and saved conversations.
|
||||
|
||||
## [Plugin 1.11.3] - 2026-09-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Relay Dashboard WebSockets work with current Hermes authentication helpers while preserving older-host compatibility, single-use tickets, Host/Origin/IP checks, and Relay session authentication.
|
||||
|
||||
## [Android 1.16.1] - 2026-09-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android Dashboard-only connections start the profile-scoped session directory before Gateway readiness, so a cold launch no longer leaves both the directory and passive Gateway socket waiting on each other. (#495, #528)
|
||||
|
||||
## [Android 1.16.0] - 2026-09-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Android no longer crashes when a route probe finishes while a network change invalidates the endpoint cache.
|
||||
- Android opens an authenticated Gateway chat on the first foreground launch instead of waiting for a background-and-resume cycle to leave the waking state. (#495, #528)
|
||||
- Android Dashboard sign-in removes pasted line breaks from username and password fields, matching the browser login while preserving every other credential character. (#541)
|
||||
- Android keeps saved Dashboard sign-ins bound to their connection when switching gateways, rather than letting a stale resolver route invalidate another connection's session.
|
||||
- Bot Mode no longer crashes when different connections have bots with the same profile name. Both the conversation list and Active Now strip preserve each bot's connection, and opening progress appears only on the selected bot.
|
||||
- Android feedback uses themed banners and action cards instead of platform toasts and default snackbars. Dashboard errors no longer misidentify missing resources as an outdated Relay. Developer settings includes local-only message previews.
|
||||
- Missing chat attachments show their error and retry in the attachment card without repeated global popups. Global action messages occupy the top message area instead of covering the composer.
|
||||
- Chat distinguishes session preparation from response streaming and retains initialization errors that arrive before the session acknowledgement. Long-press the agent header to open a live session-diagnostics drawer.
|
||||
- Delegated-agent activity survives parent replies and leaves compact history entries for later read-only review. The activity strip appears only while work runs; historical process views cannot stop or dismiss live work. (#447)
|
||||
|
||||
## [Plugin 1.11.2] - 2026-09-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
|
||||
- **`android_*` tools resolve bridge credentials written after host startup.** Requests retry profile-scoped env and active bridge-session credentials after a stale token is rejected, and vision navigation now shares the same current Relay transport instead of the retired standalone default.
|
||||
- **`android_setup` accepts both its canonical and legacy schema keys.** `bridge_session_token` and `pairing_code` are accepted, while a missing token returns a structured error.
|
||||
- **Android tool setup tests use a temporary Hermes home.** Test runs no longer write bridge settings into a developer environment.
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 31, 2026
|
||||
**Release Date:** September 13, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This patch restores native installation compatibility on affected Hermes versions and makes Relay prompt context advertise only capabilities the selected session can actually call. Standard Chat, Manage, standard voice, and ordinary inbound files remain upstream-owned.
|
||||
Dashboard WebSocket connections work again with current Hermes authentication helpers, while older Hermes hosts remain supported.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Native installer compatibility.** The plugin keeps its complete current manifest while avoiding the installer/runtime schema mismatch that caused `manifest_version 2` installs to fail after an apparent Hermes update.
|
||||
- **Capability-gated phone context.** Phone-control and cross-platform delivery guidance now follows the selected session/profile tool catalog instead of implying unavailable `android_*` or `send_message` callables.
|
||||
- Resolve WebSocket guards from their current upstream module and retain the older-host fallback. Single-use tickets, Host/Origin/IP checks, and independent Hermes-Relay session authentication remain enforced. Missing or incomplete helper contracts deny admission.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
+17
-18
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.15.1
|
||||
# Hermes-Relay Android v1.17.0
|
||||
|
||||
**Release Date:** September 2, 2026
|
||||
**Release Date:** September 13, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.15.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.17.0-sideload-release.apk` and tap it for the full feature set, or install from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,29 +12,28 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch improves chat, media, and voice reliability. It reduces memory-heavy work, keeps attachment previews stable through rotation, and makes follow-up message behavior and voice errors easier to understand.
|
||||
Google Play gains optional voice controls over other apps. This release also makes Clarify batches, profile identity, and chat context easier to follow while preserving confirmed answers and saved conversations.
|
||||
|
||||
## Added
|
||||
|
||||
- Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Permission grants require a separate Start action. Stop voice from the overlay or persistent notification; screen lock, task removal, and permission loss end the session.
|
||||
|
||||
## Changed
|
||||
|
||||
- Choose Correct now or Queue next from a slim tray behind the composer. Chat settings sets the default; the tray overrides one message. Stop pauses the queue, Resume continues it, and editing or removing an item preserves its successors.
|
||||
- Chat and Voice use readable centered layouts on wider screens, including landscape Voice Focus.
|
||||
- Standalone response cards use one surface, assistant bubbles are subtler, and timestamps share a row with delivery status.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Correction and delivery labels remain visible inside user-message bubbles.
|
||||
- Voice errors open in a scrollable dialog with separate Retry and Dismiss actions.
|
||||
- Attachment previews remain open through rotation, and video previews preserve their proportions.
|
||||
- Release optimization preserves the native speech configuration required for wake-word startup.
|
||||
- Standard Hermes attachments download directly to disk with bounded size checks.
|
||||
- Session refresh avoids repeated request loops; history loads, Markdown, image previews, and media exports keep memory use bounded.
|
||||
- Image-generation progress stays visible through gaps between interim replies and returned media.
|
||||
- The first prompt waits for Gateway session readiness. Ownership refusals retain the prompt for retry and show the original server error.
|
||||
- Answer upstream Clarify batches one question at a time, with independent choices, custom answers, and confirmed progress across reconnects. (#474)
|
||||
- Context previews show that Gateway chats cannot send phone status or general turn context. Automatic phone-status sharing remains supported for API-only chats. (#556)
|
||||
- Profiles display their Hermes names and group the resolved server default under its agent identity, preserving explicit selection and saved conversations.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.15.1** (versionCode **54**).
|
||||
- App version: **1.17.0** (versionCode **57**).
|
||||
- Standard Chat, sessions, profiles, Manage, voice, and ordinary media use current upstream Hermes. Speech-to-text still requires a configured provider on the host.
|
||||
- Hermes-Relay Plugin **1.11.1** remains the current optional plugin release; this Android patch does not require a new plugin version.
|
||||
- Paused text queues can be restored. Attachment bytes are not persisted in preferences; unrestorable attachment queues must be reviewed and sent again.
|
||||
- Hermes-Relay Plugin **1.11.3** is the optional release for Hermes-Relay tools and current Dashboard WebSocket compatibility.
|
||||
- Explicit Direct API/API-only connections remain supported and are not used as silent failover for Dashboard-owned chats.
|
||||
- Granular Device Control and the system Voice Focus overlay remain sideload-only.
|
||||
- Voice Overlay is available in Google Play and sideload builds. Device Control remains sideload-only.
|
||||
- Gateway phone-status delivery and automatic Android identification remain unavailable pending upstream support.
|
||||
- Physical Android 14-16 and OEM voice-overlay testing was not performed for this release. Code, rendered UI, existing emulator evidence, CI, and signed-package preflight provide the recorded verification.
|
||||
|
||||
@@ -100,9 +100,9 @@ android {
|
||||
}
|
||||
|
||||
// ─── Bridge release tracks ─────────────────────────────────────────────────
|
||||
// Google Play ships Bridge Core only: pairing, chat, voice, terminal/TUI,
|
||||
// Google Play ships Bridge Core and user-started voice-only overlay: pairing, chat, voice, terminal/TUI,
|
||||
// media, notification companion, relay sessions, and status. It does not
|
||||
// declare AccessibilityService, overlay, MediaProjection, wake-lock device
|
||||
// declare AccessibilityService, MediaProjection, wake-lock device
|
||||
// control, SMS/call/contact/location, or unattended-control permissions.
|
||||
//
|
||||
// googlePlay — canonical Play Store install. Bridge Core only.
|
||||
@@ -458,8 +458,8 @@ dependencies {
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.73.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.73.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.74.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.74.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.ApiEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EndpointResolverConcurrencyInstrumentedTest {
|
||||
|
||||
@Test
|
||||
fun probeCompletionRacingInvalidation_staysCrashFreeOnAndroidCollections() = runBlocking {
|
||||
repeat(25) { iteration ->
|
||||
val candidateCount = 8
|
||||
val requestsStarted = CountDownLatch(candidateCount)
|
||||
val releaseRequests = CountDownLatch(1)
|
||||
val raceGate = CountDownLatch(1)
|
||||
val requestSequence = AtomicInteger(0)
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
if (requestSequence.incrementAndGet() <= candidateCount) {
|
||||
requestsStarted.countDown()
|
||||
releaseRequests.await(5, TimeUnit.SECONDS)
|
||||
throw InterruptedIOException("instrumented invalidation race")
|
||||
}
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("{}".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
val resolver = EndpointResolver(client)
|
||||
val candidates = (1..candidateCount).map { index ->
|
||||
EndpointCandidate(
|
||||
role = "instrumented-$iteration-$index",
|
||||
priority = 0,
|
||||
api = ApiEndpoint(host = "127.0.0.1", port = 1, tls = false),
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
val staleResolve = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
resolver.resolve(candidates, EndpointSurface.Api)
|
||||
}
|
||||
assertTrue(requestsStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val invalidation = async(Dispatchers.Default) {
|
||||
raceGate.await(5, TimeUnit.SECONDS)
|
||||
resolver.clearCache()
|
||||
}
|
||||
val completions = async(Dispatchers.Default) {
|
||||
raceGate.await(5, TimeUnit.SECONDS)
|
||||
releaseRequests.countDown()
|
||||
}
|
||||
raceGate.countDown()
|
||||
|
||||
withTimeout(2_000L) {
|
||||
invalidation.await()
|
||||
completions.await()
|
||||
staleResolve.await()
|
||||
}
|
||||
assertTrue(resolver.cacheSnapshot().isEmpty())
|
||||
|
||||
resolver.clearCache()
|
||||
val freshWinner = withTimeout(2_000L) {
|
||||
resolver.resolve(listOf(candidates.first()), EndpointSurface.Api)
|
||||
}
|
||||
assertEquals(candidates.first(), freshWinner)
|
||||
assertTrue(
|
||||
resolver.probeOutcomes.value.getValue(
|
||||
EndpointResolver.cacheKey(candidates.first(), EndpointSurface.Api),
|
||||
).reachable,
|
||||
)
|
||||
} finally {
|
||||
raceGate.countDown()
|
||||
releaseRequests.countDown()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.data.setGatewayKeepAlive
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Real ActivityManager/notification lifecycle; no Gateway or personal data. */
|
||||
class GatewayKeepAliveServiceInstrumentedTest {
|
||||
@get:Rule val activity = createAndroidComposeRule<ComponentActivity>()
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private val context get() = instrumentation.targetContext
|
||||
|
||||
@Before fun setup() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
instrumentation.uiAutomation.executeShellCommand(
|
||||
"pm grant ${context.packageName} android.permission.POST_NOTIFICATIONS",
|
||||
).close()
|
||||
}
|
||||
runBlocking { context.setGatewayKeepAlive(false) }
|
||||
}
|
||||
|
||||
@After fun cleanup() {
|
||||
instrumentation.runOnMainSync {
|
||||
GatewayKeepAliveService.stop(context)
|
||||
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||
}
|
||||
await("service shutdown") { serviceState() == null }
|
||||
runBlocking { context.setGatewayKeepAlive(false) }
|
||||
}
|
||||
|
||||
@Test fun immediateStopsAndOverlappingStartsSurviveThePlatformWatchdog() {
|
||||
// All changes happen before Android can dispatch onCreate/onStartCommand.
|
||||
instrumentation.runOnMainSync {
|
||||
repeat(25) {
|
||||
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
|
||||
GatewayKeepAliveService.stop(context)
|
||||
}
|
||||
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
|
||||
}
|
||||
await("foreground promotion") { serviceState()?.foreground == true }
|
||||
instrumentation.runOnMainSync { GatewayKeepAliveService.stop(context) }
|
||||
await("settled shutdown") { serviceState() == null }
|
||||
// Observation window, not a startup workaround: an asynchronous system
|
||||
// foreground-start crash fails the instrumentation process during it.
|
||||
CountDownLatch(1).await(12, TimeUnit.SECONDS)
|
||||
assertTrue(serviceState() == null)
|
||||
}
|
||||
|
||||
@Test fun notificationDisablesAlwaysOnWhileANewerTurnStaysProtected() {
|
||||
runBlocking { context.setGatewayKeepAlive(true) }
|
||||
instrumentation.runOnMainSync {
|
||||
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
await("persistent notification") {
|
||||
manager.activeNotifications.any { it.id == GatewayKeepAliveService.NOTIFICATION_ID }
|
||||
}
|
||||
val action = manager.activeNotifications.single {
|
||||
it.id == GatewayKeepAliveService.NOTIFICATION_ID
|
||||
}.notification.actions.single().actionIntent
|
||||
instrumentation.runOnMainSync {
|
||||
ActiveTurnKeepAliveRegistry.acquire("fixture::profile-a::session")
|
||||
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
|
||||
}
|
||||
action.send()
|
||||
await("persisted notification action") {
|
||||
runBlocking { context.relayDataStore.data.first()[KEY_GATEWAY_KEEP_ALIVE] == false }
|
||||
}
|
||||
instrumentation.runOnMainSync {
|
||||
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
|
||||
}
|
||||
assertTrue(serviceState()?.foreground == true)
|
||||
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
|
||||
await("active-turn notification without always-on action") {
|
||||
manager.activeNotifications.singleOrNull {
|
||||
it.id == GatewayKeepAliveService.NOTIFICATION_ID
|
||||
}?.notification?.let { it.actions.isNullOrEmpty() } == true
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun serviceState(): ActivityManager.RunningServiceInfo? =
|
||||
context.getSystemService(ActivityManager::class.java).getRunningServices(100)
|
||||
.singleOrNull { it.service.className == GatewayKeepAliveService::class.java.name }
|
||||
|
||||
private fun await(description: String, condition: () -> Boolean) {
|
||||
val deadline = SystemClock.uptimeMillis() + 10_000
|
||||
while (!condition() && SystemClock.uptimeMillis() < deadline) {
|
||||
instrumentation.waitForIdleSync()
|
||||
SystemClock.sleep(20)
|
||||
}
|
||||
assertTrue(description, condition())
|
||||
}
|
||||
}
|
||||
+63
-196
@@ -3,230 +3,97 @@ package com.hermesandroid.relay.ui.onboarding
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
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.compose.ui.test.performTextReplacement
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Instrumented tests for the Standard-first onboarding pager.
|
||||
*/
|
||||
/** Standard setup stays separate from Direct API and optional Relay grants. */
|
||||
class OnboardingFlowTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private fun setOnboardingContent() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val connectionViewModel = ConnectionViewModel(app)
|
||||
composeTestRule.setContent {
|
||||
HermesRelayTheme {
|
||||
OnboardingScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
private fun start() {
|
||||
val model = ConnectionViewModel(ApplicationProvider.getApplicationContext<Application>())
|
||||
compose.setContent { HermesRelayTheme { OnboardingScreen(model, onComplete = {}) } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsHermesForAndroidTitle() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes-Relay for Android")
|
||||
.assertIsDisplayed()
|
||||
private fun connectPage() {
|
||||
compose.onNodeWithText("Get started").performClick()
|
||||
repeat(3) { compose.onNodeWithText("Next").performClick(); compose.waitForIdle() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun firstPage_showsStandardFirstDescription() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Standard")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Advanced")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Setup Guide")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes Docs")
|
||||
.assertIsDisplayed()
|
||||
@Test fun welcomeOffersStartAndDemo() {
|
||||
start()
|
||||
compose.onNodeWithText("Hermes,\nin your pocket").assertIsDisplayed()
|
||||
compose.onNodeWithText("Get started").assertIsDisplayed().assertIsEnabled()
|
||||
compose.onNodeWithText("Try the demo").assertIsDisplayed().assertIsEnabled()
|
||||
compose.onNodeWithText("Back").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextButton_navigatesForward_toChatPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Chat")
|
||||
.assertIsDisplayed()
|
||||
@Test fun introNavigationAndSkipRemainAvailable() {
|
||||
start()
|
||||
compose.onNodeWithText("Get started").performClick()
|
||||
compose.onNodeWithText("Chat").assertIsDisplayed()
|
||||
compose.onNodeWithText("Back").performClick()
|
||||
compose.onNodeWithText("Get started").assertIsDisplayed()
|
||||
compose.onNodeWithText("Get started").performClick()
|
||||
compose.onNodeWithText("Skip").performClick()
|
||||
compose.onNodeWithText("Skip setup?").assertIsDisplayed()
|
||||
compose.onNodeWithText("Go back").performClick()
|
||||
compose.onNodeWithText("Chat").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canNavigateForward_throughStandardAndPowerPages() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Connect").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Add gateway").assertIsDisplayed()
|
||||
@Test fun standardMethodsDoNotAskForApiCredentials() {
|
||||
start(); connectPage()
|
||||
compose.onNodeWithText("Hermes nearby").assertIsDisplayed()
|
||||
compose.onNodeWithText("Remote gateway").assertIsDisplayed().performClick()
|
||||
compose.onNodeWithText("Hermes address").assertIsDisplayed()
|
||||
compose.onNodeWithText("API key").assertDoesNotExist()
|
||||
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_hiddenOnFirstPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Back")
|
||||
.assertDoesNotExist()
|
||||
@Test fun publicHttpConsentResetsWhenAddressChanges() {
|
||||
start(); connectPage()
|
||||
compose.onNodeWithText("Remote gateway").performClick()
|
||||
compose.onNodeWithText("Hermes address").performTextReplacement("http://11.0.0.1:9119")
|
||||
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsNotEnabled()
|
||||
compose.onNodeWithText("I accept the risk and allow HTTP for this address").performScrollTo().performClick()
|
||||
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsEnabled()
|
||||
compose.onNodeWithText("Hermes address").performScrollTo().performTextReplacement("http://11.0.0.1:9120")
|
||||
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backButton_navigatesBackward() {
|
||||
setOnboardingContent()
|
||||
|
||||
composeTestRule.onNodeWithText("Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
|
||||
|
||||
composeTestRule.onNodeWithText("Back").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
|
||||
@Test fun advancedKeepsApiAndRelaySeparate() {
|
||||
start(); connectPage()
|
||||
compose.onNodeWithText("Advanced").performScrollTo().performClick()
|
||||
compose.onNodeWithText("API-only connection").assertIsDisplayed()
|
||||
compose.onNodeWithText("Pair Relay by code").performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addGatewayPage_leadsWithStandardGatewayMethods() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes nearby")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Remote gateway")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Scan Hermes setup QR")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Recommended")
|
||||
.assertDoesNotExist()
|
||||
@Test fun setupSkipIsScrollReachable() {
|
||||
start(); connectPage()
|
||||
compose.onNodeWithText("Skip for now — set up later in Settings").performScrollTo().assertIsDisplayed().performClick()
|
||||
compose.onNodeWithText("Skip setup?").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Remote gateway").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Hermes address")
|
||||
.assertIsDisplayed()
|
||||
@Test fun hostedGatewayKeepsItsSeparateAddressEntry() {
|
||||
start(); connectPage()
|
||||
compose.onNodeWithText("Nous-hosted Hermes").performClick()
|
||||
compose.onNodeWithText("Connect to Nous-hosted Hermes").assertIsDisplayed()
|
||||
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualSetup_findButton_isShown() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Remote gateway").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Find Hermes")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cloudSetup_requestsTheHostedDashboardAddress() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Nous-hosted Hermes").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Connect to Nous-hosted Hermes")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Use the complete HTTPS address shown for your hosted agent.")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addGatewayPage_keepsPairingOptional() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(4)
|
||||
|
||||
composeTestRule.onNodeWithText("Advanced").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Pair Relay by code")
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun powerPage_linksToPermissionReview() {
|
||||
setOnboardingContent()
|
||||
navigateToPage(3)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Review permissions")
|
||||
.assertIsDisplayed()
|
||||
.assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipButton_visibleOnIntroPages_andWizardSkipOnAddGatewayPage() {
|
||||
setOnboardingContent()
|
||||
|
||||
repeat(4) {
|
||||
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Skip for now — set up later in Settings")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun navigateToPage(pageIndex: Int) {
|
||||
repeat(pageIndex) {
|
||||
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
@Test fun optionalPowerPermissionsRemainReachable() {
|
||||
start()
|
||||
compose.onNodeWithText("Get started").performClick()
|
||||
repeat(2) { compose.onNodeWithText("Next").performClick(); compose.waitForIdle() }
|
||||
compose.onNodeWithText("Review permissions").performScrollTo().assertIsDisplayed().assertIsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hermesandroid.relay.HermesRelayApp
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.data.AppearancePreferences
|
||||
import com.hermesandroid.relay.data.CustomThemePreset
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.runtime.HermesRuntimeInitializationState
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Real Activity/DataStore/Compose ownership, with the device set to dark mode. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AppearanceColdStartInstrumentedTest {
|
||||
@Test
|
||||
fun savedAppearanceOwnsColdStartAndLaterModeChanges() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val app = ApplicationProvider.getApplicationContext<HermesRelayApp>()
|
||||
val previousPreferences = runBlocking { app.relayDataStore.data.first() }
|
||||
val originalNightMode =
|
||||
(app.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode
|
||||
assumeTrue(
|
||||
originalNightMode == UiModeManager.MODE_NIGHT_AUTO ||
|
||||
originalNightMode == UiModeManager.MODE_NIGHT_NO ||
|
||||
originalNightMode == UiModeManager.MODE_NIGHT_YES,
|
||||
)
|
||||
val custom = CustomThemePreset(
|
||||
id = "day",
|
||||
name = "Day",
|
||||
mode = CustomThemePreset.MODE_LIGHT,
|
||||
backgroundHex = "#F5F5F5",
|
||||
surfaceHex = "#FFFFFF",
|
||||
accentHex = "#0E18D6",
|
||||
textHex = "#111111",
|
||||
)
|
||||
instrumentation.uiAutomation.executeShellCommand("cmd uimode night yes").close()
|
||||
try {
|
||||
runBlocking {
|
||||
app.relayDataStore.edit { preferences ->
|
||||
preferences[AppearancePreferences.themeKey] = "light"
|
||||
preferences[AppearancePreferences.appThemeKey] = AppThemes.DEFAULT_ID
|
||||
}
|
||||
}
|
||||
instrumentation.runOnMainSync {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
}
|
||||
ActivityScenario.launch(MainActivity::class.java).use {
|
||||
runBlocking {
|
||||
withTimeout(30_000) {
|
||||
app.runtime.connectionViewModel.isReady.first { it }
|
||||
app.runtime.initializationState.first {
|
||||
it == HermesRuntimeInitializationState.Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
awaitTheme(isDark = false, nightMode = AppCompatDelegate.MODE_NIGHT_NO)
|
||||
|
||||
runBlocking {
|
||||
app.relayDataStore.edit {
|
||||
it[AppearancePreferences.themeKey] = "dark"
|
||||
}
|
||||
}
|
||||
awaitTheme(isDark = true, nightMode = AppCompatDelegate.MODE_NIGHT_YES)
|
||||
|
||||
runBlocking {
|
||||
app.relayDataStore.edit {
|
||||
it[AppearancePreferences.themeKey] = "auto"
|
||||
}
|
||||
}
|
||||
awaitTheme(isDark = true, nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
|
||||
runBlocking {
|
||||
app.relayDataStore.edit {
|
||||
it[AppearancePreferences.customThemesKey] =
|
||||
AppearancePreferences.encodeCustomThemes(listOf(custom))
|
||||
it[AppearancePreferences.appThemeKey] = custom.appThemeId
|
||||
}
|
||||
}
|
||||
awaitTheme(isDark = false, nightMode = AppCompatDelegate.MODE_NIGHT_NO)
|
||||
}
|
||||
} finally {
|
||||
runBlocking {
|
||||
app.relayDataStore.edit { preferences ->
|
||||
previousPreferences[AppearancePreferences.themeKey]?.let {
|
||||
preferences[AppearancePreferences.themeKey] = it
|
||||
} ?: preferences.remove(AppearancePreferences.themeKey)
|
||||
previousPreferences[AppearancePreferences.appThemeKey]?.let {
|
||||
preferences[AppearancePreferences.appThemeKey] = it
|
||||
} ?: preferences.remove(AppearancePreferences.appThemeKey)
|
||||
previousPreferences[AppearancePreferences.customThemesKey]?.let {
|
||||
preferences[AppearancePreferences.customThemesKey] = it
|
||||
} ?: preferences.remove(AppearancePreferences.customThemesKey)
|
||||
}
|
||||
}
|
||||
val restoreMode = when (originalNightMode) {
|
||||
UiModeManager.MODE_NIGHT_YES -> "yes"
|
||||
UiModeManager.MODE_NIGHT_NO -> "no"
|
||||
else -> "auto"
|
||||
}
|
||||
instrumentation.uiAutomation.executeShellCommand("cmd uimode night $restoreMode").close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitTheme(isDark: Boolean, nightMode: Int) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val deadline = SystemClock.uptimeMillis() + 15_000
|
||||
while (SystemClock.uptimeMillis() < deadline) {
|
||||
instrumentation.waitForIdleSync()
|
||||
if (RelayRefresh.activePalette.isDark == isDark &&
|
||||
AppCompatDelegate.getDefaultNightMode() == nightMode
|
||||
) {
|
||||
assertEquals(nightMode, AppCompatDelegate.getDefaultNightMode())
|
||||
if (isDark) assertTrue(RelayRefresh.activePalette.isDark)
|
||||
else assertFalse(RelayRefresh.activePalette.isDark)
|
||||
return
|
||||
}
|
||||
SystemClock.sleep(25)
|
||||
}
|
||||
fail(
|
||||
"Appearance did not settle: paletteDark=${RelayRefresh.activePalette.isDark}, " +
|
||||
"nightMode=${AppCompatDelegate.getDefaultNightMode()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performImeAction
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.WebSocket
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/** Production socket, ViewModel, transcript, Compose and IME actions on a virtual device. */
|
||||
class ClarifyBatchInstrumentedTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
private lateinit var fixture: AndroidGatewayContractFixture
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var gateway: GatewayChatClient
|
||||
private lateinit var viewModel: ChatViewModel
|
||||
private lateinit var handler: ChatHandler
|
||||
private lateinit var socket: WebSocket
|
||||
|
||||
@Before fun setUp() {
|
||||
fixture = AndroidGatewayContractFixture()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val http = OkHttpClient()
|
||||
gateway = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), http),
|
||||
okHttpClient = http, scope = scope,
|
||||
callbackDispatcher = { Handler(Looper.getMainLooper()).post(it) },
|
||||
)
|
||||
handler = ChatHandler().also { it.setSessionId("20260821_120000_fixture") }
|
||||
viewModel = ChatViewModel().also {
|
||||
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setProfileMessageLoader { Result.success(emptyList()) }
|
||||
it.updateGatewayClient(gateway)
|
||||
}
|
||||
compose.setContent {
|
||||
val messages by handler.messages.collectAsStateWithLifecycle()
|
||||
MaterialTheme {
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(messages.size, key = { messages[it].id }) { index ->
|
||||
MessageBubble(messages[index], showTimestamps = false,
|
||||
onCardInput = viewModel::answerAsk, animationEnabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(runBlocking { gateway.prewarmAwait("20260821_120000_fixture") })
|
||||
socket = fixture.awaitServerSocket()
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
gateway.shutdown()
|
||||
scope.cancel()
|
||||
fixture.shutdown()
|
||||
}
|
||||
|
||||
@Test fun confirmedProgressSurvivesLifecycleAndCustomAnswerUsesIme() {
|
||||
compose.runOnIdle { viewModel.sendMessage("Ask two questions") }
|
||||
fixture.awaitRpc("prompt.submit")
|
||||
socket.send(fixture.event("clarify.request", Json.parseToJsonElement("""
|
||||
{"request_id":"batch-device","questions":[
|
||||
{"qid":"route/a","question":"Which route?","choices":["Canary","Immediate"]},
|
||||
{"qid":"notes:b","question":"Anything else?","choices":null}
|
||||
]}
|
||||
""") as JsonObject, "fixture-live-1"))
|
||||
compose.waitUntil(10_000) { viewModel.pendingAsk.value != null }
|
||||
compose.onNodeWithText("Canary").performClick()
|
||||
compose.waitUntil(10_000) { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Canary" }
|
||||
assertEquals(JsonPrimitive("route/a"), fixture.awaitRpc("clarify.respond")["question_id"])
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
compose.onNodeWithText("Question 2 of 2").assertIsDisplayed()
|
||||
compose.onNodeWithContentDescription("Type an answer…").apply {
|
||||
performClick()
|
||||
performTextInput(" Keep rollback ready ")
|
||||
performImeAction()
|
||||
}
|
||||
compose.waitUntil(10_000) { viewModel.pendingAsk.value == null }
|
||||
compose.onNodeWithText("All questions answered").assertIsDisplayed()
|
||||
assertEquals(2, fixture.rpcCount("clarify.respond"))
|
||||
assertEquals(1, fixture.rpcCount("prompt.submit"))
|
||||
}
|
||||
}
|
||||
+82
@@ -62,9 +62,17 @@ class GatewayExternalFixtureInstrumentedTest {
|
||||
private var gatewayScope: CoroutineScope? = null
|
||||
private var gatewayClient: GatewayChatClient? = null
|
||||
private var viewModel: ChatViewModel? = null
|
||||
private var voiceViewModel: VoiceViewModel? = null
|
||||
private var voicePlayer: com.hermesandroid.relay.audio.VoicePlayer? = null
|
||||
private var voiceSfx: com.hermesandroid.relay.audio.VoiceSfxPlayer? = null
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
compose.runOnUiThread {
|
||||
voiceViewModel?.exitVoiceMode()
|
||||
voicePlayer?.release()
|
||||
voiceSfx?.release()
|
||||
}
|
||||
viewModel?.updateGatewayClient(null)
|
||||
gatewayClient?.shutdown()
|
||||
gatewayScope?.cancel()
|
||||
@@ -250,6 +258,80 @@ class GatewayExternalFixtureInstrumentedTest {
|
||||
assertEquals("gateway", vm.streamingEndpoint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsolicitedVoiceCompletions_surviveActivityPauseWithoutHistorySpeech() {
|
||||
val base = InstrumentationRegistry.getArguments().getString(ARG_FIXTURE_BASE_URL)
|
||||
?.trim()?.trimEnd('/')
|
||||
assumeTrue("Pass the unsolicited_voice_completions fixture URL", !base.isNullOrBlank())
|
||||
requireNotNull(base)
|
||||
val http = OkHttpClient.Builder().callTimeout(10, TimeUnit.SECONDS).build()
|
||||
assertEquals("unsolicited_voice_completions", readFixtureJson(http, "$base/__fixture__/state")["scenario"]?.jsonString())
|
||||
val dashboard = DashboardApiClient(base, http)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { gatewayScope = it }
|
||||
val gateway = GatewayChatClient(
|
||||
initialDashboardClient = dashboard, okHttpClient = http,
|
||||
callbackDispatcher = { Handler(Looper.getMainLooper()).post(it) }, scope = scope,
|
||||
).also { gatewayClient = it }
|
||||
val handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
|
||||
val spoken = java.util.concurrent.CopyOnWriteArrayList<String>()
|
||||
lateinit var vm: ChatViewModel
|
||||
compose.runOnUiThread {
|
||||
val app = compose.activity.application
|
||||
vm = ChatViewModel().also {
|
||||
it.initialize(null, handler)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setProfileMessageLoaderWithMode { profile, id, mode ->
|
||||
dashboard.getSessionMessages(id, profile, mode)
|
||||
}
|
||||
it.updateGatewayClient(gateway)
|
||||
viewModel = it
|
||||
}
|
||||
val audio = object : com.hermesandroid.relay.network.shared.VoiceAudioClient {
|
||||
override val route = com.hermesandroid.relay.data.VoiceAudioRoute.Standard
|
||||
override suspend fun transcribe(audioFile: java.io.File) = Result.success("")
|
||||
override suspend fun synthesize(text: String): Result<java.io.File> {
|
||||
spoken.add(text)
|
||||
// A short silent WAV exercises the production play/drain path without a provider.
|
||||
val pcm = ByteArray(3200)
|
||||
val header = java.nio.ByteBuffer.allocate(44).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
.put("RIFF".toByteArray()).putInt(36 + pcm.size).put("WAVEfmt ".toByteArray())
|
||||
.putInt(16).putShort(1).putShort(1).putInt(16000).putInt(32000)
|
||||
.putShort(2).putShort(16).put("data".toByteArray()).putInt(pcm.size).array()
|
||||
val file = java.io.File.createTempFile("fixture-voice", ".wav", app.cacheDir)
|
||||
file.writeBytes(header + pcm)
|
||||
return Result.success(file)
|
||||
}
|
||||
}
|
||||
val player = com.hermesandroid.relay.audio.VoicePlayer(app).also { voicePlayer = it }
|
||||
val sfx = com.hermesandroid.relay.audio.VoiceSfxPlayer(app).also { voiceSfx = it }
|
||||
voiceViewModel = VoiceViewModel(app).also {
|
||||
it.initialize(
|
||||
voiceClient = com.hermesandroid.relay.network.relay.RelayVoiceClient(app, http, { null }, { null }),
|
||||
voiceAudioClient = audio, chatViewModel = vm,
|
||||
recorder = com.hermesandroid.relay.audio.VoiceRecorder(app, scope),
|
||||
player = player, sfxPlayer = sfx,
|
||||
)
|
||||
it.enterVoiceMode()
|
||||
}
|
||||
}
|
||||
compose.setContent {
|
||||
val messages by vm.messages.collectAsStateWithLifecycle()
|
||||
Text(messages.joinToString("\n") { it.content }, Modifier.testTag("voice-fixture-history"))
|
||||
}
|
||||
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
|
||||
compose.runOnUiThread { vm.sendMessage("Start background work.") }
|
||||
compose.waitUntil(10_000) { handler.messages.value.any { it.content == "Work started." } }
|
||||
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.STARTED)
|
||||
compose.waitUntil(15_000) { spoken.size == 3 }
|
||||
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
|
||||
compose.runOnUiThread { voiceViewModel?.onAppResumed() }
|
||||
compose.waitForIdle()
|
||||
assertEquals(listOf("Process finished.", "Watch matched.", "Delegated work finished."), spoken.toList())
|
||||
assertEquals(1, readFixtureJson(http, "$base/__fixture__/evidence")["entries"].let { it as JsonArray }.rpcCount("prompt.submit"))
|
||||
assertTrue(handler.messages.value.any { it.content == "Delegated work finished." })
|
||||
assertEquals("gateway", vm.streamingEndpoint)
|
||||
}
|
||||
|
||||
private fun JsonArray.rpcCount(method: String): Int = count { element ->
|
||||
val entry = element as? JsonObject ?: return@count false
|
||||
entry["kind"]?.jsonString() == "rpc" && entry["method"]?.jsonString() == method
|
||||
|
||||
+142
-5
@@ -5,11 +5,12 @@ import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
@@ -17,25 +18,30 @@ import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
|
||||
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
|
||||
import com.hermesandroid.relay.ui.screens.shouldOwnVisibleGateway
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -85,6 +91,8 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
@Volatile
|
||||
private var persistedHistory: List<MessageItem> = emptyList()
|
||||
private val historySignInRequired = MutableStateFlow(false)
|
||||
private val coldStartAdmissionEnabled = MutableStateFlow(false)
|
||||
private val coldStartGatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -119,6 +127,19 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
|
||||
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
|
||||
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
|
||||
val admissionEnabled by coldStartAdmissionEnabled.collectAsStateWithLifecycle()
|
||||
val admissionAvailability by coldStartGatewayAvailability.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(admissionEnabled, admissionAvailability) {
|
||||
if (admissionEnabled) {
|
||||
viewModel.setChatVisible(
|
||||
shouldOwnVisibleGateway(
|
||||
appForeground = true,
|
||||
isGatewayTransport = true,
|
||||
gatewayAvailability = admissionAvailability,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialTheme {
|
||||
Column(Modifier.testTag("contract-transcript")) {
|
||||
GatewayBackgroundProcessStrip(
|
||||
@@ -156,6 +177,122 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
fixture.awaitRpc("session.resume")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticatedUnknownColdLaunch_opensObservationSocketWithoutLifecycleBounce() {
|
||||
viewModel.setChatVisible(false)
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
|
||||
val controlMethods = setOf(
|
||||
"session.resume",
|
||||
"session.activate",
|
||||
"prompt.submit",
|
||||
"session.interrupt",
|
||||
)
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = okHttp,
|
||||
),
|
||||
okHttpClient = okHttp,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
viewModel.setChatTurnCheckpointStore(null)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
|
||||
coldStartGatewayAvailability.value = GatewayAvailability.Unknown
|
||||
coldStartAdmissionEnabled.value = true
|
||||
|
||||
compose.waitUntil(5_000) {
|
||||
gatewayClient.connectionState.value == GatewayConnectionState.Ready
|
||||
}
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
|
||||
controlMethods.forEach { method ->
|
||||
assertEquals(
|
||||
"cold observation sent $method",
|
||||
baseline.getValue(method),
|
||||
fixture.rpcCount(method),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyColdLaunch_waitsForExactDirectoryThenOpensObservationSocket() {
|
||||
viewModel.setChatVisible(false)
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
|
||||
val directoryStarted = CompletableDeferred<Unit>()
|
||||
val directoryResult = CompletableDeferred<Result<List<SessionItem>>>()
|
||||
viewModel.setProfileSessionLister { profile ->
|
||||
assertEquals(PROFILE_NAME, profile)
|
||||
directoryStarted.complete(Unit)
|
||||
directoryResult.await()
|
||||
}
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
|
||||
val controlMethods = setOf(
|
||||
"session.resume",
|
||||
"session.activate",
|
||||
"prompt.submit",
|
||||
"session.interrupt",
|
||||
)
|
||||
val baseline = controlMethods.associateWith(fixture::rpcCount)
|
||||
val ticketMintsBefore = fixture.requestsTo("/api/auth/ws-ticket")
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val okHttp = OkHttpClient()
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = okHttp,
|
||||
),
|
||||
okHttpClient = okHttp,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
viewModel.setChatTurnCheckpointStore(null)
|
||||
viewModel.updateGatewayClient(gatewayClient)
|
||||
viewModel.setChatVisible(true)
|
||||
|
||||
// This is the production binder's Dashboard/profile hydration edge.
|
||||
// The socket must stay passive and closed until the exact-owner REST
|
||||
// directory publishes, then open without a lifecycle bounce.
|
||||
viewModel.refreshSessions()
|
||||
compose.waitUntil(5_000) { directoryStarted.isCompleted }
|
||||
assertEquals(ticketMintsBefore, fixture.requestsTo("/api/auth/ws-ticket"))
|
||||
|
||||
directoryResult.complete(
|
||||
Result.success(listOf(SessionItem(id = STORED_SESSION_ID, title = "Fixture session"))),
|
||||
)
|
||||
|
||||
compose.waitUntil(5_000) {
|
||||
gatewayClient.connectionState.value == GatewayConnectionState.Ready
|
||||
}
|
||||
serverSocket = fixture.awaitServerSocket()
|
||||
assertEquals(ticketMintsBefore + 1, fixture.requestsTo("/api/auth/ws-ticket"))
|
||||
controlMethods.forEach { method ->
|
||||
assertEquals(
|
||||
"directory-gated cold observation sent $method",
|
||||
baseline.getValue(method),
|
||||
fixture.rpcCount(method),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Google Play ships Hermes Bridge Core only. It intentionally does not merge
|
||||
any Device Control services or permissions.
|
||||
|
||||
This file is intentionally kept as an empty overlay so future flavor-specific
|
||||
This overlay owns the voice-only special access so future flavor-specific
|
||||
permissions / activities have an obvious home. Mirror structural additions
|
||||
in `app/src/sideload/AndroidManifest.xml` unless the change is intentionally
|
||||
track-specific.
|
||||
@@ -22,6 +22,9 @@
|
||||
android:name="android.permission.WAKE_LOCK"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- User-started voice controls only; does not enable Device Control. -->
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<application />
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -23,7 +23,7 @@ GOOGLE PLAY AND SIDELOAD
|
||||
|
||||
The Google Play build includes Chat, voice, sessions, Manage, profiles, notifications, media, and Terminal/TUI when the Hermes-Relay plugin is paired.
|
||||
|
||||
Google Play does not include Android Device Control. It cannot read the phone screen, tap, type, swipe, take device screenshots, send SMS, place calls, or access contacts or location.
|
||||
Google Play does not include Android Device Control. It cannot tap, type, swipe, send SMS, place calls, or access contacts or location. Optional Voice Overlay provides user-started voice controls over other apps, with microphone notification and Stop voice. Selecting Hermes as Android Digital Assistant can provide bounded screen text and a screenshot for an explicit unlocked assistant invocation; this context goes to your configured server and AI provider.
|
||||
|
||||
Device Control is available only in the signed Sideload build on this project's GitHub Releases. It requires the Sideload app, a paired Hermes-Relay plugin, explicit Android accessibility permission, and the app's safety controls.
|
||||
|
||||
@@ -32,9 +32,6 @@ FEATURES
|
||||
- Streaming Chat with reasoning, markdown, tool progress, attachments, mid-turn steering, edit-and-resend, and searchable commands.
|
||||
- Manage models and provider keys, edit profiles, and browse, install, or update skills through the Hermes Dashboard.
|
||||
- Hands-free voice through your server's speech providers. Hermes-Relay pairing adds per-profile voices and an experimental realtime engine.
|
||||
- Create, switch, search, rename, pin, archive, and continue sessions.
|
||||
- Connect multiple Hermes servers and switch in one tap; add LAN, Tailscale, or public routes.
|
||||
- Pair the Hermes-Relay plugin for Terminal/TUI, notifications, media, enhanced voice, Relay sessions, and per-feature grants.
|
||||
- Inspect connection readiness, routes, response timing, token usage, and stream health without exposing credentials.
|
||||
|
||||
SECURITY AND PRIVACY
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
v1.15.1 - Steadier chat, media, and voice
|
||||
v1.17.0 - Voice over other apps and clearer conversations
|
||||
|
||||
More reliable chats and media: fewer memory-heavy refreshes, smoother large histories, and attachment previews that survive rotation. Choose whether follow-ups correct the current response or wait in a queue. Voice errors are easier to read, image-generation progress stays visible, and wake-word startup and first-message readiness are fixed.
|
||||
Use optional voice controls over other apps, with clear permission setup and an immediate Stop action. Answer Clarify batches one question at a time, with progress preserved across reconnects. Enjoy cleaner chat cards, recognizable profile names, and context previews that show what your connection supports. Phone control remains sideload-only.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
|
||||
v1.17.0 - 跨应用语音与更清晰的对话
|
||||
|
||||
可选的语音悬浮控件让你在使用其他应用时继续语音会话,提供清晰的权限说明和随时停止操作。逐题回答 Clarify 批量问题,重连后保留已确认的进度。聊天卡片更简洁,配置文件显示名称更易辨认,上下文预览准确说明当前连接支持发送哪些信息。设备控制仍仅限侧载版本。
|
||||
|
||||
@@ -1,6 +1,119 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.17.0",
|
||||
"title": "Voice over other apps and clearer conversations",
|
||||
"date": "2026-09-13",
|
||||
"summary": "Google Play gains optional voice controls over other apps. Clarify questions, profile names, and chat context are easier to follow without losing confirmed answers or saved conversations.",
|
||||
"changes": [
|
||||
{"id": "play-voice-overlay", "kind": "added", "title": "Keep voice controls over other apps", "summary": "Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Stop voice from its controls or persistent notification; screen lock and permission loss end the session.", "highlight": true},
|
||||
{"id": "clarify-batches", "kind": "fixed", "title": "Answer Clarify questions one at a time", "summary": "Multi-question requests support separate choices and custom answers. Confirmed progress survives reconnects, and failed sends keep the current answer available.", "highlight": true},
|
||||
{"id": "chat-card-surfaces", "kind": "improved", "title": "Read cleaner chat cards", "summary": "Standalone response cards lose the extra outer bubble, assistant messages use quieter surfaces, and delivery status sits beside the timestamp."},
|
||||
{"id": "transport-context-preview", "kind": "fixed", "title": "See which context your chat can send", "summary": "Gateway previews mark phone status and turn context as unavailable. Automatic phone-status sharing is labeled for API-only chats, where it remains supported."},
|
||||
{"id": "profile-display-names", "kind": "fixed", "title": "Recognize profiles by their display names", "summary": "Profiles use their Hermes display names, and the resolved server default appears under its agent identity. Explicit profile choices and saved conversations are preserved.", "highlight": true}
|
||||
],
|
||||
"compatibility": [
|
||||
"Voice Overlay is now available in Google Play and sideload builds. Device Control remains sideload-only.",
|
||||
"Standard Chat and voice use upstream Hermes without requiring Hermes-Relay Plugin. Gateway phone-status delivery and automatic Android identification remain unavailable."
|
||||
],
|
||||
"playNotes": "Use optional voice controls over other apps, with clear permission setup and an immediate Stop action. Answer Clarify batches one question at a time, with progress preserved across reconnects. Enjoy cleaner chat cards, recognizable profile names, and context previews that show what your connection supports. Phone control remains sideload-only.",
|
||||
"sections": []
|
||||
},
|
||||
{
|
||||
"version": "1.16.1",
|
||||
"title": "Dashboard-only cold starts recover",
|
||||
"date": "2026-09-12",
|
||||
"summary": "Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.",
|
||||
"changes": [
|
||||
{
|
||||
"id": "gateway-directory-bootstrap",
|
||||
"kind": "fixed",
|
||||
"title": "Open Gateway chat from a Dashboard-only cold start",
|
||||
"summary": "The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.",
|
||||
"highlight": true
|
||||
}
|
||||
],
|
||||
"compatibility": [
|
||||
"Standard Dashboard and Gateway chat continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
|
||||
"Hermes-Relay Plugin 1.11.2 remains the matching optional release for Relay tools."
|
||||
],
|
||||
"playNotes": "Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.",
|
||||
"sections": []
|
||||
},
|
||||
{
|
||||
"version": "1.16.0",
|
||||
"title": "Safer startup, connections, and activity",
|
||||
"date": "2026-09-10",
|
||||
"summary": "Gateway chat opens reliably from a cold launch, saved sign-ins stay with the correct connection, and network changes no longer race the route cache. Bot Mode and delegated-work feedback also remain stable across multiple gateways and later review.",
|
||||
"changes": [
|
||||
{
|
||||
"id": "gateway-cold-start",
|
||||
"kind": "fixed",
|
||||
"title": "Open Gateway chat on the first launch",
|
||||
"summary": "An authenticated Gateway wakes and opens from a cold foreground start instead of waiting for the app to background and resume.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "connection-owned-signin",
|
||||
"kind": "fixed",
|
||||
"title": "Keep saved sign-ins with their connection",
|
||||
"summary": "Switching gateways cannot reuse an outgoing resolver route to invalidate another connection's saved Dashboard session.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "network-change-invalidation",
|
||||
"kind": "fixed",
|
||||
"title": "Recover safely when the network changes",
|
||||
"summary": "Route-probe completion and endpoint-cache invalidation are serialized so Wi-Fi, mobile-data, VPN, or Tailscale changes do not trigger the reported crash.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "bot-mode-connection-identity",
|
||||
"kind": "fixed",
|
||||
"title": "Open same-named bots from multiple gateways",
|
||||
"summary": "Bot Mode and Active Now keep connection and profile identity together, avoiding duplicate list keys and opening progress on the selected bot."
|
||||
},
|
||||
{
|
||||
"id": "delegated-activity-receipts",
|
||||
"kind": "improved",
|
||||
"title": "Review delegated work after it finishes",
|
||||
"summary": "Compact, bounded activity receipts survive parent replies and remain available read-only, while the live strip appears only during active work.",
|
||||
"highlight": true
|
||||
},
|
||||
{
|
||||
"id": "chat-feedback-surfaces",
|
||||
"kind": "improved",
|
||||
"title": "Keep feedback with the surface that owns it",
|
||||
"summary": "Themed banners and action cards replace platform popups, global actions stay clear of the composer, and local Developer previews make feedback states inspectable."
|
||||
},
|
||||
{
|
||||
"id": "attachment-error-recovery",
|
||||
"kind": "fixed",
|
||||
"title": "Retry missing attachments in place",
|
||||
"summary": "A missing attachment keeps its error and Retry action in the attachment card without producing repeated global messages."
|
||||
},
|
||||
{
|
||||
"id": "session-preparation-diagnostics",
|
||||
"kind": "improved",
|
||||
"title": "See session preparation and initialization failures",
|
||||
"summary": "Chat distinguishes session preparation from response streaming, retains early initialization errors, and opens session diagnostics from the agent header."
|
||||
},
|
||||
{
|
||||
"id": "pasted-dashboard-credentials",
|
||||
"kind": "fixed",
|
||||
"title": "Paste Dashboard credentials without hidden line breaks",
|
||||
"summary": "Username and password fields remove pasted carriage returns and line feeds while preserving every other credential character."
|
||||
}
|
||||
],
|
||||
"compatibility": [
|
||||
"Standard Chat, sessions, profiles, Manage, voice, Bot Mode, and delegated activity continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.",
|
||||
"Hermes-Relay Plugin 1.11.2 is the matching optional release for Relay tools. Existing erased or revoked Dashboard credentials still require a legitimate sign-in.",
|
||||
"Granular Device Control and the system Voice Focus overlay remain sideload-only."
|
||||
],
|
||||
"playNotes": "Gateway chat now opens reliably on a cold launch. Saved Dashboard sign-ins stay bound to the correct connection, pasted credentials ignore accidental line breaks, and network changes no longer race the route cache. Bot Mode supports duplicate profile names across gateways, while delegated work, session setup, attachment errors, and feedback remain visible and easier to review.",
|
||||
"sections": []
|
||||
},
|
||||
{
|
||||
"version": "1.15.1",
|
||||
"title": "Steadier chat, media, and voice",
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
v1.15.1 - Steadier chat, media, and voice
|
||||
v1.17.0 - Voice over other apps and clearer conversations
|
||||
|
||||
Summary
|
||||
* Chats use less memory, attachment previews stay in place, and voice failures are easier to recover from. Follow-up controls make it clear whether a message changes the current response or waits for the next turn.
|
||||
* Google Play gains optional voice controls over other apps. Clarify questions, profile names, and chat context are easier to follow without losing confirmed answers or saved conversations.
|
||||
|
||||
Highlights
|
||||
* Choose when follow-up messages are sent — A slim tray behind the composer offers Correct now or Queue next. Chat settings sets the default, and a composer choice applies to one message. Stop pauses pending work until Resume; editing or removing an item keeps the remaining queue usable.
|
||||
* Keep attachment previews open through rotation — Image, video, audio, PDF, text, and file previews stay open as the screen rotates. Videos retain their original proportions.
|
||||
* Keep large chats and media manageable — Automatic session refresh no longer loops. Routine history loads, chat rendering, image previews, and media exports use bounded memory instead of allocating entire large responses.
|
||||
* Keep voice controls over other apps — Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Stop voice from its controls or persistent notification; screen lock and permission loss end the session.
|
||||
* Answer Clarify questions one at a time — Multi-question requests support separate choices and custom answers. Confirmed progress survives reconnects, and failed sends keep the current answer available.
|
||||
* Recognize profiles by their display names — Profiles use their Hermes display names, and the resolved server default appears under its agent identity. Explicit profile choices and saved conversations are preserved.
|
||||
|
||||
Improved
|
||||
* Make better use of wider screens — Chat and Voice keep text and controls on readable centered layouts. Landscape Voice Focus separates identity controls from conversation activity.
|
||||
* Read cleaner chat cards — Standalone response cards lose the extra outer bubble, assistant messages use quieter surfaces, and delivery status sits beside the timestamp.
|
||||
|
||||
Fixed
|
||||
* Read message delivery status clearly — Correction and delivery labels use contrasting text instead of disappearing into the message bubble.
|
||||
* Read and dismiss voice errors — Voice errors open in a contained dialog with scrollable details and separate Retry and Dismiss actions, without overlapping chat controls.
|
||||
* Fix wake-word startup in release builds — Release optimization now preserves the native speech configuration names needed to initialize wake-word detection.
|
||||
* Download attachments with less memory — Standard Hermes attachments stream into the on-disk cache while download size limits remain enforced.
|
||||
* Keep image-generation progress visible — The working indicator stays visible between interim replies and the generated image, including gateways that omit tool activity events.
|
||||
* Wait for new chats to be ready — The first message waits for the Gateway session to initialize. Ownership refusals keep the prompt retryable and show the server's error.
|
||||
* See which context your chat can send — Gateway previews mark phone status and turn context as unavailable. Automatic phone-status sharing is labeled for API-only chats, where it remains supported.
|
||||
|
||||
Compatibility
|
||||
* Standard Chat, sessions, media, and voice continue to use upstream Hermes. Voice transcription still requires a configured speech-to-text provider on the Hermes host.
|
||||
* Paused text queues can be restored. Attachment bytes are not stored in preferences; an attachment queue that cannot be restored must be reviewed and sent again.
|
||||
* Voice Overlay is now available in Google Play and sideload builds. Device Control remains sideload-only.
|
||||
* Standard Chat and voice use upstream Hermes without requiring Hermes-Relay Plugin. Gateway phone-status delivery and automatic Android identification remain unavailable.
|
||||
|
||||
@@ -13,10 +13,15 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.runtime.HermesProcessRuntime
|
||||
import com.hermesandroid.relay.ui.theme.AppearanceNightMode
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
@@ -57,6 +62,10 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
// Install the crash handler FIRST so any failure in the rest of app
|
||||
// init (or anywhere later) is captured and surfaced on next launch.
|
||||
CrashReporter.install(this)
|
||||
// Apply saved Light/Dark/Auto before the first Activity frame so DayNight
|
||||
// does not briefly follow the system when Appearance is explicitly Light.
|
||||
// Bounded + best-effort: HermesRelayTheme SideEffect is the durable path.
|
||||
applyPersistedAppearanceNightMode()
|
||||
AppAnalytics.initialize(this)
|
||||
// A8 — wire the bridge-gesture wake-lock wrapper so
|
||||
// ActionExecutor.tap/tapText/typeText/swipe/scroll can hold
|
||||
@@ -76,6 +85,19 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
AppForegroundTracker.initialize()
|
||||
}
|
||||
|
||||
private fun applyPersistedAppearanceNightMode() {
|
||||
try {
|
||||
runBlocking {
|
||||
val preferences = withTimeoutOrNull(400L) {
|
||||
relayDataStore.data.first()
|
||||
} ?: return@runBlocking
|
||||
AppearanceNightMode.applyFromPreferences(preferences)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// Non-fatal — theme root reapplies once DataStore is ready.
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMainApplicationProcess(): Boolean {
|
||||
val processName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
getProcessName()
|
||||
|
||||
@@ -34,9 +34,7 @@ object AgentDisplay {
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
|
||||
// Display can use the root default profile's metadata without making it a
|
||||
// request/session override. Verbose SOUL summaries are filtered by
|
||||
// profileDisplayName below, so this is safe for headers/cards.
|
||||
// Display resolution never changes selection or persistence identity.
|
||||
fun effectiveDisplayProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
@@ -44,36 +42,22 @@ object AgentDisplay {
|
||||
): Profile? {
|
||||
selectedProfile?.let { return it }
|
||||
val resolvedServerDefault = profileRequestName(serverDefaultProfileName)
|
||||
return resolvedServerDefault
|
||||
?.let { activeName ->
|
||||
profiles.firstOrNull { it.name.equals(activeName, ignoreCase = true) }
|
||||
}
|
||||
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
|
||||
// An absent roster row is not authority to substitute the root profile.
|
||||
// Retain the confirmed name while its display metadata is loading.
|
||||
return resolvedServerDefault?.let { activeName ->
|
||||
profiles.firstOrNull { it.name == activeName }
|
||||
?: Profile(name = activeName, model = "")
|
||||
}
|
||||
}
|
||||
|
||||
// The NAME goes in the name slot. Non-default profiles use their profile
|
||||
// name first. The synthetic default profile uses its description only when
|
||||
// that description looks like a concise human agent name ("Victor"), not a
|
||||
// verbose SOUL summary.
|
||||
// Match upstream Desktop: presentation-only display_name, then exact request name.
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
if (profile.name.equals("default", ignoreCase = true)) {
|
||||
return defaultProfileDisplayName(profile)
|
||||
}
|
||||
return when {
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
else -> null
|
||||
}
|
||||
return profile.displayName.trim().takeIf(String::isNotEmpty)
|
||||
?: profile.name.trim().takeIf(String::isNotEmpty)
|
||||
}
|
||||
|
||||
fun defaultProfileDisplayName(profile: Profile?): String? =
|
||||
profile
|
||||
?.description
|
||||
?.trim()
|
||||
?.takeIf { it.looksLikeConciseAgentName() }
|
||||
?.let(::titleCase)
|
||||
|
||||
@Suppress("UNUSED_PARAMETER") // connectionLabel retained for source compatibility.
|
||||
fun agentName(
|
||||
profile: Profile?,
|
||||
selectedPersonality: String,
|
||||
@@ -86,7 +70,7 @@ object AgentDisplay {
|
||||
|
||||
// "none"/"neutral" are the upstream "cleared overlay" aliases — treat
|
||||
// them like "default" for identity: fall through to the server default
|
||||
// (or the base connection identity) rather than rendering the literal
|
||||
// identity rather than rendering the literal
|
||||
// word as an agent name.
|
||||
val personalityName = if (
|
||||
isClearedPersonality(selectedPersonality) &&
|
||||
@@ -102,7 +86,6 @@ object AgentDisplay {
|
||||
return when {
|
||||
personalityName.isNotBlank() && personalityName != "default" ->
|
||||
titleCase(personalityName.trim())
|
||||
!connectionLabel.isNullOrBlank() -> connectionLabel.trim()
|
||||
else -> "Hermes"
|
||||
}
|
||||
}
|
||||
@@ -199,16 +182,6 @@ object AgentDisplay {
|
||||
?.replace(Regex("\\s+"), " ")
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun String.looksLikeConciseAgentName(): Boolean {
|
||||
if (isBlank() || length > 40 || contains('\n') || contains('\r')) {
|
||||
return false
|
||||
}
|
||||
if (any { it == '.' || it == ':' || it == ';' }) {
|
||||
return false
|
||||
}
|
||||
return trim().split(Regex("\\s+")).size <= 4
|
||||
}
|
||||
|
||||
private fun titleCase(value: String): String =
|
||||
value.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.hermesandroid.relay.ui.theme.AppFont
|
||||
@@ -35,23 +36,25 @@ internal object AppearancePreferences {
|
||||
private val serializer = ListSerializer(CustomThemePreset.serializer())
|
||||
|
||||
fun state(context: Context): Flow<PersistedAppearance> = context.applicationContext.relayDataStore.data
|
||||
.map { preferences ->
|
||||
val customThemes = decodeCustomThemes(preferences[customThemesKey])
|
||||
val requestedThemeId = preferences[appThemeKey]
|
||||
val customTheme = CustomThemePreset.idFromAppTheme(requestedThemeId)
|
||||
?.let { id -> customThemes.firstOrNull { it.id == id } }
|
||||
PersistedAppearance(
|
||||
themePreference = preferences[themeKey]
|
||||
?.takeIf { it == "auto" || it == "light" || it == "dark" }
|
||||
?: "auto",
|
||||
appThemeId = customTheme?.appThemeId ?: AppThemes.byId(requestedThemeId).id,
|
||||
accentHex = normalizeAccentHex(preferences[accentKey]),
|
||||
shapeId = AppearanceShape.fromId(preferences[shapeKey]).id,
|
||||
appFontId = AppFont.byId(preferences[appFontKey]).id,
|
||||
fontScale = (preferences[fontScaleKey] ?: 1.0f).coerceIn(0.85f, 1.3f),
|
||||
customTheme = customTheme,
|
||||
)
|
||||
}
|
||||
.map(::decode)
|
||||
|
||||
fun decode(preferences: Preferences): PersistedAppearance {
|
||||
val customThemes = decodeCustomThemes(preferences[customThemesKey])
|
||||
val requestedThemeId = preferences[appThemeKey]
|
||||
val customTheme = CustomThemePreset.idFromAppTheme(requestedThemeId)
|
||||
?.let { id -> customThemes.firstOrNull { it.id == id } }
|
||||
return PersistedAppearance(
|
||||
themePreference = preferences[themeKey]
|
||||
?.takeIf { it == "auto" || it == "light" || it == "dark" }
|
||||
?: "auto",
|
||||
appThemeId = customTheme?.appThemeId ?: AppThemes.byId(requestedThemeId).id,
|
||||
accentHex = normalizeAccentHex(preferences[accentKey]),
|
||||
shapeId = AppearanceShape.fromId(preferences[shapeKey]).id,
|
||||
appFontId = AppFont.byId(preferences[appFontKey]).id,
|
||||
fontScale = (preferences[fontScaleKey] ?: 1.0f).coerceIn(0.85f, 1.3f),
|
||||
customTheme = customTheme,
|
||||
)
|
||||
}
|
||||
|
||||
fun shape(context: Context): Flow<String> = state(context).map { it.shapeId }
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -135,6 +136,9 @@ data class ChatTurnAskCheckpoint(
|
||||
val text: String,
|
||||
val choices: List<String>? = null,
|
||||
val multiSelect: Boolean = false,
|
||||
val questions: List<GatewayClarifyQuestion> = emptyList(),
|
||||
val answers: Map<String, String> = emptyMap(),
|
||||
val ownerId: String? = null,
|
||||
val smartDenied: Boolean = false,
|
||||
val envVar: String? = null,
|
||||
val timeoutSeconds: Int,
|
||||
|
||||
@@ -44,7 +44,9 @@ val Connection.automaticChatTransport: SessionTransport
|
||||
get() {
|
||||
val dashboardPersisted = !dashboardUrl.isNullOrBlank() ||
|
||||
!authenticatedDashboardOrigin.isNullOrBlank()
|
||||
return if (dashboardPersisted) SessionTransport.GATEWAY else SessionTransport.SSE
|
||||
// An empty first-setup placeholder is standard Gateway intent, not an
|
||||
// API-only conversation. Only an actual API endpoint selects legacy SSE.
|
||||
return if (dashboardPersisted || apiServerUrl.isBlank()) SessionTransport.GATEWAY else SessionTransport.SSE
|
||||
}
|
||||
|
||||
fun Connection.chatTransportForPreference(preference: String): SessionTransport =
|
||||
|
||||
@@ -67,6 +67,8 @@ data class Connection(
|
||||
* "derive from [apiServerUrl] using the conventional same-host :9119".
|
||||
*/
|
||||
val dashboardUrl: String? = null,
|
||||
/** User-accepted cleartext origins. This does not assert or monitor VPN protection. */
|
||||
val dashboardHttpConsentOrigins: Set<String> = emptySet(),
|
||||
/**
|
||||
* Credential-free origin that most recently completed Dashboard
|
||||
* authentication for this connection. Public origins require HTTPS;
|
||||
@@ -116,7 +118,7 @@ data class Connection(
|
||||
*/
|
||||
val resolvedDashboardUrl: String
|
||||
get() = authenticatedDashboardOrigin
|
||||
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
|
||||
?.let { normalizeCredentialFreeAuthenticatedDashboardOrigin(it, dashboardHttpConsentOrigins) }
|
||||
?: configuredDashboardUrl
|
||||
|
||||
/** Stable display/host identity that does not depend on the API surface. */
|
||||
@@ -628,7 +630,10 @@ internal fun normalizeCredentialFreeHttpsOrigin(raw: String): String? {
|
||||
* HTTPS; cleartext is accepted only for literal loopback, RFC1918/link-local,
|
||||
* or Tailscale CGNAT addresses.
|
||||
*/
|
||||
internal fun normalizeCredentialFreeAuthenticatedDashboardOrigin(raw: String): String? {
|
||||
internal fun normalizeCredentialFreeAuthenticatedDashboardOrigin(
|
||||
raw: String,
|
||||
httpConsentOrigins: Set<String> = emptySet(),
|
||||
): String? {
|
||||
normalizeCredentialFreeHttpsOrigin(raw)?.let { return it }
|
||||
val parsed = runCatching { URI(raw.trim()) }.getOrNull() ?: return null
|
||||
if (!parsed.scheme.equals("http", ignoreCase = true)) return null
|
||||
@@ -651,6 +656,6 @@ internal fun normalizeCredentialFreeAuthenticatedDashboardOrigin(raw: String): S
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
if (!trustedHost) return null
|
||||
if (!trustedHost && !dashboardHttpConsentMatches(raw, httpConsentOrigins)) return null
|
||||
return parsed.normalize().toASCIIString().trimEnd('/').takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
@@ -581,7 +581,7 @@ internal fun Connection.withDashboardDefaults(): Connection {
|
||||
it.role.equals(LEGACY_AUTHENTICATED_DASHBOARD_ROUTE_ROLE, ignoreCase = true)
|
||||
}
|
||||
val migratedAuthenticatedOrigin = authenticatedDashboardOrigin
|
||||
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
|
||||
?.let { normalizeCredentialFreeAuthenticatedDashboardOrigin(it, dashboardHttpConsentOrigins) }
|
||||
?: legacyAuthenticatedRoute?.dashboard?.url
|
||||
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
|
||||
val routesWithoutLegacyAuthentication = routeCandidates.filterNot {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/** An explicit HTTP exception is scoped to a connection and exact origin, never a VPN claim. */
|
||||
fun dashboardHttpOrigin(address: String): String? {
|
||||
val url = address.trim().toHttpUrlOrNull() ?: return null
|
||||
if (url.scheme != "http" || url.username.isNotEmpty() || url.password.isNotEmpty() ||
|
||||
url.query != null || url.fragment != null
|
||||
) return null
|
||||
return url.newBuilder().encodedPath("/").build().toString().trimEnd('/')
|
||||
}
|
||||
|
||||
fun dashboardHttpConsentRequired(address: String): Boolean =
|
||||
dashboardHttpOrigin(address) != null && Connection.inferRouteRole(address) == "public"
|
||||
|
||||
fun dashboardHttpConsentMatches(address: String, approvedOrigins: Set<String>): Boolean =
|
||||
dashboardHttpOrigin(address)?.let { it in approvedOrigins } == true
|
||||
|
||||
/** Editing an origin retires its old exception; another address requires its own confirmation. */
|
||||
fun updatedDashboardHttpConsents(
|
||||
previous: Set<String>,
|
||||
oldAddress: String?,
|
||||
newAddress: String,
|
||||
confirmedOrigin: String?,
|
||||
): Set<String> {
|
||||
val oldOrigin = oldAddress?.let(::dashboardHttpOrigin)
|
||||
val newOrigin = dashboardHttpOrigin(newAddress)
|
||||
val retained = if (oldOrigin != newOrigin) previous - setOfNotNull(oldOrigin) else previous
|
||||
return if (newOrigin != null && confirmedOrigin == newOrigin) retained + newOrigin else retained
|
||||
}
|
||||
@@ -197,15 +197,34 @@ fun EndpointCandidate.isDashboardOnlyRoute(): Boolean =
|
||||
/** Dashboard-first URL identity for routing, diagnostics, and UI labels. */
|
||||
fun EndpointCandidate.primaryRouteUrl(): String? =
|
||||
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: proxyDashboardBaseUrlOrNull()
|
||||
?: api?.url
|
||||
?: relay?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: proxy?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Dashboard/Gateway identity only; Relay and broker transports are extensions. */
|
||||
/**
|
||||
* Dashboard/Gateway identity only; Relay and broker transports are extensions.
|
||||
*
|
||||
* Hermes Secure Link stores the dashboard surface under [ProxyEndpoint.surfaces]
|
||||
* (`…/dashboard`), not [DashboardEndpoint.url]. Without that hop, Routes/Access
|
||||
* fall back to the saved plain `:9119` URL while Overview already rides the
|
||||
* live Secure Link origin.
|
||||
*/
|
||||
fun EndpointCandidate.gatewayRouteUrl(): String? =
|
||||
dashboard?.url?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
|
||||
?: proxyDashboardBaseUrlOrNull()
|
||||
?: api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
|
||||
/** Secure Link dashboard base when the proxy advertises a dashboard surface. */
|
||||
internal fun EndpointCandidate.proxyDashboardBaseUrlOrNull(): String? {
|
||||
val proxy = proxy ?: return null
|
||||
if (!proxy.isValidPinnedProxy()) return null
|
||||
val surfaces = proxy.surfaces.map { it.trim().lowercase() }.toSet()
|
||||
if ("dashboard" !in surfaces) return null
|
||||
val base = proxy.url.trim().trimEnd('/').takeIf { it.isNotBlank() } ?: return null
|
||||
return "$base/dashboard"
|
||||
}
|
||||
|
||||
/** Stable host/port identity without assuming that an API surface exists. */
|
||||
fun EndpointCandidate.routeAuthority(): String? {
|
||||
val rawUrl = primaryRouteUrl() ?: return null
|
||||
|
||||
@@ -96,7 +96,8 @@ object FeatureFlags {
|
||||
* flavor ships AccessibilityService-backed Device Control. The `googlePlay`
|
||||
* flavor is Bridge Core: relay pairing, chat, voice, terminal, notification
|
||||
* companion, media, and session-grant surfaces without screen reading, taps,
|
||||
* typing, screenshots, overlays, or unattended control.
|
||||
* typing, MediaProjection screenshots, or unattended control. Voice-only overlay
|
||||
* presentation is a separate capability shared by both flavors.
|
||||
*
|
||||
* Device Control tier definitions (see `Phase 3 — Bridge Channel.md`):
|
||||
* 1. baseline — sideload only (app open, tap, navigate within app)
|
||||
@@ -126,6 +127,9 @@ object BuildFlavor {
|
||||
*/
|
||||
val isSideload: Boolean get() = current == SIDELOAD
|
||||
|
||||
/** Voice presentation does not grant Device Control. Unknown distributions fail closed. */
|
||||
val voiceSystemOverlay: Boolean get() = current == GOOGLE_PLAY || current == SIDELOAD
|
||||
|
||||
val bridgeTier1: Boolean get() = current == SIDELOAD // baseline device control
|
||||
val bridgeTier2: Boolean get() = current == SIDELOAD // screen context
|
||||
val bridgeTier3: Boolean get() = current == SIDELOAD // voice-first
|
||||
|
||||
@@ -71,6 +71,8 @@ data class HermesCard(
|
||||
* actions.
|
||||
*/
|
||||
val input: HermesCardInput? = null,
|
||||
/** Local Gateway batch; never an ordinary chat-message answer protocol. */
|
||||
val clarifyBatch: HermesCardClarifyBatch? = null,
|
||||
) {
|
||||
object BuiltInTypes {
|
||||
const val SKILL_RESULT = "skill_result"
|
||||
@@ -96,6 +98,25 @@ data class HermesCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class HermesCardClarifyBatch(
|
||||
val questions: List<HermesCardClarifyQuestion>,
|
||||
val expiresAtMillis: Long? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HermesCardClarifyQuestion(
|
||||
val key: String,
|
||||
val question: String,
|
||||
val input: HermesCardInput,
|
||||
val answer: String? = null,
|
||||
val submitting: Boolean = false,
|
||||
)
|
||||
|
||||
/** Local callback identity. The RPC always uses the original qid, never this UI key. */
|
||||
fun clarifyQuestionCardKey(cardKey: String, qid: String): String =
|
||||
Json.encodeToString(listOf(cardKey, qid))
|
||||
|
||||
/**
|
||||
* Interactive input slot on a [HermesCard]. The flags compose rather than
|
||||
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
|
||||
|
||||
@@ -111,6 +111,8 @@ data class Profile(
|
||||
val hasAvatar: Boolean = false,
|
||||
@SerialName("ui_meta")
|
||||
val uiMeta: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("display_name")
|
||||
val displayName: String = "",
|
||||
) {
|
||||
val hasIsolatedApi: Boolean
|
||||
get() = !apiServerUrl.isNullOrBlank()
|
||||
|
||||
@@ -27,7 +27,7 @@ data class ProviderUsagePreferences(
|
||||
val visibleProviders: Set<String> = DEFAULT_VISIBLE_PROVIDERS,
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go")
|
||||
val DEFAULT_VISIBLE_PROVIDERS = setOf("openai-codex", "nous", "opencode-go", "supergrok")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-1
@@ -131,7 +131,7 @@ class ProactiveMessageHandler(
|
||||
ProactiveMessageNotifier.notify(
|
||||
context = context,
|
||||
title = msg.title,
|
||||
text = msg.text,
|
||||
text = mediaFreeProactivePreview(msg.text),
|
||||
messageId = msg.messageId,
|
||||
chatId = msg.chatId,
|
||||
)
|
||||
@@ -156,6 +156,30 @@ class ProactiveMessageHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/** Notification text is a preview; the Thread owns attachment rendering. */
|
||||
internal fun mediaFreeProactivePreview(text: String): String {
|
||||
var fence: String? = null
|
||||
val lines = mutableListOf<String>()
|
||||
for (line in text.lines()) {
|
||||
val trimmed = line.trim()
|
||||
val delimiter = when {
|
||||
trimmed.startsWith("```") -> "```"
|
||||
trimmed.startsWith("~~~") -> "~~~"
|
||||
else -> null
|
||||
}
|
||||
if (delimiter != null) {
|
||||
fence = if (fence == delimiter) null else if (fence == null) delimiter else fence
|
||||
}
|
||||
val markerOnly = fence == null && (
|
||||
trimmed.startsWith("MEDIA:hermes-relay://") ||
|
||||
trimmed.startsWith("MEDIA:/") ||
|
||||
Regex("^MEDIA:[A-Za-z]:\\\\").containsMatchIn(trimmed)
|
||||
)
|
||||
if (!markerOnly && trimmed.isNotEmpty()) lines += trimmed
|
||||
}
|
||||
return lines.joinToString(" ").ifBlank { "Attachment" }
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed agent-initiated message. `surfacing` is the optional route hint
|
||||
* (null = app default); Phase 2 keys inbox/session delivery off it.
|
||||
|
||||
@@ -75,16 +75,25 @@ class RelayHttpClient(
|
||||
private val context: Context? = null,
|
||||
/** Dashboard-authenticated client for same-origin plugin ingress calls. */
|
||||
private val dashboardHttpClientProvider: ((String) -> OkHttpClient?)? = null,
|
||||
/**
|
||||
* Pinned-TLS client for Hermes Secure Link (`plugin_proxy`) relay URLs.
|
||||
* Without this, HTTPS probes against the self-signed Secure Link cert fail
|
||||
* with "Trust anchor for certification path not found" while the WSS path
|
||||
* (which already uses buildPluginProxyClient) stays healthy — the UI then
|
||||
* reports dashboard/relay surfaces offline despite an Active connection.
|
||||
*/
|
||||
private val pluginProxyHttpClientProvider: ((String) -> OkHttpClient?)? = null,
|
||||
) {
|
||||
|
||||
private fun relayHttpBaseOrNull(url: String): String? =
|
||||
RelayEndpointContract.parseOrNull(url)?.httpBaseUrl
|
||||
|
||||
private fun callClient(relayUrl: String): OkHttpClient =
|
||||
if (isDashboardRelayIngressUrl(relayUrl)) {
|
||||
dashboardHttpClientProvider?.invoke(relayUrl) ?: okHttpClient
|
||||
} else {
|
||||
okHttpClient
|
||||
when {
|
||||
isDashboardRelayIngressUrl(relayUrl) ->
|
||||
dashboardHttpClientProvider?.invoke(relayUrl) ?: okHttpClient
|
||||
else ->
|
||||
pluginProxyHttpClientProvider?.invoke(relayUrl) ?: okHttpClient
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -327,11 +336,11 @@ class RelayHttpClient(
|
||||
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchMedia failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
Log.w(TAG, "fetchMedia failed")
|
||||
Result.failure(if (e is RelayMediaLimitException) e else IOException("Relay media request failed"))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchMedia unexpected error: ${e.message}")
|
||||
Result.failure(e)
|
||||
Log.w(TAG, "fetchMedia unexpected error")
|
||||
Result.failure(IOException("Relay media request failed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +416,7 @@ class RelayHttpClient(
|
||||
val reason = when (response.code) {
|
||||
401 -> "Unauthorized — re-pair with the relay"
|
||||
403 -> "Path not allowed by relay sandbox"
|
||||
404 -> "File not found on relay: $path"
|
||||
404 -> "File not found on relay"
|
||||
400 -> "Bad request — missing path"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
@@ -437,15 +446,15 @@ class RelayHttpClient(
|
||||
Result.success(FetchedMedia(contentType, bytes, fileName, sensitive))
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchMediaByPath failed for $path: ${e.message}")
|
||||
Log.w(TAG, "fetchMediaByPath failed")
|
||||
if (e is RelayMediaLimitException) {
|
||||
Result.failure(e)
|
||||
} else {
|
||||
Result.failure(IOException("Relay unreachable: ${e.message ?: "IO error"}"))
|
||||
Result.failure(IOException("Relay media request failed"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchMediaByPath unexpected error for $path: ${e.message}")
|
||||
Result.failure(e)
|
||||
Log.w(TAG, "fetchMediaByPath unexpected error")
|
||||
Result.failure(IOException("Relay media request failed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1360,7 +1369,16 @@ class RelayHttpClient(
|
||||
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
|
||||
)
|
||||
}
|
||||
val version = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
val surface = (parsed["surface"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
val versionRaw = (parsed["version"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
// Secure Link /relay/health historically returned status=ok without
|
||||
// version (surface=hermes_secure_proxy). Treat that as healthy so
|
||||
// route probes don't spam "Missing version field".
|
||||
val version = when {
|
||||
!versionRaw.isNullOrBlank() -> versionRaw
|
||||
surface.equals("hermes_secure_proxy", ignoreCase = true) -> "secure-link"
|
||||
else -> null
|
||||
}
|
||||
if (version.isNullOrBlank()) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
|
||||
@@ -103,6 +103,8 @@ class RelayVoiceClient(
|
||||
private val voiceOutputFirstAudioTimeoutMs: Long = VOICE_OUTPUT_FIRST_AUDIO_TIMEOUT_MS,
|
||||
/** Dashboard-authenticated transport for same-origin plugin ingress. */
|
||||
private val dashboardHttpClientProvider: ((String) -> OkHttpClient?)? = null,
|
||||
/** Pinned-TLS client for Hermes Secure Link relay URLs (self-signed leaf). */
|
||||
private val pluginProxyHttpClientProvider: ((String) -> OkHttpClient?)? = null,
|
||||
/** Fresh Dashboard ticket request for every ingress voice socket dial. */
|
||||
private val dashboardIngressWebSocketRequestProvider:
|
||||
(suspend (String) -> Request?)? = null,
|
||||
@@ -114,11 +116,7 @@ class RelayVoiceClient(
|
||||
private val okHttpClient: OkHttpClient
|
||||
get() {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
return if (isDashboardRelayIngressUrl(relayUrl)) {
|
||||
dashboardHttpClientProvider?.invoke(relayUrl) ?: directOkHttpClient
|
||||
} else {
|
||||
directOkHttpClient
|
||||
}
|
||||
return resolveClient(relayUrl)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -163,13 +161,16 @@ class RelayVoiceClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun callClient(url: String): OkHttpClient =
|
||||
if (isDashboardRelayIngressUrl(url)) {
|
||||
dashboardHttpClientProvider?.invoke(url) ?: directOkHttpClient
|
||||
} else {
|
||||
directOkHttpClient
|
||||
private fun resolveClient(url: String): OkHttpClient =
|
||||
when {
|
||||
isDashboardRelayIngressUrl(url) ->
|
||||
dashboardHttpClientProvider?.invoke(url) ?: directOkHttpClient
|
||||
else ->
|
||||
pluginProxyHttpClientProvider?.invoke(url) ?: directOkHttpClient
|
||||
}
|
||||
|
||||
private fun callClient(url: String): OkHttpClient = resolveClient(url)
|
||||
|
||||
private fun sessionClient(): OkHttpClient =
|
||||
okHttpClient.newBuilder()
|
||||
.callTimeout(SESSION_CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
|
||||
@@ -124,8 +124,15 @@ class EndpointResolver(
|
||||
* expected path for plain JVM tests.
|
||||
*/
|
||||
private val context: Context? = null,
|
||||
/** Route-aware client for pinned plugin proxy probes. */
|
||||
private val clientForCandidate: ((EndpointCandidate) -> OkHttpClient?)? = null,
|
||||
/**
|
||||
* Route-aware client for pinned plugin proxy probes.
|
||||
* Second arg is the concrete probe request URL when known — callers must
|
||||
* pin only when *this* request targets the Secure Link authority. Using a
|
||||
* pin client for every surface on a LAN candidate that merely *stores* a
|
||||
* Secure Link relay URL breaks plain :9119/:8642 probes (authority guard
|
||||
* throws IOException → "Unreachable - IOException").
|
||||
*/
|
||||
private val clientForCandidate: ((EndpointCandidate, probeRequestUrl: String?) -> OkHttpClient?)? = null,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -149,7 +156,10 @@ class EndpointResolver(
|
||||
)
|
||||
|
||||
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
|
||||
private val inFlightProbes = ConcurrentHashMap<String, Deferred<Boolean>>()
|
||||
// Every access is owned by [probeStateLock]. This must not be a
|
||||
// concurrently-mutated collection: clearCache() takes a stable snapshot
|
||||
// while completion callbacks remove finished probes.
|
||||
private val inFlightProbes = mutableMapOf<String, Deferred<Boolean>>()
|
||||
private val probeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val probeStateLock = Any()
|
||||
private var probeGeneration = 0L
|
||||
@@ -486,7 +496,13 @@ class EndpointResolver(
|
||||
probe(candidate, surface, generation)
|
||||
}.also { deferred ->
|
||||
inFlightProbes[key] = deferred
|
||||
deferred.invokeOnCompletion { inFlightProbes.remove(key, deferred) }
|
||||
deferred.invokeOnCompletion {
|
||||
synchronized(probeStateLock) {
|
||||
// Identity-aware removal prevents an invalidated
|
||||
// probe from removing its fresh replacement.
|
||||
inFlightProbes.remove(key, deferred)
|
||||
}
|
||||
}
|
||||
deferred.start()
|
||||
}
|
||||
}
|
||||
@@ -547,7 +563,9 @@ class EndpointResolver(
|
||||
)
|
||||
}
|
||||
}
|
||||
val fastClient = (clientForCandidate?.invoke(candidate) ?: httpClient).newBuilder()
|
||||
val fastClient = (
|
||||
clientForCandidate?.invoke(candidate, target.requestUrl) ?: httpClient
|
||||
).newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
|
||||
+11
-13
@@ -3,7 +3,6 @@ package com.hermesandroid.relay.network.shared
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.ProxyEndpoint
|
||||
import com.hermesandroid.relay.data.isValidPinnedProxy
|
||||
import okhttp3.CertificatePinner
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.URI
|
||||
@@ -66,9 +65,10 @@ fun EndpointCandidate.pluginProxyRoutesOrNull(): PluginProxyRoutes? =
|
||||
private fun formatHost(host: String): String = if (':' in host) "[$host]" else host
|
||||
|
||||
/**
|
||||
* Build a client that trusts the system normally, plus exactly the
|
||||
* pairing-advertised SPKI for this proxy. The authority guard keeps a pin
|
||||
* scoped to host *and port*; OkHttp's CertificatePinner alone is host-only.
|
||||
* Require the paired leaf SPKI for both system-trusted and self-signed chains.
|
||||
* Validate it in the trust manager, before OkHttp's chain cleaning, so a
|
||||
* self-signed paired leaf does not depend on a platform-supplied cleaned chain.
|
||||
* The authority guard applies to HTTP calls and WebSocket upgrades alike.
|
||||
*/
|
||||
fun buildPluginProxyClient(
|
||||
baseBuilder: OkHttpClient.Builder,
|
||||
@@ -88,12 +88,12 @@ fun buildPluginProxyClient(
|
||||
if (rawSocketFactory != null) baseBuilder.socketFactory(rawSocketFactory)
|
||||
return baseBuilder
|
||||
.sslSocketFactory(sslContext.socketFactory, pinnedTrust)
|
||||
.certificatePinner(
|
||||
CertificatePinner.Builder().add(expectedHost, routes.pinSha256).build(),
|
||||
)
|
||||
.addNetworkInterceptor(Interceptor { chain ->
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.addInterceptor(Interceptor { chain ->
|
||||
val requestUrl = chain.request().url
|
||||
if (!requestUrl.host.equals(expectedHost, ignoreCase = true) ||
|
||||
if (!requestUrl.isHttps ||
|
||||
!requestUrl.host.equals(expectedHost, ignoreCase = true) ||
|
||||
requestUrl.port != expectedPort
|
||||
) {
|
||||
throw java.io.IOException("Pinned proxy redirect left its paired authority")
|
||||
@@ -122,7 +122,7 @@ private fun systemTrustManager(): X509TrustManager {
|
||||
return factory.trustManagers.filterIsInstance<X509TrustManager>().single()
|
||||
}
|
||||
|
||||
private class PinnedOrSystemTrustManager(
|
||||
internal class PinnedOrSystemTrustManager(
|
||||
private val system: X509TrustManager,
|
||||
private val expectedPin: String,
|
||||
) : X509TrustManager {
|
||||
@@ -133,10 +133,8 @@ private class PinnedOrSystemTrustManager(
|
||||
val certificates = chain?.takeIf { it.isNotEmpty() }
|
||||
?: throw CertificateException("Proxy supplied no certificate chain")
|
||||
val systemAccepted = runCatching { system.checkServerTrusted(chain, authType) }.isSuccess
|
||||
if (systemAccepted) return
|
||||
|
||||
val leaf = certificates.first()
|
||||
leaf.checkValidity()
|
||||
if (!systemAccepted) leaf.checkValidity()
|
||||
val actual = "sha256/" + java.util.Base64.getEncoder().encodeToString(
|
||||
MessageDigest.getInstance("SHA-256").digest(leaf.publicKey.encoded),
|
||||
)
|
||||
|
||||
@@ -479,12 +479,15 @@ class ChatHandler {
|
||||
arrivedWhileAway: Boolean = false,
|
||||
) {
|
||||
val id = messageId?.let { "proactive-$it" } ?: "proactive-${java.util.UUID.randomUUID()}"
|
||||
val mediaHits = mutableListOf<Pair<String, MediaMarkerHit>>()
|
||||
val cleanedText = extractMediaMarkersFromContent(id, text, mediaHits)
|
||||
val visibleText = if (mediaHits.isEmpty()) text else cleanedText
|
||||
_messages.update { list ->
|
||||
if (messageId != null && list.any { it.id == id }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = text,
|
||||
content = visibleText,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
agentName = agentName,
|
||||
badges = if (arrivedWhileAway) listOf("While away") else emptyList(),
|
||||
@@ -492,6 +495,8 @@ class ChatHandler {
|
||||
)
|
||||
(list + msg).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
// The row must exist before the ViewModel attaches a loading card.
|
||||
mediaHits.forEach { (_, hit) -> dispatchMediaHit(id, hit) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,6 +523,16 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh only an existing local ask, preserving its dispatches and transcript position. */
|
||||
fun updateAskCardMessage(messageId: String, card: HermesCard) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.clientOnly && message.matchesIdentity(messageId)) message.copy(cards = listOf(card))
|
||||
else message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit-and-regenerate local truncation: drop [messageId] and everything
|
||||
* after it. The gateway performs the authoritative truncation via
|
||||
@@ -1725,34 +1740,7 @@ class ChatHandler {
|
||||
// Now that the reloaded messages are in state, fire callbacks so the
|
||||
// ViewModel can insert LOADING/FAILED attachments via mutateMessage.
|
||||
for ((messageId, hit) in pendingMediaHits) {
|
||||
when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> {
|
||||
val dedupeKey = "$messageId:relay:${hit.token}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
val alreadyHydrated = _messages.value
|
||||
.firstOrNull { it.matchesIdentity(messageId) }
|
||||
?.attachments
|
||||
?.any { it.relayToken == hit.token } == true
|
||||
if (!alreadyHydrated) {
|
||||
Log.d(TAG, "Media marker accepted from reloaded Relay history")
|
||||
onMediaAttachmentRequested(messageId, hit.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
is MediaMarkerHit.BarePath -> {
|
||||
val dedupeKey = "$messageId:bare:${hit.path}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
val alreadyHydrated = _messages.value
|
||||
.firstOrNull { it.matchesIdentity(messageId) }
|
||||
?.attachments
|
||||
?.any { it.relayToken == hit.path } == true
|
||||
if (!alreadyHydrated) {
|
||||
Log.d(TAG, "Media marker (bare-path, reload): ${hit.path}")
|
||||
onMediaBarePathRequested(messageId, hit.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatchMediaHit(messageId, hit)
|
||||
}
|
||||
for ((messageId, path) in pendingPersistedUserImages) {
|
||||
onPersistedUserImageRequested(messageId, path)
|
||||
@@ -1986,29 +1974,33 @@ class ChatHandler {
|
||||
content: String,
|
||||
out: MutableList<Pair<String, MediaMarkerHit>>,
|
||||
): String {
|
||||
var cleaned = content
|
||||
val visibleLines = mutableListOf<String>()
|
||||
var openFence: String? = null
|
||||
for (rawLine in content.lines()) {
|
||||
val trimmed = rawLine.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
if (trimmed.isEmpty()) {
|
||||
visibleLines += rawLine
|
||||
continue
|
||||
}
|
||||
val delimiter = fenceDelimiter(rawLine)
|
||||
if (delimiter != null) {
|
||||
openFence = if (openFence == delimiter) null else if (openFence == null) delimiter else openFence
|
||||
visibleLines += rawLine
|
||||
continue
|
||||
}
|
||||
if (openFence != null) {
|
||||
visibleLines += rawLine
|
||||
continue
|
||||
}
|
||||
if (openFence != null) continue
|
||||
|
||||
val hits = parseMediaMarkerLine(trimmed)
|
||||
if (hits.isNotEmpty()) {
|
||||
hits.forEach { out.add(messageId to it) }
|
||||
cleaned = cleaned
|
||||
.replace("\n$rawLine\n", "\n")
|
||||
.replace("\n$rawLine", "")
|
||||
.replace("$rawLine\n", "")
|
||||
.replace(rawLine, "")
|
||||
} else {
|
||||
visibleLines += rawLine
|
||||
}
|
||||
}
|
||||
return cleaned.trim()
|
||||
return visibleLines.joinToString("\n").trim()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2615,27 +2607,28 @@ class ChatHandler {
|
||||
*/
|
||||
private fun tryDispatchMediaMarker(messageId: String, line: String): Boolean {
|
||||
val hits = parseMediaMarkerLine(line)
|
||||
for (hit in hits) {
|
||||
when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> {
|
||||
val dedupeKey = "$messageId:relay:${hit.token}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker accepted from Relay stream")
|
||||
onMediaAttachmentRequested(messageId, hit.token)
|
||||
}
|
||||
}
|
||||
is MediaMarkerHit.BarePath -> {
|
||||
val dedupeKey = "$messageId:bare:${hit.path}"
|
||||
if (dispatchedMediaMarkers.add(dedupeKey)) {
|
||||
Log.d(TAG, "Media marker (bare-path): ${hit.path}")
|
||||
onMediaBarePathRequested(messageId, hit.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.forEach { dispatchMediaHit(messageId, it) }
|
||||
return hits.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun dispatchMediaHit(messageId: String, hit: MediaMarkerHit) {
|
||||
val (key, reference) = when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> "$messageId:relay:${hit.token}" to hit.token
|
||||
is MediaMarkerHit.BarePath -> "$messageId:bare:${hit.path}" to hit.path
|
||||
}
|
||||
if (!dispatchedMediaMarkers.add(key)) return
|
||||
val alreadyHydrated = _messages.value
|
||||
.firstOrNull { it.matchesIdentity(messageId) }
|
||||
?.attachments
|
||||
?.any { it.relayToken == reference } == true
|
||||
if (alreadyHydrated) return
|
||||
Log.d(TAG, "Media marker accepted")
|
||||
when (hit) {
|
||||
is MediaMarkerHit.RelayToken -> onMediaAttachmentRequested(messageId, hit.token)
|
||||
is MediaMarkerHit.BarePath -> onMediaBarePathRequested(messageId, hit.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a matched annotation line from the message's displayed content.
|
||||
* This prevents the raw annotation text (e.g., `💻 terminal`) from showing
|
||||
|
||||
@@ -29,6 +29,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -362,7 +363,7 @@ data class DashboardFetchedFile(
|
||||
*/
|
||||
class DashboardApiClient(
|
||||
baseUrl: String,
|
||||
private val okHttpClient: OkHttpClient = defaultClient(),
|
||||
internal val okHttpClient: OkHttpClient = defaultClient(),
|
||||
private val ownsHttpClient: Boolean = true,
|
||||
private val json: Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -1506,7 +1507,7 @@ class DashboardApiClient(
|
||||
val patched = buildJsonObject {
|
||||
obj.forEach { (k, v) -> put(k, v) }
|
||||
if (obj["name"] == null && !nameOverride.isNullOrBlank()) put("name", nameOverride)
|
||||
if (obj["model"] == null) put("model", "")
|
||||
if (obj["model"] == null || obj["model"] == JsonNull) put("model", "")
|
||||
}
|
||||
json.decodeFromJsonElement(Profile.serializer(), patched)
|
||||
}.getOrNull()
|
||||
@@ -1525,10 +1526,14 @@ class DashboardApiClient(
|
||||
password: String,
|
||||
next: String = "/",
|
||||
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
|
||||
// Match the Dashboard's single-line HTML username/password controls:
|
||||
// remove only forbidden line breaks and preserve every other code point.
|
||||
val normalizedUsername = username.replace("\r", "").replace("\n", "")
|
||||
val normalizedPassword = password.replace("\r", "").replace("\n", "")
|
||||
val payload = buildJsonObject {
|
||||
put("provider", provider)
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("username", normalizedUsername)
|
||||
put("password", normalizedPassword)
|
||||
put("next", next)
|
||||
}
|
||||
val httpUrl = resolveUrl("/auth/password-login")
|
||||
@@ -2152,9 +2157,10 @@ fun sameDashboardBase(candidate: String, trusted: String): Boolean {
|
||||
fun trustedDashboardBearerAuthOrNull(
|
||||
candidate: String,
|
||||
trusted: String,
|
||||
httpConsentOrigins: Set<String> = emptySet(),
|
||||
tokenStoreProvider: () -> NativeDashboardTokenStore,
|
||||
): DashboardBearerAuth? =
|
||||
if (isNativeDashboardTransportEligible(candidate) &&
|
||||
if (isNativeDashboardTransportEligible(candidate, httpConsentOrigins) &&
|
||||
sameDashboardBase(candidate, trusted)
|
||||
) {
|
||||
DashboardBearerAuth(candidate, tokenStoreProvider())
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
data class DashboardSetupVerification(
|
||||
val status: DashboardStatus,
|
||||
val session: DashboardAuthSession,
|
||||
val ticketAvailable: Boolean,
|
||||
) {
|
||||
val authenticated: Boolean get() = session.authenticated && ticketAvailable
|
||||
}
|
||||
|
||||
/** Public status is discovery, not proof that a phone can use protected Dashboard routes. */
|
||||
suspend fun DashboardApiClient.verifySetup(): DashboardSetupVerification {
|
||||
val status = getStatus().getOrThrow()
|
||||
var session = currentSession().getOrThrow()
|
||||
val ticketAvailable = if (session.authenticated) {
|
||||
val ticket = requestWsTicket()
|
||||
val failure = ticket.exceptionOrNull()
|
||||
if (failure?.isDashboardSignInRequiredFailure() == true) {
|
||||
session = session.copy(authenticated = false)
|
||||
false
|
||||
} else {
|
||||
ticket.getOrThrow()
|
||||
true
|
||||
}
|
||||
} else false
|
||||
if (!status.authRequired && !session.authenticated) {
|
||||
throw DashboardLocalAuthenticationRequiredException()
|
||||
}
|
||||
return DashboardSetupVerification(status, session, ticketAvailable)
|
||||
}
|
||||
|
||||
class DashboardLocalAuthenticationRequiredException : IOException(
|
||||
"Hermes is reachable, but protected requests are not authorized. If the Dashboard is forwarded " +
|
||||
"from loopback, check its bind address, authentication provider, and dashboard.public_url " +
|
||||
"on the host. A 401 alone does not identify the cause.",
|
||||
)
|
||||
@@ -277,7 +277,7 @@ class GatewayChatClient(
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val client: OkHttpClient = (okHttpClient ?: OkHttpClient())
|
||||
private fun socketClient(base: OkHttpClient): OkHttpClient = base
|
||||
.newBuilder()
|
||||
// The 10s default connectTimeout is LAN-tuned; a remote dashboard
|
||||
// reached over Tailscale (DERP cold start) can take longer to complete
|
||||
@@ -294,8 +294,19 @@ class GatewayChatClient(
|
||||
* being torn down — the in-flight turn's session is server-side and the
|
||||
* same shared gateway sits behind both routes.
|
||||
*/
|
||||
private data class RouteTransport(
|
||||
val dashboard: DashboardApiClient,
|
||||
val socket: OkHttpClient,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var dashboardClient: DashboardApiClient = initialDashboardClient
|
||||
private var routeTransport = RouteTransport(
|
||||
initialDashboardClient,
|
||||
socketClient(okHttpClient ?: initialDashboardClient.okHttpClient),
|
||||
)
|
||||
|
||||
private val dashboardClient: DashboardApiClient
|
||||
get() = routeTransport.dashboard
|
||||
|
||||
private val _connectionState = MutableStateFlow(GatewayConnectionState.Idle)
|
||||
val connectionState: StateFlow<GatewayConnectionState> = _connectionState.asStateFlow()
|
||||
@@ -1001,7 +1012,7 @@ class GatewayChatClient(
|
||||
fun retarget(newDashboardClient: DashboardApiClient) {
|
||||
if (dashboardClient === newDashboardClient) return
|
||||
Log.i(TAG, "Gateway retargeting to a new route (turn active=${hasActiveTurn()})")
|
||||
dashboardClient = newDashboardClient
|
||||
routeTransport = RouteTransport(newDashboardClient, socketClient(newDashboardClient.okHttpClient))
|
||||
if (hasActiveTurn()) {
|
||||
retargetedThisTurn = activeTurn?.ended == false
|
||||
webSocket?.cancel()
|
||||
@@ -1586,6 +1597,7 @@ class GatewayChatClient(
|
||||
claimedBackground?.pendingAsk?.let { ask ->
|
||||
boundTurn.restoreInteraction(ask)
|
||||
}
|
||||
boundTurn.restorePendingClarify(response)
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
@@ -1746,17 +1758,39 @@ class GatewayChatClient(
|
||||
)
|
||||
|
||||
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
|
||||
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> {
|
||||
suspend fun respondClarify(
|
||||
requestId: String,
|
||||
answer: String,
|
||||
questionId: String? = null,
|
||||
): Result<GatewayAskResponse> {
|
||||
val respondingTurn = activeTurn
|
||||
if (questionId != null) {
|
||||
val ask = respondingTurn?.pendingInteraction
|
||||
// A lost acknowledgement is ambiguous until activation replays server progress.
|
||||
// Never overwrite an accepted answer while reconnect is still reconciling it.
|
||||
if (rejoinInProgress || ask?.kind != GatewayAsk.Kind.CLARIFY || ask.requestId != requestId ||
|
||||
ask.questions.none { it.qid == questionId } || ask.ownershipToken.retired.get() ||
|
||||
questionId in ask.ownershipToken.answers.get()
|
||||
) return Result.failure(GatewayRpcException("Clarification is not ready for this question"))
|
||||
}
|
||||
val generation = respondingTurn?.interactionGeneration
|
||||
val ownershipToken = respondingTurn?.pendingInteraction?.ownershipToken
|
||||
return rpc(
|
||||
"clarify.respond",
|
||||
buildJsonObject {
|
||||
put("request_id", requestId)
|
||||
put("answer", answer)
|
||||
questionId?.let { put("question_id", it) }
|
||||
},
|
||||
).map {
|
||||
it.gatewayAskResponse().also {
|
||||
respondingTurn?.acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
|
||||
if (generation != null) {
|
||||
respondingTurn.acknowledgeClarify(requestId, questionId, answer, it == GatewayAskResponse.EXPIRED, generation)
|
||||
}
|
||||
val currentTurn = activeTurn
|
||||
if (ownershipToken != null && currentTurn !== respondingTurn) {
|
||||
currentTurn?.acknowledgeClarifyOwner(requestId, questionId, answer, it == GatewayAskResponse.EXPIRED, ownershipToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1913,6 +1947,7 @@ class GatewayChatClient(
|
||||
model = row.stringField("model").orEmpty(),
|
||||
provider = row.stringField("provider").orEmpty(),
|
||||
description = row.stringField("description").orEmpty(),
|
||||
displayName = row.stringField("display_name").orEmpty(),
|
||||
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
@@ -3056,12 +3091,14 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
private suspend fun connectOnce() {
|
||||
// Ticket, URL and TLS/auth policy must belong to one route snapshot.
|
||||
val transport = routeTransport
|
||||
val connectStart = System.nanoTime()
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||
val ticket = transport.dashboard.requestWsTicket().getOrElse { e ->
|
||||
val statusCode = (e as? DashboardHttpException)?.statusCode
|
||||
val authFailure = statusCode in setOf(401, 403)
|
||||
val rateLimited = statusCode == 429
|
||||
@@ -3084,8 +3121,15 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
val ticketMs = (System.nanoTime() - connectStart) / 1_000_000
|
||||
if (transport !== routeTransport) {
|
||||
throw GatewayConnectAttemptException(
|
||||
"Gateway route changed while minting a ticket",
|
||||
GatewayConnectFailureStage.Ticket,
|
||||
retryable = true,
|
||||
)
|
||||
}
|
||||
val socketProfile = currentSessionProfile()
|
||||
val url = dashboardClient.gatewayWebSocketUrl(
|
||||
val url = transport.dashboard.gatewayWebSocketUrl(
|
||||
ticket = ticket.ticket,
|
||||
profile = socketProfile,
|
||||
)
|
||||
@@ -3098,7 +3142,7 @@ class GatewayChatClient(
|
||||
_connectionState.value = GatewayConnectionState.Connecting
|
||||
val ready = CompletableDeferred<Unit>()
|
||||
readySignal = ready
|
||||
val socket = client.newWebSocket(
|
||||
val socket = transport.socket.newWebSocket(
|
||||
Request.Builder().url(url).build(),
|
||||
createListener(ready),
|
||||
)
|
||||
@@ -3853,7 +3897,10 @@ class GatewayChatClient(
|
||||
val interactionRequest = GatewayEventMapper.interactionRequest(type, payload)
|
||||
if (interactionRequest != null) {
|
||||
val previous = backgroundTurn.pendingAsk
|
||||
backgroundTurn.pendingAsk = interactionRequest
|
||||
backgroundTurn.pendingAsk = if (previous?.kind == interactionRequest.kind &&
|
||||
previous.requestId == interactionRequest.requestId && interactionRequest.questions.isNotEmpty()
|
||||
) interactionRequest.withAnswers(previous.answers + interactionRequest.answers, previous)
|
||||
else interactionRequest
|
||||
if (previous?.kind != interactionRequest.kind ||
|
||||
previous.requestId != interactionRequest.requestId
|
||||
) {
|
||||
@@ -3880,6 +3927,7 @@ class GatewayChatClient(
|
||||
// be replayed or buffered. Only an authoritative expiry retires a
|
||||
// detached ask; an explicit response is retired by its foreground VM.
|
||||
if (explicitlyExpired) {
|
||||
pendingAsk.ownershipToken.retired.set(true)
|
||||
backgroundTurn.pendingAsk = null
|
||||
callbackDispatcher {
|
||||
backgroundInteractionListener?.invoke(
|
||||
@@ -3997,6 +4045,9 @@ class GatewayChatClient(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (type == "session.info" && eventSessionId != null && eventSessionId == liveSessionId) {
|
||||
payload?.let(turn::restorePendingClarify)
|
||||
}
|
||||
turn.onEvent(type, payload)
|
||||
if (turn.ended) {
|
||||
if (activeTurn === turn) activeTurn = null
|
||||
@@ -4203,6 +4254,7 @@ class GatewayChatClient(
|
||||
activated.isSuccess -> {
|
||||
activated.getOrNull()?.let { result ->
|
||||
applySessionResultInfo(result)
|
||||
turn.restorePendingClarify(result)
|
||||
turn.settleFromAuthoritativeSessionState(
|
||||
running = result.booleanField("running"),
|
||||
source = "session.activate",
|
||||
@@ -4505,6 +4557,19 @@ class GatewayChatClient(
|
||||
fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
|
||||
mapper.acknowledgeInteraction(expiry)
|
||||
}
|
||||
val interactionGeneration: Long get() = mapper.interactionGeneration
|
||||
fun acknowledgeClarify(requestId: String, questionId: String?, answer: String, expired: Boolean, generation: Long) {
|
||||
mapper.acknowledgeClarify(requestId, questionId, answer, expired, generation)
|
||||
}
|
||||
fun acknowledgeClarifyOwner(requestId: String, questionId: String?, answer: String, expired: Boolean, owner: GatewayAskOwnership) {
|
||||
mapper.acknowledgeClarifyOwner(requestId, questionId, answer, expired, owner)
|
||||
}
|
||||
fun restorePendingClarify(snapshot: JsonObject) {
|
||||
val payload = snapshot["pending_clarify"] as? JsonObject
|
||||
?: (snapshot["info"] as? JsonObject)?.get("pending_clarify") as? JsonObject
|
||||
?: return
|
||||
GatewayEventMapper.interactionRequest("clarify.request", payload)?.let(mapper::restoreInteraction)
|
||||
}
|
||||
private val deferredEventLock = Any()
|
||||
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
|
||||
private var eventsDeferred = deferEvents
|
||||
@@ -5268,6 +5333,7 @@ private fun parseBotRosterEntry(row: JsonObject): BotRosterEntry? {
|
||||
model = row.stringField("model").orEmpty(),
|
||||
provider = row.stringField("provider").orEmpty(),
|
||||
description = row.stringField("description")?.take(512).orEmpty(),
|
||||
displayName = row.stringField("display_name").orEmpty(),
|
||||
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
|
||||
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
|
||||
+91
-26
@@ -32,19 +32,66 @@ class GatewayEventMapper(
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
internal val currentInteraction: GatewayAsk?
|
||||
get() = pendingInteraction
|
||||
@Volatile internal var interactionGeneration: Long = 0
|
||||
private set
|
||||
|
||||
internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
|
||||
pendingInteraction = ask
|
||||
if (!duplicate) callbacks.onInteractionRequest(ask)
|
||||
@Synchronized internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val previous = pendingInteraction
|
||||
val duplicate = previous?.sameRequestAs(ask) == true
|
||||
if (!duplicate) interactionGeneration++
|
||||
val merged = if (duplicate && ask.questions.isNotEmpty()) {
|
||||
ask.withAnswers(previous.answers + ask.answers, previous)
|
||||
} else if (ask.questions.isNotEmpty()) ask.withAnswers(emptyMap()) else ask
|
||||
if (merged.ownershipToken.retired.get() && !merged.clarifyComplete) {
|
||||
if (duplicate) pendingInteraction = null
|
||||
callbacks.onInteractionExpired(GatewayAskExpiry(merged.kind, merged.requestId))
|
||||
if (pendingInteraction == null) drainDeferredTerminalEvent()
|
||||
return
|
||||
}
|
||||
pendingInteraction = merged
|
||||
if (!duplicate || previous != merged) callbacks.onInteractionRequest(merged)
|
||||
if (merged.clarifyComplete) {
|
||||
pendingInteraction = null
|
||||
drainDeferredTerminalEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized internal fun acknowledgeClarify(
|
||||
requestId: String,
|
||||
questionId: String?,
|
||||
answer: String,
|
||||
expired: Boolean,
|
||||
generation: Long = interactionGeneration,
|
||||
) {
|
||||
if (generation != interactionGeneration) return
|
||||
val pending = pendingInteraction ?: return
|
||||
if (pending.kind != GatewayAsk.Kind.CLARIFY || pending.requestId != requestId) return
|
||||
if (!expired && questionId != null && pending.questions.none { it.qid == questionId }) return
|
||||
if (!expired && questionId != null && pending.questions.any { it.qid == questionId }) {
|
||||
val updated = pending.withAnswers(pending.answers + (questionId to answer))
|
||||
if (updated.questions.any { it.qid !in updated.answers }) {
|
||||
pendingInteraction = updated
|
||||
return
|
||||
}
|
||||
}
|
||||
acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
|
||||
}
|
||||
|
||||
@Synchronized internal fun acknowledgeClarifyOwner(
|
||||
requestId: String, questionId: String?, answer: String, expired: Boolean, owner: GatewayAskOwnership,
|
||||
) {
|
||||
if (pendingInteraction?.ownershipToken !== owner) return
|
||||
acknowledgeClarify(requestId, questionId, answer, expired)
|
||||
}
|
||||
|
||||
/** Retire only the ask whose explicit respond RPC reached server truth. */
|
||||
internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
|
||||
@Synchronized internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
|
||||
val pending = pendingInteraction ?: return
|
||||
if (pending.matches(expiry)) {
|
||||
pending.ownershipToken.retired.set(true)
|
||||
pendingInteraction = null
|
||||
drainDeferredTerminalEvent()
|
||||
}
|
||||
@@ -77,7 +124,7 @@ class GatewayEventMapper(
|
||||
*/
|
||||
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
@Synchronized fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
|
||||
interactionRequest(type, payload)?.let { ask ->
|
||||
@@ -88,6 +135,7 @@ class GatewayEventMapper(
|
||||
interactionExpiry(type, payload)?.let { expiry ->
|
||||
val pending = pendingInteraction
|
||||
if (pending != null && pending.matches(expiry)) {
|
||||
pending.ownershipToken.retired.set(true)
|
||||
pendingInteraction = null
|
||||
}
|
||||
callbacks.onInteractionExpired(expiry)
|
||||
@@ -503,26 +551,7 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> {
|
||||
val choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.distinct()
|
||||
?.take(MAX_CLARIFY_CHOICES)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = choices,
|
||||
multiSelect = payload.boolean("multi_select") == true && choices != null,
|
||||
// Current upstream owns expiry through clarify.expire and
|
||||
// does not advertise its configurable deadline. Never
|
||||
// invent a local deadline; consume future additive
|
||||
// metadata only when it is present and positive.
|
||||
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
|
||||
)
|
||||
}
|
||||
"clarify.request" -> clarifyRequest(payload)
|
||||
|
||||
"approval.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
@@ -555,6 +584,41 @@ class GatewayEventMapper(
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun clarifyRequest(payload: JsonObject?): GatewayAsk? {
|
||||
val rawQuestions = payload?.get("questions")
|
||||
if (rawQuestions != null && rawQuestions !is JsonArray) return null
|
||||
val questions = rawQuestions?.map { value ->
|
||||
val row = value as? JsonObject ?: return null
|
||||
val qid = (row["qid"] as? JsonPrimitive)?.takeIf { it.isString }
|
||||
?.contentOrNull?.takeIf(String::isNotBlank) ?: return null
|
||||
val text = row.string("question")?.takeIf(String::isNotBlank) ?: return null
|
||||
val choices = (row["choices"] as? JsonArray)?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.takeIf { option -> option.isString }
|
||||
?.contentOrNull?.takeIf(String::isNotBlank)
|
||||
}.orEmpty().take(MAX_CLARIFY_CHOICES)
|
||||
GatewayClarifyQuestion(qid, text, choices, row.boolean("multi_select") == true && choices.isNotEmpty())
|
||||
}.orEmpty()
|
||||
if (questions.size > MAX_CLARIFY_QUESTIONS || questions.map { it.qid }.distinct().size != questions.size) return null
|
||||
val choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
|
||||
?.filter(String::isNotEmpty)?.distinct()?.take(MAX_CLARIFY_CHOICES)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
return GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = choices,
|
||||
multiSelect = payload.boolean("multi_select") == true && choices != null,
|
||||
// Upstream owns its configurable deadline; only consume advertised metadata.
|
||||
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
|
||||
questions = questions,
|
||||
answers = (payload?.get("answers") as? JsonObject)?.mapNotNull { (qid, value) ->
|
||||
(value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
|
||||
?.takeIf { questions.any { q -> q.qid == qid } }?.let { qid to it }
|
||||
}?.toMap().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
|
||||
"clarify.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
@@ -634,6 +698,7 @@ class GatewayEventMapper(
|
||||
// Upstream clarify tool accepts at most four choices. Sudo/secret retain fixed
|
||||
// `_block()` timeouts; clarify is configurable and expires authoritatively.
|
||||
private const val MAX_CLARIFY_CHOICES = 4
|
||||
private const val MAX_CLARIFY_QUESTIONS = 5
|
||||
private const val SUDO_TIMEOUT_SECONDS = 120
|
||||
private const val SECRET_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
+135
-63
@@ -9,18 +9,23 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.setGatewayKeepAlive
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Foreground service that holds the app process up so work the user already
|
||||
@@ -48,16 +53,16 @@ import kotlinx.coroutines.launch
|
||||
*
|
||||
* 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 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.
|
||||
* idle-close timer while retention is required. Task removal releases local
|
||||
* foreground protection; it does not terminate server-owned work or assume
|
||||
* that removing a task kills the application process.
|
||||
*
|
||||
* # Android 15 watchdog
|
||||
* # Foreground-start obligation (Android 8+)
|
||||
*
|
||||
* On target SDK 35 any intent to a service that declares a foregroundServiceType
|
||||
* must call `startForeground` within 5s — so [onStartCommand] always does that
|
||||
* first, before branching on the action. Shutdown goes through [stop]
|
||||
* (`stopService`) to bypass [onStartCommand] entirely.
|
||||
* An accepted startForegroundService must promote promptly, even if demand
|
||||
* disappears before delivery. Never stopService a pending start: Android 12
|
||||
* also treats teardown before promotion as a foreground-start failure.
|
||||
* Main-thread demand is coalesced until onStartCommand acknowledges the start.
|
||||
*/
|
||||
class GatewayKeepAliveService : Service() {
|
||||
companion object {
|
||||
@@ -67,47 +72,76 @@ class GatewayKeepAliveService : Service() {
|
||||
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
|
||||
private const val EXTRA_START_TOKEN = "start_token"
|
||||
private var runningInstance: GatewayKeepAliveService? = null
|
||||
private var pendingStart: String? = null
|
||||
private var desiredPersistent = false
|
||||
private var desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
|
||||
private var wasForeground = false
|
||||
private var taskRemoved = false
|
||||
@Volatile private var persistentToken: String? = null
|
||||
// Preference writes must survive service teardown, but never process death.
|
||||
private val preferenceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
@MainThread
|
||||
fun update(
|
||||
context: Context,
|
||||
persistent: Boolean,
|
||||
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
appForeground: Boolean = true,
|
||||
) {
|
||||
if (!persistent && !activeTurns.required) {
|
||||
stop(context)
|
||||
return
|
||||
checkMainThread()
|
||||
if (appForeground && !wasForeground) taskRemoved = false
|
||||
wasForeground = appForeground
|
||||
if (persistent != desiredPersistent) {
|
||||
persistentToken = if (persistent) UUID.randomUUID().toString() else null
|
||||
}
|
||||
runningInstance?.let { service ->
|
||||
service.applyState(persistent, activeTurns)
|
||||
service.startForegroundNotification()
|
||||
desiredPersistent = persistent
|
||||
desiredTurns = activeTurns
|
||||
// Keep the accepted start alive until Android delivers its command.
|
||||
if (pendingStart != null) return
|
||||
runningInstance?.let {
|
||||
it.reconcile()
|
||||
return
|
||||
}
|
||||
if (taskRemoved || !appForeground || (!persistent && !activeTurns.required)) return
|
||||
val token = UUID.randomUUID().toString()
|
||||
pendingStart = token
|
||||
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 {
|
||||
context.applicationContext.startService(intent)
|
||||
.putExtra(EXTRA_START_TOKEN, token)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pendingStart = null
|
||||
Log.w(TAG, "Foreground service launch rejected; retaining server-owned work", e)
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun stop(context: Context) {
|
||||
// stopService() bypasses onStartCommand, so a "please shut down"
|
||||
// never trips the Android 15 foreground-start watchdog.
|
||||
context.applicationContext.stopService(
|
||||
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
|
||||
)
|
||||
update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(), wasForeground)
|
||||
}
|
||||
|
||||
private fun checkMainThread() {
|
||||
check(Looper.myLooper() == Looper.getMainLooper())
|
||||
}
|
||||
|
||||
internal fun resetForTest() {
|
||||
runningInstance = null
|
||||
pendingStart = null
|
||||
desiredPersistent = false
|
||||
desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
|
||||
persistentToken = null
|
||||
wasForeground = false
|
||||
taskRemoved = false
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var persistent = false
|
||||
private var activeTurns = 0
|
||||
private var waitingSessions = 0
|
||||
@@ -116,45 +150,63 @@ class GatewayKeepAliveService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runningInstance = this
|
||||
// No datastore, socket, coroutine or other owner work ahead of promotion.
|
||||
// A cold stale notification action has no accepted foreground start.
|
||||
if (pendingStart != null) {
|
||||
applyState(desiredPersistent, desiredTurns)
|
||||
startForegroundNotification()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val token = intent?.getStringExtra(EXTRA_START_TOKEN)
|
||||
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 disabled continuous background connection")
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
persistent = false
|
||||
if (activeTurns == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
startForegroundNotification()
|
||||
applyState(desiredPersistent, desiredTurns)
|
||||
// Also promote reused service instances before acknowledging the start.
|
||||
// A delivered start still owes promotion if process-local demand
|
||||
// was lost or its token is stale. Never replay its old demand.
|
||||
if (startForegroundNotification()) {
|
||||
if (token == pendingStart) pendingStart = null
|
||||
if (pendingStart == null) {
|
||||
runningInstance = this
|
||||
reconcile()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
} else if (intent?.action == ACTION_STOP &&
|
||||
intent.data?.lastPathSegment == persistentToken && persistentToken != null
|
||||
) {
|
||||
val actionToken = persistentToken
|
||||
val context = applicationContext
|
||||
preferenceScope.launch {
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
// Recheck inside the serialized edit; an old action must
|
||||
// not undo a subsequent disable/re-enable cycle.
|
||||
if (persistentToken == actionToken) preferences[KEY_GATEWAY_KEEP_ALIVE] = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not disable persistent connection", e)
|
||||
}
|
||||
}
|
||||
// The preference collector reconciles current active-turn demand
|
||||
// after persistence. Never stop from the notification's old snapshot.
|
||||
}
|
||||
if (runningInstance !== this && pendingStart == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
// 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()
|
||||
taskRemoved = true
|
||||
// Leases belong to chat owners. Keep them intact so a surviving
|
||||
// process can protect unfinished turns again when the user returns.
|
||||
// A queued new start still owes Android promotion before retirement.
|
||||
if (pendingStart == null) retire()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (runningInstance === this) runningInstance = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -164,7 +216,23 @@ class GatewayKeepAliveService : Service() {
|
||||
// this foreground service (and its Gateway socket) alive. Re-post the
|
||||
// existing notification so its localized title/body follow the new
|
||||
// application resources without restarting either owner.
|
||||
startForegroundNotification()
|
||||
if (runningInstance === this) reconcile()
|
||||
}
|
||||
|
||||
private fun reconcile() {
|
||||
if (taskRemoved || (!desiredPersistent && !desiredTurns.required)) {
|
||||
retire()
|
||||
} else {
|
||||
applyState(desiredPersistent, desiredTurns)
|
||||
startForegroundNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private fun retire() {
|
||||
if (runningInstance === this) runningInstance = null
|
||||
// Let Android remove the foreground notification with service teardown.
|
||||
// Do not demote an instance while another start may be queued for it.
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
@@ -181,10 +249,10 @@ class GatewayKeepAliveService : Service() {
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
// check is finicky about correlating the runtime type arg with the manifest.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
private fun startForegroundNotification(): Boolean {
|
||||
try {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
@@ -194,9 +262,12 @@ class GatewayKeepAliveService : Service() {
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
|
||||
stopSelf()
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Foreground notification failed; retaining server-owned work", e)
|
||||
pendingStart = null
|
||||
retire()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +279,7 @@ class GatewayKeepAliveService : Service() {
|
||||
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
|
||||
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
.setData(Uri.parse("hermes-relay://keep-alive/$persistentToken"))
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
val (title, body) = when {
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Shared types for the Gateway chat transport — upstream hermes-agent's
|
||||
@@ -230,10 +233,35 @@ data class GatewayAsk(
|
||||
* authoritative `*.expire` event still retires the interaction.
|
||||
*/
|
||||
val timeoutSeconds: Int,
|
||||
val questions: List<GatewayClarifyQuestion> = emptyList(),
|
||||
val answers: Map<String, String> = emptyMap(),
|
||||
) {
|
||||
enum class Kind { CLARIFY, APPROVAL, SUDO, SECRET }
|
||||
val clarifyComplete: Boolean get() = questions.isNotEmpty() && questions.all { it.qid in answers }
|
||||
|
||||
/** In-memory request incarnation shared when a live ask moves between turn mappers. */
|
||||
internal var ownershipToken = GatewayAskOwnership(answers)
|
||||
private set
|
||||
|
||||
internal fun withAnswers(answers: Map<String, String>, owner: GatewayAsk = this): GatewayAsk =
|
||||
copy(answers = owner.ownershipToken.answers.updateAndGet { it + answers })
|
||||
.also { it.ownershipToken = owner.ownershipToken }
|
||||
}
|
||||
|
||||
/** A detached/reclaimed mapper shares confirmed progress with an RPC still owned by its predecessor. */
|
||||
internal class GatewayAskOwnership(answers: Map<String, String>) {
|
||||
val answers = AtomicReference(answers.toMap())
|
||||
val retired = AtomicBoolean(false)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class GatewayClarifyQuestion(
|
||||
val qid: String,
|
||||
val question: String,
|
||||
val choices: List<String> = emptyList(),
|
||||
val multiSelect: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Server-side expiry of one blocking gateway interaction. Sudo/secret asks
|
||||
* correlate by [requestId]; approvals remain session-scoped and therefore
|
||||
|
||||
+17
-2
@@ -111,6 +111,8 @@ class NativeDashboardAuthClient(
|
||||
private val tokenStore: NativeDashboardTokenStore,
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.dns(RetryingNativeAuthDns())
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.retryOnConnectionFailure(false)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
@@ -390,15 +392,28 @@ internal class NativeDashboardCallbackException(
|
||||
val retryable: Boolean = true,
|
||||
) : IOException(message)
|
||||
|
||||
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
|
||||
internal fun isNativeDashboardTransportEligible(
|
||||
baseUrl: String,
|
||||
httpConsentOrigins: Set<String> = emptySet(),
|
||||
): Boolean {
|
||||
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
|
||||
return url.scheme == "https" ||
|
||||
(
|
||||
url.scheme == "http" &&
|
||||
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
|
||||
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host) ||
|
||||
com.hermesandroid.relay.data.dashboardHttpConsentMatches(baseUrl, httpConsentOrigins))
|
||||
)
|
||||
}
|
||||
|
||||
/** A cleartext exception never authorizes following a request onto another origin. */
|
||||
internal fun dashboardClientWithHttpConsent(
|
||||
client: OkHttpClient,
|
||||
baseUrl: String,
|
||||
httpConsentOrigins: Set<String>,
|
||||
): OkHttpClient = if (com.hermesandroid.relay.data.dashboardHttpConsentMatches(baseUrl, httpConsentOrigins)) {
|
||||
client.newBuilder().followRedirects(false).followSslRedirects(false).build()
|
||||
} else client
|
||||
|
||||
/**
|
||||
* Hermes already permits explicitly configured HTTP dashboard sessions on
|
||||
* local routes. The brokered flow is no less protected than that cookie flow,
|
||||
|
||||
@@ -106,6 +106,9 @@ internal class HermesRuntimeBinder(
|
||||
},
|
||||
apiBearerTokenProvider = connection::getApiKey,
|
||||
dashboardHttpClientProvider = connection::dashboardHttpClientForRelayIngress,
|
||||
pluginProxyHttpClientProvider = { url ->
|
||||
connection.pluginProxyClientForUrl(url, includeRelaySessionHeader = false)
|
||||
},
|
||||
dashboardIngressWebSocketRequestProvider = connection::dashboardRelayRequestForIngress,
|
||||
)
|
||||
val standardVoiceClient = StandardHermesVoiceClient(
|
||||
@@ -327,7 +330,8 @@ internal class HermesRuntimeBinder(
|
||||
connection.serverCapabilities,
|
||||
connection.gatewayAvailability,
|
||||
connection.effectiveDashboardUrl,
|
||||
) { preference, _, gateway, dashboardUrl ->
|
||||
connection.activeConnection,
|
||||
) { preference, _, gateway, dashboardUrl, _ ->
|
||||
Triple(preference, gateway, dashboardUrl)
|
||||
}.collectLatest { (preference, _, dashboardUrl) ->
|
||||
if (
|
||||
@@ -351,14 +355,18 @@ internal class HermesRuntimeBinder(
|
||||
connection.activeConnectionId,
|
||||
connection.effectiveSessionProfileName,
|
||||
connection.lastSessionId,
|
||||
connection.activeEndpoint,
|
||||
) { ready, connectionId, profileName, sessionId, activeEndpoint ->
|
||||
// The session directory belongs to the standard Dashboard
|
||||
// route. connection.activeEndpoint is the optional Relay
|
||||
// socket's selected candidate and stays null on a valid
|
||||
// Dashboard-only LAN connection.
|
||||
connection.effectiveDashboardUrl,
|
||||
) { ready, connectionId, profileName, sessionId, dashboardUrl ->
|
||||
ProfileContextInputs(
|
||||
ready,
|
||||
connectionId,
|
||||
profileName,
|
||||
sessionId,
|
||||
dashboardRouteResolved = activeEndpoint != null,
|
||||
dashboardUrl = dashboardUrl,
|
||||
)
|
||||
}
|
||||
combine(
|
||||
@@ -374,7 +382,7 @@ internal class HermesRuntimeBinder(
|
||||
)
|
||||
}.collectLatest { inputs ->
|
||||
profileContextReady.value = false
|
||||
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardRouteResolved)) {
|
||||
if (!shouldRefreshSessionDirectory(inputs.chatReady, inputs.dashboardUrl)) {
|
||||
return@collectLatest
|
||||
}
|
||||
if (!inputs.profileSelectionSettled) {
|
||||
@@ -600,7 +608,7 @@ internal class HermesRuntimeBinder(
|
||||
val connectionId: String?,
|
||||
val profileName: String?,
|
||||
val sessionId: String?,
|
||||
val dashboardRouteResolved: Boolean,
|
||||
val dashboardUrl: String,
|
||||
val profileSelectionSettled: Boolean = false,
|
||||
val profileLocked: Boolean = false,
|
||||
val hiddenSources: Set<String> = emptySet(),
|
||||
@@ -632,12 +640,13 @@ internal class HermesRuntimeBinder(
|
||||
/**
|
||||
* Session browsing is Dashboard HTTP state, not Gateway-socket state. API-only
|
||||
* connections still use chat readiness; Dashboard connections can refresh once
|
||||
* the resolver has selected a live route, after the profile-settle fence.
|
||||
* their persisted/resolved Dashboard origin publishes, after the profile-settle
|
||||
* fence. The optional Relay endpoint is deliberately not part of this decision.
|
||||
*/
|
||||
internal fun shouldRefreshSessionDirectory(
|
||||
chatReady: Boolean,
|
||||
dashboardRouteResolved: Boolean,
|
||||
): Boolean = chatReady || dashboardRouteResolved
|
||||
dashboardUrl: String,
|
||||
): Boolean = chatReady || dashboardUrl.isNotBlank()
|
||||
|
||||
internal fun assistantCanTransmitScreenContext(engineMode: VoiceEngineMode): Boolean =
|
||||
engineMode == VoiceEngineMode.HermesVoiceOutput
|
||||
|
||||
@@ -1095,14 +1095,16 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe theme preference
|
||||
val themePreference by connectionViewModel.theme.collectAsState()
|
||||
val appThemeId by connectionViewModel.appTheme.collectAsState()
|
||||
val fontScale by connectionViewModel.fontScale.collectAsState()
|
||||
val appFontId by connectionViewModel.appFont.collectAsState()
|
||||
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
|
||||
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
|
||||
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
|
||||
// The same decoded emission that releases splash readiness owns the first
|
||||
// real frame; individual settings flows can hydrate independently later.
|
||||
val appearance by connectionViewModel.appearance.collectAsState()
|
||||
val themePreference = appearance.themePreference
|
||||
val appThemeId = appearance.appThemeId
|
||||
val fontScale = appearance.fontScale
|
||||
val appFontId = appearance.appFontId
|
||||
val appearanceAccent = appearance.accentHex
|
||||
val appearanceShape = appearance.shapeId
|
||||
val activeCustomTheme = appearance.customTheme
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
@@ -1964,7 +1966,7 @@ fun RelayApp() {
|
||||
// (WhatsApp-style; see ChatScreen). That's the "can I talk to the
|
||||
// agent?" signal.
|
||||
// • Relay socket (bridge/terminal/relay-voice) → the bottom
|
||||
// RelayStatusStrip's "Reconnecting…" cue only. It never blocks chat,
|
||||
// RelayStatusStrip is reserved for the active chat owner's status.
|
||||
// so it stays ambient. (`connectionReconnecting` below.)
|
||||
// A routine in-progress reconnect surfaces only in the bottom strip.
|
||||
// Computed off the raw status (not the dismiss-gated `toast`) because the
|
||||
|
||||
+10
-3
@@ -1198,12 +1198,18 @@ fun ActiveCardSecurityPosture(
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
val currentPairedSession by connectionViewModel.currentPairedSession.collectAsState()
|
||||
val pairedDevices by connectionViewModel.pairedDevices.collectAsState()
|
||||
// Live dashboard path (Secure Link / preferred route), not only the saved
|
||||
// plain :9119 configuredDashboardUrl that pairing still stores alongside.
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val dashboardDisplayUrl = effectiveDashboardUrl.trim().trimEnd('/').ifBlank {
|
||||
activeConnection?.resolvedDashboardUrl.orEmpty()
|
||||
}
|
||||
|
||||
val dashboardStatus = activeConnection?.dashboardLastStatus
|
||||
val dashboardSignInRequired = dashboardStatus?.authRequired == true &&
|
||||
dashboardStatus.authenticated != true
|
||||
val dashboardValue = when {
|
||||
activeConnection?.resolvedDashboardUrl.isNullOrBlank() ->
|
||||
dashboardDisplayUrl.isBlank() ->
|
||||
stringResource(R.string.active_section_not_configured)
|
||||
dashboardStatus == null -> stringResource(R.string.active_section_not_checked)
|
||||
!dashboardStatus.reachable -> stringResource(R.string.active_section_unreachable)
|
||||
@@ -1255,7 +1261,7 @@ fun ActiveCardSecurityPosture(
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = activeConnection?.resolvedDashboardUrl.orEmpty().ifBlank {
|
||||
text = dashboardDisplayUrl.ifBlank {
|
||||
stringResource(R.string.active_section_not_configured)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -2031,11 +2037,12 @@ fun ActiveCardRoutesSection(
|
||||
original = routeEditorOriginal,
|
||||
relayEnabled = connection.relayUrl.isNotBlank() ||
|
||||
endpoints.any { it.relay != null },
|
||||
onSave = { role, dashboardUrl, onResult ->
|
||||
onSave = { role, dashboardUrl, httpConsentOrigin, onResult ->
|
||||
connectionViewModel.saveExtraRoute(
|
||||
role = role,
|
||||
dashboardUrl = dashboardUrl,
|
||||
original = routeEditorOriginal,
|
||||
httpConsentOrigin = httpConsentOrigin,
|
||||
onResult = onResult,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyBatch
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** One mobile question surface; confirmed qids advance without resubmitting earlier answers. */
|
||||
@Composable
|
||||
internal fun ClarifyBatchContent(
|
||||
batch: HermesCardClarifyBatch,
|
||||
expired: Boolean,
|
||||
onInputSubmit: (String, String) -> Unit,
|
||||
) {
|
||||
val answered = batch.questions.filter { it.answer != null }
|
||||
val activeIndex = batch.questions.indexOfFirst { it.answer == null }
|
||||
val active = batch.questions.getOrNull(activeIndex)
|
||||
var showAnswers by rememberSaveable { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(active?.key, expired) { focusManager.clearFocus() }
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = when {
|
||||
expired -> stringResource(R.string.clarify_batch_expired)
|
||||
active == null -> stringResource(R.string.clarify_batch_complete)
|
||||
else -> stringResource(R.string.clarify_batch_progress, activeIndex + 1, batch.questions.size)
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
)
|
||||
if (batch.questions.size > 1) {
|
||||
LinearProgressIndicator(
|
||||
progress = { answered.size.toFloat() / batch.questions.size },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (active != null && !expired) {
|
||||
key(active.key) {
|
||||
Text(
|
||||
active.question,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
CardInputSlot(
|
||||
input = active.input,
|
||||
onSubmit = { if (!active.submitting) onInputSubmit(active.key, it) },
|
||||
enabled = !active.submitting,
|
||||
stackedChoices = true,
|
||||
)
|
||||
if (active.submitting) {
|
||||
Text(stringResource(R.string.clarify_batch_sending), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (answered.isNotEmpty()) {
|
||||
if (active == null || expired) {
|
||||
Text(stringResource(R.string.clarify_batch_answered, answered.size), style = MaterialTheme.typography.labelLarge)
|
||||
} else {
|
||||
TextButton(onClick = { showAnswers = !showAnswers }) {
|
||||
Text(stringResource(R.string.clarify_batch_answered, answered.size))
|
||||
}
|
||||
}
|
||||
if (showAnswers || active == null || expired) {
|
||||
answered.forEach { question ->
|
||||
Text(question.question, style = MaterialTheme.typography.labelLarge)
|
||||
val answer = if (question.input.multiSelect) {
|
||||
runCatching { Json.decodeFromString<List<String>>(question.answer.orEmpty()).joinToString(", ") }
|
||||
.getOrDefault(question.answer.orEmpty())
|
||||
} else question.answer.orEmpty()
|
||||
Text(answer, style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3625,7 +3625,7 @@ internal fun ProfileDisplayManagerDialog(
|
||||
val isServerDefault = key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
val profile = profiles.firstOrNull { it.name == key }
|
||||
val label = if (isServerDefault) {
|
||||
stringResource(R.string.conn_info_server_default)
|
||||
stringResource(R.string.profile_follow_server_default)
|
||||
} else {
|
||||
profile?.let(AgentDisplay::profileDisplayName)
|
||||
?: key.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -249,9 +251,10 @@ fun ConnectionWizard(
|
||||
var nearbyBusy by remember { mutableStateOf(false) }
|
||||
var nearbyResults by remember { mutableStateOf<List<HermesLanDiscoveryResult>>(emptyList()) }
|
||||
var nearbyMessage by remember { mutableStateOf<String?>(null) }
|
||||
var dashboardAddress by rememberSaveable(wizardDraftIdentity, currentDashboardUrl) {
|
||||
mutableStateOf(currentDashboardUrl)
|
||||
var dashboardAddress by rememberSaveable(wizardDraftIdentity) {
|
||||
mutableStateOf(if (connectionDraftId != null) "" else currentDashboardUrl)
|
||||
}
|
||||
var dashboardHttpConsentOrigin by rememberSaveable(wizardDraftIdentity) { mutableStateOf<String?>(null) }
|
||||
var dashboardProbeBusy by remember { mutableStateOf(false) }
|
||||
var dashboardProbeError by remember { mutableStateOf<String?>(null) }
|
||||
var dashboardProbeResult by remember {
|
||||
@@ -425,16 +428,24 @@ fun ConnectionWizard(
|
||||
val inspectDashboard: (String, String?) -> Unit = { address, suggestedHostname ->
|
||||
dashboardAddress = address
|
||||
dashboardSuggestedHostname = suggestedHostname
|
||||
dashboardProbeBusy = true
|
||||
dashboardProbeError = null
|
||||
connectionViewModel.probeHermesDashboard(address) { result ->
|
||||
dashboardProbeBusy = false
|
||||
if (result.ok) {
|
||||
dashboardProbeResult = result
|
||||
dashboardAddress = result.dashboardUrl
|
||||
step = WizardStep.DashboardFound
|
||||
} else {
|
||||
dashboardProbeError = result.message
|
||||
val normalized = Connection.normalizeDashboardUrlInput(address)
|
||||
if (com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalized) &&
|
||||
dashboardHttpConsentOrigin != com.hermesandroid.relay.data.dashboardHttpOrigin(normalized)
|
||||
) {
|
||||
dashboardEntryIntent = DashboardEntryIntent.Server
|
||||
step = WizardStep.DashboardManual
|
||||
} else {
|
||||
dashboardProbeBusy = true
|
||||
connectionViewModel.probeHermesDashboard(address, dashboardHttpConsentOrigin) { result ->
|
||||
dashboardProbeBusy = false
|
||||
if (result.ok) {
|
||||
dashboardProbeResult = result
|
||||
dashboardAddress = result.dashboardUrl
|
||||
step = WizardStep.DashboardFound
|
||||
} else {
|
||||
dashboardProbeError = result.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,6 +563,7 @@ fun ConnectionWizard(
|
||||
connectionViewModel.saveDashboardConnection(
|
||||
dashboardUrl = draft.dashboardUrl,
|
||||
discoveredHostname = draft.discoveredHostname,
|
||||
httpConsentOrigin = draft.httpConsentOrigin,
|
||||
) { saved ->
|
||||
standardBusy = false
|
||||
saved.fold(
|
||||
@@ -652,7 +664,7 @@ fun ConnectionWizard(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (step != WizardStep.Nearby) {
|
||||
WizardStepIndicator(currentStep = step.indicatorIndex, method = chosenMethod)
|
||||
WizardStepIndicator(currentStep = step.indicatorIndex, method = chosenMethod, directApi = step == WizardStep.StandardEntry)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
@@ -707,8 +719,11 @@ fun ConnectionWizard(
|
||||
address = dashboardAddress,
|
||||
onAddressChange = {
|
||||
dashboardAddress = it
|
||||
dashboardHttpConsentOrigin = null
|
||||
dashboardProbeError = null
|
||||
},
|
||||
httpConsentOrigin = dashboardHttpConsentOrigin,
|
||||
onHttpConsentChange = { dashboardHttpConsentOrigin = it },
|
||||
busy = dashboardProbeBusy,
|
||||
error = dashboardProbeError,
|
||||
onBack = { step = WizardStep.Nearby },
|
||||
@@ -729,6 +744,7 @@ fun ConnectionWizard(
|
||||
dashboardUrl = result.dashboardUrl,
|
||||
signInRequired = result.signInRequired,
|
||||
discoveredHostname = dashboardSuggestedHostname,
|
||||
httpConsentOrigin = result.httpConsentOrigin,
|
||||
)
|
||||
val existing = findDuplicateFor("", "", result.dashboardUrl)
|
||||
if (existing != null) {
|
||||
@@ -1526,6 +1542,7 @@ private data class DashboardConnectionDraft(
|
||||
val dashboardUrl: String,
|
||||
val signInRequired: Boolean,
|
||||
val discoveredHostname: String? = null,
|
||||
val httpConsentOrigin: String? = null,
|
||||
)
|
||||
|
||||
private const val SetupGuideUrl = "https://hermes-relay.dev/docs/guide/getting-started"
|
||||
@@ -1537,7 +1554,7 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WizardStepIndicator(currentStep: Int, method: PairMethod) {
|
||||
private fun WizardStepIndicator(currentStep: Int, method: PairMethod, directApi: Boolean = false) {
|
||||
val totalSteps = 3
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -1599,7 +1616,7 @@ private fun WizardStepIndicator(currentStep: Int, method: PairMethod) {
|
||||
text = when (currentStep) {
|
||||
0 -> stringResource(R.string.cw_step_1_3)
|
||||
1 -> when (method) {
|
||||
PairMethod.Standard -> stringResource(R.string.cw_step_2_3_standard)
|
||||
PairMethod.Standard -> stringResource(if (directApi) R.string.cw_hermes_label else R.string.cw_step_2_3_standard)
|
||||
PairMethod.Scan -> stringResource(R.string.cw_step_2_3_scan)
|
||||
PairMethod.EnterCode -> stringResource(R.string.cw_step_2_3_enter_code)
|
||||
PairMethod.ShowCode -> stringResource(R.string.cw_step_2_3_show_code)
|
||||
@@ -1644,6 +1661,7 @@ private fun NearbyHermesStep(
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun NewNearbyHermesStep(
|
||||
busy: Boolean,
|
||||
@@ -1754,10 +1772,9 @@ private fun NewNearbyHermesStep(
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = { openExternalUrl(context, SetupGuideUrl) }) {
|
||||
Icon(
|
||||
@@ -1886,6 +1903,8 @@ private fun DashboardManualStep(
|
||||
intent: DashboardEntryIntent,
|
||||
address: String,
|
||||
onAddressChange: (String) -> Unit,
|
||||
httpConsentOrigin: String?,
|
||||
onHttpConsentChange: (String?) -> Unit,
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onBack: () -> Unit,
|
||||
@@ -1904,6 +1923,9 @@ private fun DashboardManualStep(
|
||||
optionalHttpUrlError(address, context)
|
||||
}
|
||||
val resolvedAddress = if (cloudSlugMode) resolveNousCloudDashboardAddress(address) else address.trim()
|
||||
val normalizedAddress = Connection.normalizeDashboardUrlInput(resolvedAddress)
|
||||
val httpAccepted = !com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalizedAddress) ||
|
||||
httpConsentOrigin == com.hermesandroid.relay.data.dashboardHttpOrigin(normalizedAddress)
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
@@ -1959,7 +1981,7 @@ private fun DashboardManualStep(
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go, autoCorrectEnabled = false),
|
||||
keyboardActions = KeyboardActions(onGo = {
|
||||
if (address.isNotBlank() && fieldError == null && !busy) onSubmit(resolvedAddress)
|
||||
if (address.isNotBlank() && fieldError == null && !busy && httpAccepted) onSubmit(resolvedAddress)
|
||||
}),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -1983,13 +2005,17 @@ private fun DashboardManualStep(
|
||||
error?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
DashboardHttpConsentControl(normalizedAddress, httpConsentOrigin, onHttpConsentChange, enabled = !busy)
|
||||
TextButton(onClick = { openExternalUrl(context, SetupGuideUrl) }) {
|
||||
Text(stringResource(R.string.cw_setup_guide))
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onBack, enabled = !busy, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) {
|
||||
Text(stringResource(R.string.cw_back))
|
||||
}
|
||||
Button(
|
||||
onClick = { onSubmit(resolvedAddress) },
|
||||
enabled = address.isNotBlank() && fieldError == null && !busy,
|
||||
enabled = address.isNotBlank() && fieldError == null && !busy && httpAccepted,
|
||||
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
|
||||
) {
|
||||
if (busy) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
@@ -2039,12 +2065,12 @@ private fun DashboardFoundStep(
|
||||
}
|
||||
FoundCapabilityLine(
|
||||
label = stringResource(R.string.cw_chat),
|
||||
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_ready),
|
||||
ready = !result.signInRequired,
|
||||
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_chat_checked_on_open),
|
||||
ready = false,
|
||||
)
|
||||
FoundCapabilityLine(
|
||||
label = stringResource(R.string.cw_manage),
|
||||
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_ready),
|
||||
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_auth_verified),
|
||||
ready = !result.signInRequired,
|
||||
)
|
||||
FoundCapabilityLine(
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.dashboardHttpConsentRequired
|
||||
import com.hermesandroid.relay.data.dashboardHttpOrigin
|
||||
|
||||
@Composable
|
||||
fun DashboardHttpConsentControl(
|
||||
address: String,
|
||||
confirmedOrigin: String?,
|
||||
onConfirmationChange: (String?) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
required: Boolean = dashboardHttpConsentRequired(address),
|
||||
) {
|
||||
if (!required) return
|
||||
val origin = dashboardHttpOrigin(address) ?: return
|
||||
val checked = confirmedOrigin == origin
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.dashboard_http_risk, origin),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = { onConfirmationChange(if (it) origin else null) },
|
||||
).padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = checked, onCheckedChange = null, enabled = enabled)
|
||||
Text(stringResource(R.string.dashboard_http_allow), modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.ui.components
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -69,6 +71,7 @@ import com.hermesandroid.relay.data.primaryRouteUrl
|
||||
import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.network.shared.EndpointSurface
|
||||
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.network.shared.pluginProxyRoutesOrNull
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URI
|
||||
@@ -427,11 +430,6 @@ private fun EndpointRow(
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.secure_link_auth_note),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,10 +578,10 @@ private fun RouteSurfaceMap(
|
||||
outcomeFor: (EndpointSurface) -> RouteProbeOutcome? = { null },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val dashboardUrl = candidate.dashboard?.url
|
||||
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
|
||||
val apiUrl = candidate.api?.url
|
||||
val relayUrl = candidate.relay?.url
|
||||
val proxy = candidate.pluginProxyRoutesOrNull()
|
||||
val dashboardUrl = candidate.gatewayRouteUrl()
|
||||
val apiUrl = candidate.api?.url ?: proxy?.apiBaseUrl
|
||||
val relayUrl = candidate.relay?.url ?: proxy?.relayWebSocketUrl
|
||||
val dashboardOutcome = outcomeFor(EndpointSurface.Dashboard)
|
||||
val apiOutcome = outcomeFor(EndpointSurface.Api)
|
||||
val relayOutcome = outcomeFor(EndpointSurface.Relay)
|
||||
@@ -925,10 +923,11 @@ internal fun EndpointCandidate.hasPlainRelayTransport(): Boolean {
|
||||
@Composable
|
||||
fun DashboardAddressEditorDialog(
|
||||
initialUrl: String,
|
||||
onSave: (dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onSave: (dashboardUrl: String, httpConsentOrigin: String?, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
|
||||
var httpConsentOrigin by remember(url) { mutableStateOf<String?>(null) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
val normalized = remember(url) {
|
||||
@@ -940,7 +939,7 @@ fun DashboardAddressEditorDialog(
|
||||
onDismissRequest = { if (!saving) onDismiss() },
|
||||
title = { Text(stringResource(R.string.dashboard_address_editor_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.dashboard_address_editor_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -973,14 +972,17 @@ fun DashboardAddressEditorDialog(
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
DashboardHttpConsentControl(normalized, httpConsentOrigin, { httpConsentOrigin = it }, enabled = !saving)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = valid && !saving && normalized != initialUrl.trim().trimEnd('/'),
|
||||
enabled = valid && !saving && normalized != initialUrl.trim().trimEnd('/') &&
|
||||
(!com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalized) ||
|
||||
httpConsentOrigin == com.hermesandroid.relay.data.dashboardHttpOrigin(normalized)),
|
||||
onClick = {
|
||||
saving = true
|
||||
onSave(normalized) { error ->
|
||||
onSave(normalized, httpConsentOrigin) { error ->
|
||||
saving = false
|
||||
if (error == null) onDismiss() else errorText = error
|
||||
}
|
||||
@@ -1029,7 +1031,7 @@ private fun isValidDashboardEditorAddress(address: String): Boolean {
|
||||
fun RouteEditorDialog(
|
||||
original: EndpointCandidate?,
|
||||
relayEnabled: Boolean = false,
|
||||
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
|
||||
onSave: (role: String, dashboardUrl: String, httpConsentOrigin: String?, onResult: (String?) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -1049,12 +1051,17 @@ fun RouteEditorDialog(
|
||||
)
|
||||
}
|
||||
var url by remember(original) { mutableStateOf(routeEditorInitialGatewayUrl(original)) }
|
||||
var httpConsentOrigin by remember(url, selectedRole) { mutableStateOf<String?>(null) }
|
||||
var errorText by remember { mutableStateOf<String?>(null) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
|
||||
val effectiveRole = if (selectedRole == CUSTOM_ROLE) customRole else selectedRole
|
||||
val normalizedAddress = Connection.normalizeDashboardUrlInput(url)
|
||||
val needsHttpConsent = com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalizedAddress) ||
|
||||
(effectiveRole == "public" && com.hermesandroid.relay.data.dashboardHttpOrigin(normalizedAddress) != null)
|
||||
val saveEnabled = !saving &&
|
||||
url.isNotBlank() &&
|
||||
(!needsHttpConsent || httpConsentOrigin == com.hermesandroid.relay.data.dashboardHttpOrigin(normalizedAddress)) &&
|
||||
(selectedRole != CUSTOM_ROLE || customRole.isNotBlank())
|
||||
|
||||
AlertDialog(
|
||||
@@ -1066,7 +1073,7 @@ fun RouteEditorDialog(
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.endpoints_route_editor_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1154,6 +1161,10 @@ fun RouteEditorDialog(
|
||||
previewCandidate?.let { candidate ->
|
||||
RouteSurfaceMap(candidate = candidate)
|
||||
}
|
||||
DashboardHttpConsentControl(
|
||||
normalizedAddress, httpConsentOrigin, { httpConsentOrigin = it },
|
||||
enabled = !saving, required = needsHttpConsent,
|
||||
)
|
||||
if (selectedRole == "tailscale") {
|
||||
Text(
|
||||
text = stringResource(R.string.endpoints_tailscale_setup_hint),
|
||||
@@ -1173,7 +1184,7 @@ fun RouteEditorDialog(
|
||||
enabled = saveEnabled,
|
||||
onClick = {
|
||||
saving = true
|
||||
onSave(effectiveRole, url) { error ->
|
||||
onSave(effectiveRole, url, httpConsentOrigin) { error ->
|
||||
saving = false
|
||||
if (error == null) {
|
||||
onDismiss()
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -145,7 +146,7 @@ fun HermesCardBubble(
|
||||
// Expiry clock for timed asks. Ticks once a second while the deadline
|
||||
// is ahead; freezes after. Keyed on the deadline so a re-used card id
|
||||
// with a fresh expiry restarts the loop.
|
||||
val expiresAt = card.input?.expiresAtMillis
|
||||
val expiresAt = card.input?.expiresAtMillis ?: card.clarifyBatch?.expiresAtMillis
|
||||
var nowMillis by remember(expiresAt) { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(expiresAt) {
|
||||
if (expiresAt == null) return@LaunchedEffect
|
||||
@@ -252,6 +253,14 @@ fun HermesCardBubble(
|
||||
// action button all collapse the same way.
|
||||
val input = card.input
|
||||
when {
|
||||
card.clarifyBatch != null -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ClarifyBatchContent(
|
||||
batch = card.clarifyBatch,
|
||||
expired = expired || alreadyChosen?.actionValue == HermesCardDispatch.EXPIRED_STAMP,
|
||||
onInputSubmit = onInputSubmit,
|
||||
)
|
||||
}
|
||||
alreadyChosen != null -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
val chosenAction = card.actions.firstOrNull {
|
||||
@@ -408,15 +417,18 @@ private fun ChoseRow(
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CardInputSlot(
|
||||
internal fun CardInputSlot(
|
||||
input: HermesCardInput,
|
||||
onSubmit: (String) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
stackedChoices: Boolean = false,
|
||||
) {
|
||||
// Deliberately remember, not rememberSaveable — a typed secret must
|
||||
// never be written into the saved-instance-state Bundle.
|
||||
var answerText by remember { mutableStateOf("") }
|
||||
// Batch drafts survive lazy-item disposal. Secrets never enter saved state.
|
||||
var answerText by if (stackedChoices && !input.masked) rememberSaveable { mutableStateOf("") }
|
||||
else remember { mutableStateOf("") }
|
||||
var reveal by remember { mutableStateOf(false) }
|
||||
var selectedChoices by remember(input.choices) { mutableStateOf(emptyList<String>()) }
|
||||
var selectedChoices by if (stackedChoices) rememberSaveable(input.choices) { mutableStateOf(emptyList<String>()) }
|
||||
else remember(input.choices) { mutableStateOf(emptyList<String>()) }
|
||||
val isMultiSelect = input.multiSelect && input.choices.isNotEmpty()
|
||||
|
||||
val showFreeText = !input.masked && (
|
||||
@@ -429,7 +441,7 @@ private fun CardInputSlot(
|
||||
|
||||
val submitFreeText = {
|
||||
val customAnswer = answerText.trim()
|
||||
if (customAnswer.isNotEmpty()) {
|
||||
if (enabled && customAnswer.isNotEmpty()) {
|
||||
onSubmit(
|
||||
if (isMultiSelect) {
|
||||
encodeClarifyMultiSelectAnswer(selectedChoices + customAnswer)
|
||||
@@ -443,14 +455,13 @@ private fun CardInputSlot(
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Choice chips
|
||||
if (input.choices.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val choices: @Composable () -> Unit = {
|
||||
input.choices.forEach { choice ->
|
||||
if (isMultiSelect) {
|
||||
val selected = choice in selectedChoices
|
||||
FilterChip(
|
||||
enabled = enabled,
|
||||
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
|
||||
selected = selected,
|
||||
onClick = {
|
||||
selectedChoices = if (selected) {
|
||||
@@ -459,7 +470,11 @@ private fun CardInputSlot(
|
||||
selectedChoices + choice
|
||||
}
|
||||
},
|
||||
label = { Text(choice, style = MaterialTheme.typography.labelMedium) },
|
||||
label = {
|
||||
Text(choice,
|
||||
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
|
||||
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
|
||||
},
|
||||
leadingIcon = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
@@ -477,9 +492,13 @@ private fun CardInputSlot(
|
||||
)
|
||||
} else {
|
||||
AssistChip(
|
||||
enabled = enabled,
|
||||
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
|
||||
onClick = { onSubmit(choice) },
|
||||
label = {
|
||||
Text(choice, style = MaterialTheme.typography.labelMedium)
|
||||
Text(choice,
|
||||
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
|
||||
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -489,6 +508,11 @@ private fun CardInputSlot(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stackedChoices) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { choices() }
|
||||
} else {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { choices() }
|
||||
}
|
||||
}
|
||||
|
||||
// Masked secret field
|
||||
@@ -534,12 +558,13 @@ private fun CardInputSlot(
|
||||
else R.string.card_answer_placeholder,
|
||||
),
|
||||
onSubmit = submitFreeText,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!isMultiSelect) {
|
||||
IconButton(
|
||||
onClick = submitFreeText,
|
||||
enabled = answerText.isNotBlank(),
|
||||
enabled = enabled && answerText.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
@@ -559,7 +584,7 @@ private fun CardInputSlot(
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Button(
|
||||
onClick = { onSubmit(encodeClarifyMultiSelectAnswer(answers)) },
|
||||
enabled = answers.isNotEmpty(),
|
||||
enabled = enabled && answers.isNotEmpty(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.card_submit),
|
||||
@@ -615,6 +640,7 @@ private fun InlineAnswerField(
|
||||
placeholder: String,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val shape = appearanceRoundedCornerShape(16.dp)
|
||||
Box(
|
||||
@@ -632,6 +658,7 @@ private fun InlineAnswerField(
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
enabled = enabled,
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
@@ -639,7 +666,7 @@ private fun InlineAnswerField(
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSubmit() }),
|
||||
keyboardActions = KeyboardActions(onSend = { onSubmit() }, onDone = { onSubmit() }),
|
||||
maxLines = 3,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -23,14 +23,9 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
|
||||
/**
|
||||
* Bottom-sheet audit of the exact extra context the agent is injected with on
|
||||
* the next turn — opened by tapping the chat [ContextMeterBar].
|
||||
*
|
||||
* Renders the SAME [ChatViewModel.InjectedContext] the send path builds (via
|
||||
* [ChatViewModel.previewInjectedContext] → `composeInjectedContext`), so it is
|
||||
* a faithful audit, not a re-derivation that could drift. Empty blocks show a
|
||||
* labeled note instead of vanishing, and the gateway's server-side persona is
|
||||
* explicitly called out as not-sent-from-this-device.
|
||||
* Transport-aware context preview, opened from the chat [ContextMeterBar].
|
||||
* Unsupported blocks are labeled rather than represented as delivered.
|
||||
* Server-reported configuration is separate from device-supplied context.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -53,7 +48,7 @@ fun InjectedContextSheet(
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.injected_context_subtitle, context.transport),
|
||||
text = stringResource(R.string.injected_context_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -71,6 +66,7 @@ fun InjectedContextSheet(
|
||||
val mediaNoRelay = stringResource(R.string.injected_context_media_no_relay)
|
||||
val relayNotSet = stringResource(R.string.injected_context_relay_not_set)
|
||||
val turnNotSet = stringResource(R.string.injected_context_turn_not_set)
|
||||
val unsupported = stringResource(R.string.injected_context_gateway_unsupported)
|
||||
|
||||
ContextSection(
|
||||
title = personaTitle,
|
||||
@@ -84,7 +80,7 @@ fun InjectedContextSheet(
|
||||
ContextSection(
|
||||
title = phoneStatusTitle,
|
||||
body = context.appContext,
|
||||
emptyNote = phoneStatusNotSet,
|
||||
emptyNote = if (context.perTurnContextSupported) phoneStatusNotSet else unsupported,
|
||||
)
|
||||
ContextSection(
|
||||
title = mediaTitle,
|
||||
@@ -107,7 +103,7 @@ fun InjectedContextSheet(
|
||||
ContextSection(
|
||||
title = turnTitle,
|
||||
body = context.interfaceContext,
|
||||
emptyNote = turnNotSet,
|
||||
emptyNote = if (context.perTurnContextSupported) turnNotSet else unsupported,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -214,7 +215,7 @@ fun MessageBubble(
|
||||
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||
message.role == MessageRole.SYSTEM -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
isActionBubble -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.45f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||
}
|
||||
|
||||
val textColor = when (message.role) {
|
||||
@@ -436,8 +437,8 @@ fun MessageBubble(
|
||||
// rows) would otherwise paint a bare timestamp-only chip between the
|
||||
// Thought-process block and the tool pill. The first-token working state
|
||||
// is rendered directly in the conversation
|
||||
// lane below, without an opaque bubble. Cards and attachments still own
|
||||
// a normal bubble even when response prose has not arrived yet.
|
||||
// lane below, without an opaque bubble. Standalone cards own their own
|
||||
// surface; wrapping those in another filled bubble duplicates the chrome.
|
||||
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
|
||||
StandaloneStreamingStatus(
|
||||
status = streamingStatus,
|
||||
@@ -454,6 +455,10 @@ fun MessageBubble(
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
val standaloneCards = !isUser && !isSystem &&
|
||||
visibleMessageContent.isBlank() && quoteEnvelope == null &&
|
||||
message.cards.isNotEmpty() && message.attachments.isEmpty() &&
|
||||
inlineImages.isEmpty() && !showImageGeneration
|
||||
if (showBubble) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = maxBubbleWidth),
|
||||
@@ -599,11 +604,11 @@ fun MessageBubble(
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
shape = if (standaloneCards) RectangleShape else bubbleShape,
|
||||
color = if (standaloneCards) Color.Transparent else backgroundColor,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
if (!isUser && !isSystem && isDarkTheme && !standaloneCards) {
|
||||
Modifier.leftEdgeGlow(
|
||||
alpha = 0.12f,
|
||||
width = 28.dp,
|
||||
@@ -651,7 +656,8 @@ fun MessageBubble(
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
modifier = if (standaloneCards) Modifier
|
||||
else Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
) {
|
||||
quoteEnvelope?.let { envelope ->
|
||||
ChatQuoteReferenceChip(
|
||||
@@ -741,7 +747,7 @@ fun MessageBubble(
|
||||
onInputSubmit = { key, value ->
|
||||
onCardInput(message.id, key, value)
|
||||
},
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
maxWidth = if (standaloneCards) maxBubbleWidth else maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -823,6 +829,7 @@ fun MessageBubble(
|
||||
|
||||
val hasTokenUsage = showUsage && !isUser &&
|
||||
(message.inputTokens != null || message.outputTokens != null)
|
||||
val deliveryStatus = message.deliveryStatus?.takeIf { isUser }
|
||||
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
@@ -831,13 +838,14 @@ fun MessageBubble(
|
||||
// This row is reserved from the first streaming frame. Completion
|
||||
// can reveal both timestamp and token usage without adding a new
|
||||
// footer line or changing the bubble's measured height.
|
||||
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
|
||||
if ((isLastInGroup && (showTimestamps || hasTokenUsage)) || deliveryStatus != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = if (standaloneCards) Modifier.padding(horizontal = 4.dp) else Modifier,
|
||||
) {
|
||||
if (showTimestamps) Text(
|
||||
if (isLastInGroup && showTimestamps) Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
@@ -847,33 +855,30 @@ fun MessageBubble(
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
if (hasTokenUsage) {
|
||||
if (isLastInGroup && hasTokenUsage) {
|
||||
TokenDisplay(
|
||||
inputTokens = message.inputTokens,
|
||||
outputTokens = message.outputTokens,
|
||||
)
|
||||
}
|
||||
// Share the footer line, retaining the user bubble's contrasting foreground.
|
||||
deliveryStatus?.let { status ->
|
||||
MessageDeliveryIndicator(
|
||||
status = status,
|
||||
contentColor = textColor,
|
||||
text = MessageDeliveryIndicatorText(
|
||||
sending = stringResource(R.string.msg_bubble_sending),
|
||||
queued = stringResource(R.string.msg_bubble_queued),
|
||||
steered = stringResource(R.string.msg_bubble_steered),
|
||||
delivered = stringResource(R.string.msg_bubble_delivered),
|
||||
failed = stringResource(R.string.msg_bubble_not_sent),
|
||||
tapToRetry = stringResource(R.string.chat_retry),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery status for local user messages, including steering.
|
||||
// Use the bubble's foreground: accent-on-accent hides the label.
|
||||
message.deliveryStatus?.takeIf { isUser }?.let { status ->
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
MessageDeliveryIndicator(
|
||||
status = status,
|
||||
contentColor = textColor,
|
||||
text = MessageDeliveryIndicatorText(
|
||||
sending = stringResource(R.string.msg_bubble_sending),
|
||||
queued = stringResource(R.string.msg_bubble_queued),
|
||||
steered = stringResource(R.string.msg_bubble_steered),
|
||||
delivered = stringResource(R.string.msg_bubble_delivered),
|
||||
failed = stringResource(R.string.msg_bubble_not_sent),
|
||||
tapToRetry = stringResource(R.string.chat_retry),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Non-tail historical fragments have no reserved timestamp row.
|
||||
// Preserve their existing standalone token metadata layout.
|
||||
if (!isLastInGroup && hasTokenUsage) {
|
||||
|
||||
@@ -60,7 +60,9 @@ import com.hermesandroid.relay.ui.theme.appearanceTopRoundedCornerShape
|
||||
@Composable
|
||||
fun ModelPickerSheet(
|
||||
options: List<ChatInputPickerOption>,
|
||||
loading: Boolean = false,
|
||||
refreshing: Boolean = false,
|
||||
error: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
onSelect: (ChatInputPickerOption) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -210,11 +212,28 @@ fun ModelPickerSheet(
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_picker_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when {
|
||||
modelOptions.isEmpty() && loading -> Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(stringResource(R.string.dashboard_loading_provider_catalog))
|
||||
}
|
||||
modelOptions.isEmpty() && error != null -> Text(
|
||||
text = stringResource(R.string.dashboard_model_options_load_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
else -> Text(
|
||||
text = stringResource(
|
||||
if (modelOptions.isEmpty()) R.string.model_picker_no_models
|
||||
else R.string.model_picker_empty,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -41,6 +42,8 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -80,9 +83,10 @@ object ProfileShelfPolicy {
|
||||
profiles: List<Profile>,
|
||||
presentation: ProfilePresentation,
|
||||
selectedProfileName: String?,
|
||||
serverDefaultProfileName: String? = null,
|
||||
): List<ProfileChoice> {
|
||||
val selectedKey = AgentDisplay.profileSessionKey(selectedProfileName)
|
||||
return ProfilePresentationPolicy
|
||||
val choices = ProfilePresentationPolicy
|
||||
.visibleKeys(profiles, presentation, selectedKey)
|
||||
.mapNotNull { key ->
|
||||
if (key == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY) {
|
||||
@@ -91,6 +95,17 @@ object ProfileShelfPolicy {
|
||||
profiles.firstOrNull { it.name == key }?.let { ProfileChoice(key, it) }
|
||||
}
|
||||
}
|
||||
// Group only exact upstream identities, never matching display labels.
|
||||
// Keep the selected choice's request/presentation key unchanged.
|
||||
val hasDefault = choices.any { it.isServerDefault }
|
||||
val hasResolved = choices.any { it.key == serverDefaultProfileName }
|
||||
if (!hasDefault || !hasResolved) return choices
|
||||
val omittedKey = if (selectedProfileName == serverDefaultProfileName) {
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
} else {
|
||||
serverDefaultProfileName
|
||||
}
|
||||
return choices.filterNot { it.key == omittedKey }
|
||||
}
|
||||
|
||||
fun canSwitch(isStreaming: Boolean, streamingEndpoint: String): Boolean =
|
||||
@@ -124,8 +139,9 @@ fun ProfileShelf(
|
||||
onHide: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
|
||||
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
|
||||
}
|
||||
if (choices.size <= 1) return
|
||||
|
||||
@@ -157,7 +173,7 @@ fun ProfileShelf(
|
||||
val label = if (selected) {
|
||||
activeDisplayName
|
||||
} else {
|
||||
profileChoiceLabel(choice, resolvedProfile)
|
||||
profileChoiceLabel(choice, serverDefaultProfile)
|
||||
}
|
||||
if (selected) {
|
||||
val openPassportDescription = stringResource(R.string.profile_shelf_open_passport)
|
||||
@@ -191,7 +207,7 @@ fun ProfileShelf(
|
||||
ProfileChoiceAvatar(
|
||||
connectionViewModel,
|
||||
choice,
|
||||
resolvedProfile,
|
||||
serverDefaultProfile,
|
||||
label,
|
||||
36,
|
||||
)
|
||||
@@ -238,7 +254,7 @@ fun ProfileShelf(
|
||||
ProfileChoiceAvatar(
|
||||
connectionViewModel,
|
||||
choice,
|
||||
resolvedProfile,
|
||||
serverDefaultProfile,
|
||||
label,
|
||||
36,
|
||||
)
|
||||
@@ -275,8 +291,8 @@ fun ProfileShelf(
|
||||
|
||||
actionChoice?.let { choice ->
|
||||
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
|
||||
val target = choice.profile ?: resolvedProfile
|
||||
val label = profileChoiceLabel(choice, resolvedProfile)
|
||||
val target = choice.profile ?: serverDefaultProfile
|
||||
val label = profileChoiceLabel(choice, serverDefaultProfile)
|
||||
ProfileShelfActionsDialog(
|
||||
label = label,
|
||||
canSwitch = switchEnabled && !isProfileLocked,
|
||||
@@ -319,10 +335,14 @@ fun ProfileSwitcherSheet(
|
||||
onManageDisplay: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name)
|
||||
val serverDefaultProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
|
||||
val choices = remember(profiles, presentation, selectedProfile?.name, serverDefaultProfile) {
|
||||
ProfileShelfPolicy.choices(profiles, presentation, selectedProfile?.name, serverDefaultProfile?.name)
|
||||
}
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -346,8 +366,15 @@ fun ProfileSwitcherSheet(
|
||||
}
|
||||
choices.forEach { choice ->
|
||||
val selected = ProfileShelfPolicy.isSelected(choice, selectedProfile?.name)
|
||||
val label = profileChoiceLabel(choice, resolvedProfile)
|
||||
val model = choice.profile?.model?.takeIf { it.isNotBlank() }
|
||||
val label = profileChoiceLabel(choice, serverDefaultProfile)
|
||||
val target = if (choice.isServerDefault) serverDefaultProfile else choice.profile
|
||||
val defaultGroup = serverDefaultProfile != null &&
|
||||
(choice.isServerDefault || choice.key == serverDefaultProfile?.name)
|
||||
val detail = listOfNotNull(
|
||||
stringResource(R.string.conn_info_server_default).takeIf { defaultGroup },
|
||||
target?.name?.takeIf { it != label },
|
||||
target?.model?.takeIf { it.isNotBlank() },
|
||||
).joinToString(" · ")
|
||||
ListItem(
|
||||
modifier = Modifier.selectable(
|
||||
selected = selected,
|
||||
@@ -361,13 +388,13 @@ fun ProfileSwitcherSheet(
|
||||
headlineContent = {
|
||||
Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = if (model != null) {
|
||||
{ Text(model, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
supportingContent = if (detail.isNotBlank()) {
|
||||
{ Text(detail, maxLines = 2, overflow = TextOverflow.Ellipsis) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
leadingContent = {
|
||||
ProfileChoiceAvatar(connectionViewModel, choice, resolvedProfile, label, 42)
|
||||
ProfileChoiceAvatar(connectionViewModel, choice, serverDefaultProfile, label, 42)
|
||||
},
|
||||
trailingContent = if (selected) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
@@ -375,6 +402,27 @@ fun ProfileSwitcherSheet(
|
||||
null
|
||||
},
|
||||
)
|
||||
if (defaultGroup && selected) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
|
||||
.toggleable(
|
||||
value = choice.isServerDefault,
|
||||
enabled = switchEnabled && !isProfileLocked,
|
||||
role = Role.Checkbox,
|
||||
onValueChange = {
|
||||
onSelect(if (choice.isServerDefault) serverDefaultProfile else null)
|
||||
onDismiss()
|
||||
},
|
||||
).padding(start = 64.dp, end = 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(checked = choice.isServerDefault, onCheckedChange = null,
|
||||
enabled = switchEnabled && !isProfileLocked)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringResource(R.string.profile_follow_server_default),
|
||||
style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(top = 8.dp))
|
||||
TextButton(
|
||||
@@ -518,7 +566,8 @@ private fun ProfileActionRow(
|
||||
@Composable
|
||||
private fun profileChoiceLabel(choice: ProfileChoice, resolvedProfile: Profile?): String =
|
||||
if (choice.isServerDefault) {
|
||||
stringResource(R.string.conn_info_server_default)
|
||||
AgentDisplay.profileDisplayName(resolvedProfile)
|
||||
?: stringResource(R.string.conn_info_server_default)
|
||||
} else {
|
||||
choice.profile?.let(AgentDisplay::profileDisplayName)
|
||||
?: choice.profile?.name?.replaceFirstChar { it.uppercase() }
|
||||
|
||||
@@ -46,9 +46,8 @@ fun RelayStatusStrip(
|
||||
/** Optional security marker rendered just before the route label. */
|
||||
securityGlyph: (@Composable () -> Unit)? = null,
|
||||
/**
|
||||
* When true, the strip shows an amber "Relay reconnecting" cue in place of the
|
||||
* route label. This is where a **routine** in-progress relay reconnect
|
||||
* surfaces — the top chrome stays empty so chat content never shifts.
|
||||
* When true, the strip shows the active chat connection's pending state
|
||||
* instead of its route label. Optional Relay availability is independent.
|
||||
*/
|
||||
reconnecting: Boolean = false,
|
||||
maxContentWidth: Dp? = null,
|
||||
@@ -120,7 +119,7 @@ fun RelayStatusStrip(
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber "Relay reconnecting" cue with a softly pulsing dot. This is the *only*
|
||||
* Amber connection-progress cue with a softly pulsing dot. This is the *only*
|
||||
* surface for a routine in-progress relay reconnect — the top of the app stays
|
||||
* empty (chat/agent status rides the chat header subtitle) so nothing shifts.
|
||||
* Pulse is frame-throttled via [rememberAmbientPhase] to avoid pinning the
|
||||
@@ -144,7 +143,7 @@ private fun ReconnectingCue(modifier: Modifier = Modifier) {
|
||||
.background(RelayRefresh.Amber),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_relay_reconnecting),
|
||||
text = stringResource(R.string.session_path_connecting),
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Amber,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -63,7 +64,7 @@ fun OnboardingPage(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(210.dp),
|
||||
.height(if (LocalConfiguration.current.screenHeightDp < 620 || LocalConfiguration.current.fontScale > 1.2f) 96.dp else 150.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
heroContent()
|
||||
@@ -129,7 +130,7 @@ private fun FeatureHero(
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(46.dp),
|
||||
tint = Color(0xFF7B55F6),
|
||||
)
|
||||
|
||||
@@ -15,9 +15,11 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -207,7 +209,7 @@ fun OnboardingScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
|
||||
val currentPage = pagerState.currentPage
|
||||
val currentPageType = pages[currentPage]
|
||||
|
||||
@@ -221,7 +223,6 @@ fun OnboardingScreen(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -363,7 +364,6 @@ private fun GradientOnboardingButton(
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.clickable(onClick = onClick),
|
||||
color = Color.Transparent,
|
||||
@@ -371,13 +371,14 @@ private fun GradientOnboardingButton(
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 52.dp)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(Color(0xFF7047F5), Color(0xFF6446F0)),
|
||||
),
|
||||
)
|
||||
.padding(horizontal = 22.dp),
|
||||
.padding(horizontal = 22.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -431,6 +432,9 @@ private fun SegmentedOnboardingProgress(
|
||||
|
||||
@Composable
|
||||
private fun WelcomePage() {
|
||||
val configuration = LocalConfiguration.current
|
||||
val heroHeight = (configuration.screenHeightDp * if (configuration.fontScale > 1.2f) 0.18f else 0.27f)
|
||||
.coerceIn(80f, 240f).dp
|
||||
val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -442,15 +446,15 @@ private fun WelcomePage() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(380.dp),
|
||||
.height(heroHeight),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.onboarding_hero_option1),
|
||||
contentDescription = stringResource(R.string.onboarding_hermes_logo),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.Center,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -463,6 +463,21 @@ internal fun shouldShowRetainedHistoryDashboardSignIn(
|
||||
gatewayAvailability == GatewayAvailability.SignInRequired &&
|
||||
!apiReachable
|
||||
|
||||
/**
|
||||
* A foreground Gateway-owned Chat must be allowed to open its observation
|
||||
* socket before `gateway.ready` can make chatReady true. Authentication and
|
||||
* protocol failures are terminal; ordinary reachability failures remain
|
||||
* visible so the Gateway client's bounded retry policy can recover them.
|
||||
*/
|
||||
internal fun shouldOwnVisibleGateway(
|
||||
appForeground: Boolean,
|
||||
isGatewayTransport: Boolean,
|
||||
gatewayAvailability: GatewayAvailability,
|
||||
): Boolean = appForeground &&
|
||||
isGatewayTransport &&
|
||||
gatewayAvailability != GatewayAvailability.SignInRequired &&
|
||||
gatewayAvailability != GatewayAvailability.Unsupported
|
||||
|
||||
internal fun shouldPresentChatFailureDuringDashboardSignIn(
|
||||
failure: ChatFailureNotice,
|
||||
dashboardSignInRequired: Boolean,
|
||||
@@ -997,6 +1012,7 @@ fun ChatScreen(
|
||||
// of available profiles itself now lives entirely inside the sheet.
|
||||
val selectedProfile by connectionViewModel.selectedProfile.collectAsState()
|
||||
val effectiveProfile by connectionViewModel.effectiveDisplayProfile.collectAsState()
|
||||
val serverDefaultDisplayProfile by connectionViewModel.serverDefaultDisplayProfile.collectAsState()
|
||||
val profilePresentation by connectionViewModel.profilePresentation.collectAsState()
|
||||
val isProfileLocked by connectionViewModel.isProfileLocked.collectAsState()
|
||||
val lockedProfileName by connectionViewModel.lockedProfileName.collectAsState()
|
||||
@@ -1103,7 +1119,9 @@ fun ChatScreen(
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val modelOptionsLoading by chatViewModel.modelOptionsLoading.collectAsState()
|
||||
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
||||
val modelOptionsError by chatViewModel.modelOptionsError.collectAsState()
|
||||
val modelSelectionConfirmation by chatViewModel.modelSelectionConfirmation.collectAsState()
|
||||
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
@@ -1204,6 +1222,7 @@ fun ChatScreen(
|
||||
chatGatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
|
||||
val isGatewayTransport = remember(
|
||||
streamingEndpointPref, chatServerCapabilities, chatGatewayAvailability,
|
||||
activeConnection,
|
||||
) {
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpointPref) == "gateway"
|
||||
}
|
||||
@@ -1212,8 +1231,12 @@ fun ChatScreen(
|
||||
// the foreground. setChatVisible owns that edge; an ordinary Gateway open
|
||||
// warms only the observation socket and never attaches a saved session.
|
||||
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
|
||||
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
|
||||
val visibleGatewayOwner = appForeground && chatReady && isGatewayTransport
|
||||
LaunchedEffect(isGatewayTransport, appForeground, chatGatewayAvailability) {
|
||||
val visibleGatewayOwner = shouldOwnVisibleGateway(
|
||||
appForeground = appForeground,
|
||||
isGatewayTransport = isGatewayTransport,
|
||||
gatewayAvailability = chatGatewayAvailability,
|
||||
)
|
||||
chatViewModel.setChatVisible(visibleGatewayOwner)
|
||||
// updateGatewayClient owns the one-time catalog/reasoning bootstrap for
|
||||
// a newly-ready socket. Repeating it here created a duplicate cold-open
|
||||
@@ -1433,6 +1456,11 @@ fun ChatScreen(
|
||||
composerDraftKey.sessionId,
|
||||
) { mutableStateOf<Int?>(null) }
|
||||
var showModelSheet by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showModelSheet, isGatewayTransport, currentSessionId, selectedProfile?.name, activeConnection?.id) {
|
||||
if (showModelSheet && isGatewayTransport) {
|
||||
chatViewModel.refreshModelOptions(catalogOnly = true)
|
||||
}
|
||||
}
|
||||
var showEffortSheet by remember { mutableStateOf(false) }
|
||||
var showAgentInfo by remember { mutableStateOf(false) }
|
||||
var showProfileShelf by remember { mutableStateOf(false) }
|
||||
@@ -1653,65 +1681,53 @@ fun ChatScreen(
|
||||
voiceOutputConfig?.enabled
|
||||
}
|
||||
|
||||
val voiceSystemOverlayAvailable = BuildFlavor.isSideload
|
||||
val voiceSystemOverlayAvailable = BuildFlavor.voiceSystemOverlay
|
||||
val showVoiceSystemOverlay: () -> Unit = {
|
||||
if (!voiceSystemOverlayAvailable) {
|
||||
pendingVoiceOverlayPermission = false
|
||||
} else if (assistantSessionActive) {
|
||||
voiceOverlayHost.hide()
|
||||
} else if (!voiceOverlayHost.hasOverlayPermission()) {
|
||||
if (voiceSystemOverlayAvailable && !assistantSessionActive) {
|
||||
pendingVoiceOverlayPermission = true
|
||||
runCatching {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
context.startActivity(intent)
|
||||
}
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_perm_enable),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
pendingVoiceOverlayPermission = false
|
||||
val shown = voiceOverlayHost.show(
|
||||
VoiceOverlaySession(
|
||||
uiState = voiceViewModel.uiState,
|
||||
engineMode = voiceStats.voiceEngineMode,
|
||||
provider = activeVoiceProvider,
|
||||
model = activeVoiceModel,
|
||||
voice = activeVoiceName,
|
||||
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||
configScope = activeVoiceScope,
|
||||
outputEnabled = activeVoiceEnabled,
|
||||
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
onStartListening = { voiceViewModel.startListening() },
|
||||
onStopListening = { voiceViewModel.stopListening() },
|
||||
onInterrupt = { voiceViewModel.interruptSpeaking() },
|
||||
onPauseAutoMode = { voiceViewModel.pauseContinuousMode() },
|
||||
onReturnToHermes = {
|
||||
openHermesFromOverlay(context)
|
||||
voiceOverlayHost.hide()
|
||||
},
|
||||
onDismissOverlay = { voiceOverlayHost.hide() },
|
||||
onExit = {
|
||||
voiceOverlayHost.hide()
|
||||
voiceViewModel.exitVoiceMode()
|
||||
},
|
||||
),
|
||||
)
|
||||
if (!shown) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_start_failed),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pendingVoiceOverlayPermission && voiceUiState.voiceMode && !assistantSessionActive) {
|
||||
com.hermesandroid.relay.voice.VoiceOverlaySetupDialog(
|
||||
onDismiss = { pendingVoiceOverlayPermission = false },
|
||||
onBeforePermission = { voiceViewModel.pauseContinuousMode() },
|
||||
onStart = {
|
||||
pendingVoiceOverlayPermission = false
|
||||
val shown = voiceOverlayHost.show(
|
||||
VoiceOverlaySession(
|
||||
uiState = voiceViewModel.uiState,
|
||||
engineMode = voiceStats.voiceEngineMode,
|
||||
connectionLabel = activeConnection?.label,
|
||||
provider = activeVoiceProvider,
|
||||
model = activeVoiceModel,
|
||||
voice = activeVoiceName,
|
||||
profileName = AgentDisplay.profileDisplayName(effectiveProfile),
|
||||
configScope = activeVoiceScope,
|
||||
outputEnabled = activeVoiceEnabled,
|
||||
fallbackEnabled = voiceOutputConfig?.fallback_enabled,
|
||||
onStartListening = { voiceViewModel.startListening() },
|
||||
onStopListening = { voiceViewModel.stopListening() },
|
||||
onInterrupt = { voiceViewModel.interruptSpeaking() },
|
||||
onPauseAutoMode = { voiceViewModel.pauseContinuousMode() },
|
||||
onReturnToHermes = {
|
||||
if (!openHermesFromOverlay(context)) voiceOverlayHost.exitVoiceSession()
|
||||
},
|
||||
onDismissOverlay = { voiceOverlayHost.exitVoiceSession() },
|
||||
onExit = { voiceViewModel.exitVoiceMode() },
|
||||
),
|
||||
lifecycleOwner.lifecycle,
|
||||
)
|
||||
if (!shown) {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_start_failed),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(voiceUiState.voiceMode) {
|
||||
if (!voiceUiState.voiceMode) {
|
||||
@@ -1727,37 +1743,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(
|
||||
lifecycleOwner,
|
||||
pendingVoiceOverlayPermission,
|
||||
voiceUiState.voiceMode,
|
||||
voiceOutputConfig,
|
||||
realtimeAgentConfig,
|
||||
voiceStats.voiceEngineMode,
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && pendingVoiceOverlayPermission) {
|
||||
when {
|
||||
voiceOverlayHost.hasOverlayPermission() && voiceUiState.voiceMode -> {
|
||||
showVoiceSystemOverlay()
|
||||
}
|
||||
!voiceOverlayHost.hasOverlayPermission() -> {
|
||||
pendingVoiceOverlayPermission = false
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.chat_overlay_perm_denied),
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> pendingVoiceOverlayPermission = false
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
LaunchedEffect(voiceClient, voiceUiState.voiceMode, selectedProfile?.name) {
|
||||
if (!voiceUiState.voiceMode) return@LaunchedEffect
|
||||
val client = voiceClient ?: return@LaunchedEffect
|
||||
@@ -2460,11 +2445,12 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
|
||||
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
|
||||
val profileShelfAvailable = !supervised && com.hermesandroid.relay.ui.components.ProfileShelfPolicy.choices(
|
||||
profiles = agentProfiles,
|
||||
presentation = profilePresentation,
|
||||
selectedKey = selectedProfileKey,
|
||||
)
|
||||
selectedProfileName = selectedProfile?.name,
|
||||
serverDefaultProfileName = serverDefaultDisplayProfile?.name,
|
||||
).size > 1
|
||||
val profileSwitchEnabled = com.hermesandroid.relay.ui.components.ProfileShelfPolicy.canSwitch(
|
||||
isStreaming = isStreaming,
|
||||
streamingEndpoint = chatViewModel.streamingEndpoint,
|
||||
@@ -3237,8 +3223,8 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
if (!supervised && showContextSheet) {
|
||||
// Live audit of the exact extra context the agent will be
|
||||
// injected with on the next turn (transparency / auditability).
|
||||
// Snapshot of supported client context and separately reported
|
||||
// server configuration, not a delivery receipt.
|
||||
InjectedContextSheet(
|
||||
context = remember(showContextSheet) {
|
||||
chatViewModel.previewInjectedContext()
|
||||
@@ -3353,6 +3339,9 @@ fun ChatScreen(
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.then(if (targetConnectState == ChatConnectState.NeedsConnection) {
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
} else Modifier)
|
||||
.padding(horizontal = 32.dp)
|
||||
.then(
|
||||
responsiveLayout.introMaxWidth?.let {
|
||||
@@ -3367,7 +3356,9 @@ fun ChatScreen(
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
val avatarModifier = responsiveLayout.avatarSize?.let { size ->
|
||||
val avatarModifier = if (targetConnectState == ChatConnectState.NeedsConnection) {
|
||||
Modifier.size(80.dp).clipToBounds()
|
||||
} else responsiveLayout.avatarSize?.let { size ->
|
||||
Modifier
|
||||
.size(size)
|
||||
.clipToBounds()
|
||||
@@ -4354,8 +4345,9 @@ fun ChatScreen(
|
||||
fallbackModelDetail,
|
||||
serverDefaultModelDetail,
|
||||
hasModelChoices,
|
||||
isGatewayTransport,
|
||||
) {
|
||||
if (!hasModelChoices && fallbackModelDetail.isNullOrBlank()) {
|
||||
if (!isGatewayTransport && !hasModelChoices && fallbackModelDetail.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
buildList {
|
||||
@@ -4428,7 +4420,7 @@ fun ChatScreen(
|
||||
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
|
||||
contentDescription = stringResource(R.string.cd_select_model),
|
||||
options = it,
|
||||
enabled = chatReady && !isStreaming && it.size > 1,
|
||||
enabled = chatReady && !isStreaming && (isGatewayTransport || it.size > 1),
|
||||
)
|
||||
}
|
||||
val normalizedEffort = normalizeReasoningEffortForInput(selectedReasoningEffort)
|
||||
@@ -4794,7 +4786,9 @@ fun ChatScreen(
|
||||
if (showModelSheet) {
|
||||
ModelPickerSheet(
|
||||
options = modelPickerOptions,
|
||||
loading = modelOptionsLoading,
|
||||
refreshing = modelOptionsRefreshing,
|
||||
error = modelOptionsError,
|
||||
onRefresh = {
|
||||
chatViewModel.refreshModelOptions(refresh = true, catalogOnly = true)
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -265,7 +265,7 @@ fun ConnectionDetailScreen(
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (tabs.size > 1) {
|
||||
TabRow(selectedTabIndex = safeIndex) {
|
||||
ScrollableTabRow(selectedTabIndex = safeIndex, edgePadding = 0.dp) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = safeIndex == index,
|
||||
@@ -276,7 +276,7 @@ fun ConnectionDetailScreen(
|
||||
DetailTab.Routes -> stringResource(R.string.detail_tab_routes)
|
||||
DetailTab.Access -> stringResource(R.string.detail_tab_access)
|
||||
DetailTab.Advanced -> stringResource(R.string.detail_tab_advanced)
|
||||
})
|
||||
}, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -380,8 +380,8 @@ fun ConnectionDetailScreen(
|
||||
if (showDashboardEditor) {
|
||||
DashboardAddressEditorDialog(
|
||||
initialUrl = connection.resolvedDashboardUrl,
|
||||
onSave = { dashboardUrl, onResult ->
|
||||
connectionViewModel.updateDashboardAddress(dashboardUrl, onResult)
|
||||
onSave = { dashboardUrl, httpConsentOrigin, onResult ->
|
||||
connectionViewModel.updateDashboardAddress(dashboardUrl, httpConsentOrigin, onResult)
|
||||
},
|
||||
onDismiss = { showDashboardEditor = false },
|
||||
)
|
||||
|
||||
+28
-18
@@ -53,6 +53,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
@@ -419,6 +420,29 @@ private fun ConnectionListCard(
|
||||
label = "connectionCardBorder",
|
||||
)
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val stackActions = configuration.screenWidthDp < 360 || configuration.fontScale > 1.2f
|
||||
val showSwitch = onSwitch != null || isSwitching || justSwitched
|
||||
val switchAction: @Composable (Modifier) -> Unit = { actionModifier ->
|
||||
OutlinedButton(
|
||||
modifier = actionModifier,
|
||||
onClick = { onSwitch?.invoke() },
|
||||
enabled = !isSwitching && !justSwitched,
|
||||
) {
|
||||
when {
|
||||
isSwitching -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
justSwitched -> {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
else -> Text(stringResource(R.string.conn_switch))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -500,30 +524,16 @@ private fun ConnectionListCard(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onSwitch != null || isSwitching || justSwitched) {
|
||||
OutlinedButton(
|
||||
onClick = { onSwitch?.invoke() },
|
||||
enabled = !isSwitching && !justSwitched,
|
||||
) {
|
||||
when {
|
||||
isSwitching -> {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
justSwitched -> {
|
||||
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
else -> Text(stringResource(R.string.conn_switch))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showSwitch && !stackActions) switchAction(Modifier)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (showSwitch && stackActions) {
|
||||
switchAction(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4475,7 +4475,8 @@ internal fun summarizeObjectItem(
|
||||
)
|
||||
}
|
||||
|
||||
val title = obj.stringField("name")
|
||||
val title = obj.stringField("display_name")?.takeIf(String::isNotBlank)
|
||||
?: obj.stringField("name")
|
||||
?: obj.stringField("id")
|
||||
?: obj.stringField("title")
|
||||
?: fallbackTitle
|
||||
|
||||
@@ -317,7 +317,7 @@ fun DashboardSignInScreen(
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: status.authProviderDetails
|
||||
authFlows = status.authFlows
|
||||
var session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
var session = client.currentSession().getOrNull()
|
||||
var ticketAvailable = if (session?.authenticated == true) {
|
||||
client.requestWsTicket().isSuccess
|
||||
} else {
|
||||
@@ -359,10 +359,12 @@ fun DashboardSignInScreen(
|
||||
gatewayTicketAvailable = ticketAvailable,
|
||||
)
|
||||
if (
|
||||
!status.authRequired ||
|
||||
session?.let { dashboardAuthenticationReady(it, ticketAvailable) } == true
|
||||
) {
|
||||
finishAuthentication()
|
||||
} else if (!status.authRequired) {
|
||||
actionMessage = resources.getString(R.string.dashboard_local_auth_help)
|
||||
actionIsError = true
|
||||
}
|
||||
} finally {
|
||||
loading = false
|
||||
@@ -435,11 +437,6 @@ fun DashboardSignInScreen(
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
embeddedFallbackFromNative = true
|
||||
oauthProvider = provider
|
||||
return
|
||||
}
|
||||
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
if (authClient == null) {
|
||||
embeddedFallbackFromNative = true
|
||||
|
||||
@@ -86,6 +86,10 @@ fun PermissionsStatusScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenBridge: () -> Unit = {},
|
||||
) {
|
||||
var showVoiceOverlaySetup by remember { mutableStateOf(false) }
|
||||
if (showVoiceOverlaySetup) com.hermesandroid.relay.voice.VoiceOverlaySetupDialog(
|
||||
onDismiss = { showVoiceOverlaySetup = false },
|
||||
)
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var status by remember { mutableStateOf(AppPermissionStatusProbe.snapshot(context)) }
|
||||
@@ -209,6 +213,21 @@ fun PermissionsStatusScreen(
|
||||
)
|
||||
}
|
||||
|
||||
PermissionSection(
|
||||
title = stringResource(R.string.voice_overlay_setup_title),
|
||||
subtitle = stringResource(R.string.voice_overlay_settings_hint),
|
||||
) {
|
||||
PermissionStatusRow(
|
||||
icon = Icons.Filled.PictureInPicture,
|
||||
title = stringResource(R.string.perms_display_over_apps),
|
||||
subtitle = stringResource(R.string.voice_overlay_setup_body),
|
||||
badge = stringResource(R.string.perms_badge_optional),
|
||||
statusLabel = optionalStatus(context, status.overlayPermitted),
|
||||
granted = status.overlayPermitted,
|
||||
onClick = { showVoiceOverlaySetup = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildFlavor.isSideload) {
|
||||
SideloadPermissionsSection(
|
||||
status = status,
|
||||
|
||||
@@ -1287,7 +1287,7 @@ private fun ProfileLockDialog(
|
||||
HorizontalDivider()
|
||||
// Server default option.
|
||||
ProfileLockOptionRow(
|
||||
label = stringResource(R.string.settings_server_default),
|
||||
label = stringResource(R.string.profile_follow_server_default),
|
||||
secondary = stringResource(R.string.settings_use_default_profile),
|
||||
selected = lockedIsServerDefault,
|
||||
onSelect = { onLock(null) },
|
||||
|
||||
@@ -642,6 +642,7 @@ private fun ProviderUsageDisplaySettings(
|
||||
"openai-codex" to "Codex",
|
||||
"nous" to "Nous",
|
||||
"opencode-go" to "OpenCode Go",
|
||||
"supergrok" to "SuperGrok",
|
||||
)
|
||||
} else {
|
||||
providers.map { it.id to it.displayName }
|
||||
|
||||
@@ -582,6 +582,7 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
|
||||
VoiceSettingsSection.Listening -> {
|
||||
com.hermesandroid.relay.voice.VoiceOverlaySettingsCard()
|
||||
GlobalVoiceControlsCard(
|
||||
voiceSettings = voiceSettings,
|
||||
prefsRepo = prefsRepo,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.hermesandroid.relay.data.AppearancePreferences
|
||||
import com.hermesandroid.relay.data.CustomThemePreset
|
||||
import com.hermesandroid.relay.data.PersistedAppearance
|
||||
|
||||
/**
|
||||
* Maps the persisted appearance preference onto AppCompat's night mode.
|
||||
*
|
||||
* Compose paints its own palette, while the activity and platform surfaces use
|
||||
* Theme.AppCompat.DayNight. Both resolve from the same saved appearance.
|
||||
*/
|
||||
internal object AppearanceNightMode {
|
||||
private val VALID_PREFERENCES = setOf("auto", "light", "dark")
|
||||
|
||||
fun normalizePreference(raw: String?): String =
|
||||
raw?.takeIf { it in VALID_PREFERENCES } ?: "auto"
|
||||
|
||||
fun nightModeFor(
|
||||
themePreference: String,
|
||||
themeMode: ThemeMode,
|
||||
customTheme: CustomThemePreset? = null,
|
||||
): Int {
|
||||
customTheme?.let { preset ->
|
||||
return if (preset.isDark) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES
|
||||
} else {
|
||||
AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
}
|
||||
return when (themeMode) {
|
||||
ThemeMode.DARK_ONLY -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
ThemeMode.LIGHT_ONLY -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
ThemeMode.BOTH -> when (normalizePreference(themePreference)) {
|
||||
"light" -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun nightModeFor(appearance: PersistedAppearance): Int {
|
||||
val appTheme = appearance.customTheme?.toAppTheme() ?: AppThemes.byId(appearance.appThemeId)
|
||||
return nightModeFor(
|
||||
themePreference = appearance.customTheme?.mode ?: appearance.themePreference,
|
||||
themeMode = appTheme.mode,
|
||||
customTheme = appearance.customTheme,
|
||||
)
|
||||
}
|
||||
|
||||
fun nightModeFor(preferences: Preferences): Int =
|
||||
nightModeFor(AppearancePreferences.decode(preferences))
|
||||
|
||||
/** Apply only when the mode actually changes — avoids redundant uiMode churn. */
|
||||
fun apply(nightMode: Int) {
|
||||
try {
|
||||
if (AppCompatDelegate.getDefaultNightMode() != nightMode) {
|
||||
AppCompatDelegate.setDefaultNightMode(nightMode)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// Robolectric / headless hosts may not support night-mode switches.
|
||||
}
|
||||
}
|
||||
|
||||
fun applyFromPreferences(preferences: Preferences) {
|
||||
apply(nightModeFor(preferences))
|
||||
}
|
||||
|
||||
fun applyFromAppearance(appearance: PersistedAppearance) {
|
||||
apply(nightModeFor(appearance))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -10,7 +11,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.hermesandroid.relay.data.AppearancePreferences
|
||||
import com.hermesandroid.relay.data.CustomThemePreset
|
||||
import com.hermesandroid.relay.data.PersistedAppearance
|
||||
@@ -63,9 +66,22 @@ fun HermesRelayTheme(
|
||||
// call sites observe the active palette. SideEffect runs post-composition,
|
||||
// avoiding a state-write-during-composition; the default theme matches the
|
||||
// façade's initial value, so the common path has no first-frame flash.
|
||||
//
|
||||
// Match system-bar icon contrast to the palette in this window. AppCompat
|
||||
// night mode follows persisted Appearance independently of composable
|
||||
// previews and temporary supervised/loading palettes.
|
||||
val view = LocalView.current
|
||||
SideEffect {
|
||||
RelayRefresh.activePalette = palette
|
||||
RelayRefresh.activeShapeScale = shapeScale
|
||||
if (!view.isInEditMode) {
|
||||
val activity = view.context as? Activity
|
||||
if (activity != null) {
|
||||
val controller = WindowCompat.getInsetsController(activity.window, view)
|
||||
controller.isAppearanceLightStatusBars = !useDarkTheme
|
||||
controller.isAppearanceLightNavigationBars = !useDarkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
|
||||
@@ -72,6 +72,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyBatch
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
|
||||
import com.hermesandroid.relay.data.clarifyQuestionCardKey
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAgentNotice
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
@@ -777,6 +780,9 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
/** Callback to persist session ID — set by RelayApp */
|
||||
var onSessionChanged: ((String?) -> Unit)? = null
|
||||
|
||||
/** Capture a voice-session receipt at live admission, never during history replay. */
|
||||
internal var gatewayInboundSpeechReceiver: (() -> ((String) -> Unit)?)? = null
|
||||
var onFreshDraftSelected: ((String?, SessionTransport) -> Unit)? = null
|
||||
|
||||
/**
|
||||
@@ -1121,6 +1127,9 @@ class ChatViewModel : ViewModel() {
|
||||
modelSelectionRevision.incrementAndGet()
|
||||
_modelSelectionConfirmation.value = null
|
||||
modelOptionsGeneration.incrementAndGet()
|
||||
_modelOptionsLoading.value = false
|
||||
_modelOptionsRefreshing.value = false
|
||||
_modelOptionsError.value = null
|
||||
val cached = modelOptionsByProfile[profileKey]
|
||||
_modelProviders.value = cached?.providers.orEmpty()
|
||||
relayCapabilityGeneration.incrementAndGet()
|
||||
@@ -1138,6 +1147,10 @@ class ChatViewModel : ViewModel() {
|
||||
/** True only during an explicit user-requested dynamic model catalog refresh. */
|
||||
private val _modelOptionsRefreshing = MutableStateFlow(false)
|
||||
val modelOptionsRefreshing: StateFlow<Boolean> = _modelOptionsRefreshing.asStateFlow()
|
||||
private val _modelOptionsLoading = MutableStateFlow(false)
|
||||
val modelOptionsLoading: StateFlow<Boolean> = _modelOptionsLoading.asStateFlow()
|
||||
private val _modelOptionsError = MutableStateFlow<String?>(null)
|
||||
val modelOptionsError: StateFlow<String?> = _modelOptionsError.asStateFlow()
|
||||
|
||||
/** Current gateway model from `model.options`, used when no Android override is active. */
|
||||
private val _gatewayCurrentModel = MutableStateFlow("")
|
||||
@@ -1225,16 +1238,20 @@ class ChatViewModel : ViewModel() {
|
||||
val gateway = gatewayClient ?: run {
|
||||
android.util.Log.i("ChatViewModel", "refreshModelOptions: no gateway client")
|
||||
if (refresh) _modelOptionsRefreshing.value = false
|
||||
_modelOptionsLoading.value = false
|
||||
_modelOptionsError.value = "Gateway unavailable."
|
||||
return
|
||||
}
|
||||
if (refresh && _modelOptionsRefreshing.value) return
|
||||
if (_modelOptionsRefreshing.value) return
|
||||
if (refresh) _modelOptionsRefreshing.value = true
|
||||
_modelOptionsLoading.value = true
|
||||
_modelOptionsError.value = null
|
||||
val generation = modelOptionsGeneration.incrementAndGet()
|
||||
val profileKey = modelOptionsProfileKey()
|
||||
viewModelScope.launch {
|
||||
gateway.modelOptions(refresh = refresh).fold(
|
||||
onSuccess = {
|
||||
if (!isCurrentModelOptionsResponse(
|
||||
if (gatewayClient !== gateway || !isCurrentModelOptionsResponse(
|
||||
generation,
|
||||
modelOptionsGeneration.get(),
|
||||
profileKey,
|
||||
@@ -1267,12 +1284,22 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onFailure = {
|
||||
android.util.Log.w("ChatViewModel", "model.options failed: ${it.message}")
|
||||
if (refresh) {
|
||||
_transientNotice.tryEmit("Couldn't refresh models: ${it.message ?: "unknown error"}")
|
||||
if (gatewayClient === gateway && isCurrentModelOptionsResponse(
|
||||
generation, modelOptionsGeneration.get(),
|
||||
profileKey, modelOptionsProfileKey(),
|
||||
)
|
||||
) {
|
||||
_modelOptionsError.value = it.message ?: "Model catalog unavailable."
|
||||
if (refresh) {
|
||||
_transientNotice.tryEmit("Couldn't refresh models: ${it.message ?: "unknown error"}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
if (refresh) _modelOptionsRefreshing.value = false
|
||||
if (gatewayClient === gateway && generation == modelOptionsGeneration.get()) {
|
||||
_modelOptionsLoading.value = false
|
||||
if (refresh) _modelOptionsRefreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1507,6 +1534,9 @@ class ChatViewModel : ViewModel() {
|
||||
) {
|
||||
val client = apiClient ?: return
|
||||
val generation = modelOptionsGeneration.incrementAndGet()
|
||||
_modelOptionsLoading.value = false
|
||||
_modelOptionsRefreshing.value = false
|
||||
_modelOptionsError.value = null
|
||||
val profileKey = modelOptionsProfileKey()
|
||||
viewModelScope.launch {
|
||||
val providerResult = client.getProviderModelOptions()
|
||||
@@ -2116,6 +2146,9 @@ class ChatViewModel : ViewModel() {
|
||||
// what the agent actually runs. The next session.create then binds the
|
||||
// profile's own model.
|
||||
modelOptionsGeneration.incrementAndGet()
|
||||
_modelOptionsLoading.value = false
|
||||
_modelOptionsRefreshing.value = false
|
||||
_modelOptionsError.value = null
|
||||
_modelProviders.value = emptyList()
|
||||
_apiModelOptions.value = emptyList()
|
||||
_availableModels.value = emptyList()
|
||||
@@ -2726,6 +2759,10 @@ class ChatViewModel : ViewModel() {
|
||||
val previousClient = gatewayClient
|
||||
val changed = previousClient !== client
|
||||
if (changed) {
|
||||
modelOptionsGeneration.incrementAndGet()
|
||||
_modelOptionsLoading.value = false
|
||||
_modelOptionsRefreshing.value = false
|
||||
_modelOptionsError.value = null
|
||||
clearProjectedBackgroundProcesses()
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
@@ -2987,9 +3024,10 @@ class ChatViewModel : ViewModel() {
|
||||
_reasoningDisplay.value = null
|
||||
}
|
||||
}
|
||||
if (changed && client != null && streamRecovery != null &&
|
||||
AppForegroundTracker.isForeground.value
|
||||
) {
|
||||
// Visibility can arrive before the runtime binder publishes its client.
|
||||
// Start the same socket-only warmup in either ordering; prewarmGateway
|
||||
// retains the directory barrier and exact-checkpoint ownership rules.
|
||||
if (changed && client != null && chatVisible) {
|
||||
prewarmGateway()
|
||||
}
|
||||
if (changed && client != null) requestSessionActivityRefresh()
|
||||
@@ -3066,6 +3104,7 @@ class ChatViewModel : ViewModel() {
|
||||
var boundHandle: ActiveTurnHandle? = null
|
||||
var inputTokens: Int? = null
|
||||
var outputTokens: Int? = null
|
||||
var speechReceiver: ((String) -> Unit)? = null
|
||||
|
||||
fun ownsTranscriptSession(): Boolean =
|
||||
chatHandler === handler && handler.currentSessionId.value == storedSessionId
|
||||
@@ -3144,9 +3183,9 @@ class ChatViewModel : ViewModel() {
|
||||
onTurnComplete = {
|
||||
if (acceptsEvent()) handler.onTurnComplete(messageId)
|
||||
},
|
||||
// Server-initiated turns already take the bounded durable-history
|
||||
// reconcile below on every completion.
|
||||
onReconcileRequired = { },
|
||||
// Recovery can settle a partial live bubble before durable history
|
||||
// arrives. That history repairs Chat, but is not a speech receipt.
|
||||
onReconcileRequired = { speechReceiver = null },
|
||||
onComplete = {
|
||||
val canWriteTranscript = acceptsEvent()
|
||||
val expectedText = handler.messages.value
|
||||
@@ -3164,7 +3203,9 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
finalizeTurnSideEffects(handler, messageId)
|
||||
AppAnalytics.onStreamComplete(inputTokens, outputTokens)
|
||||
speechReceiver?.invoke(expectedText.orEmpty())
|
||||
}
|
||||
speechReceiver = null
|
||||
scheduleGatewayHistoryReconcile(
|
||||
storedSessionId = storedSessionId,
|
||||
expectedAssistantText = expectedText,
|
||||
@@ -3267,6 +3308,9 @@ class ChatViewModel : ViewModel() {
|
||||
baselineAssistantCount = handler.messages.value.count {
|
||||
it.role == MessageRole.ASSISTANT && !it.clientOnly
|
||||
}
|
||||
if (queuedRecovery == null) {
|
||||
speechReceiver = gatewayInboundSpeechReceiver?.invoke()
|
||||
}
|
||||
boundHandle = handle
|
||||
accepted = true
|
||||
activeStream = handle
|
||||
@@ -3651,7 +3695,7 @@ class ChatViewModel : ViewModel() {
|
||||
private val backgroundPendingInteractions =
|
||||
ConcurrentHashMap<TurnCheckpointKey, BackgroundPendingInteraction>()
|
||||
|
||||
/** Ask cardKeys with a respond RPC in flight — blocks double-taps until it settles. */
|
||||
/** Request-incarnation/card keys with a respond RPC in flight. */
|
||||
private val answeredAskIds = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
@@ -5407,6 +5451,9 @@ class ChatViewModel : ViewModel() {
|
||||
/** Clear server-owned catalogs before a different connection starts loading. */
|
||||
fun resetConnectionCatalogs() {
|
||||
modelOptionsGeneration.incrementAndGet()
|
||||
_modelOptionsLoading.value = false
|
||||
_modelOptionsRefreshing.value = false
|
||||
_modelOptionsError.value = null
|
||||
modelOptionsByProfile.clear()
|
||||
apiSessionModelLocks.clear()
|
||||
_availableSkills.value = emptyList()
|
||||
@@ -6773,7 +6820,18 @@ class ChatViewModel : ViewModel() {
|
||||
existing.ask.kind == ask.kind &&
|
||||
existing.ask.requestId == ask.requestId
|
||||
) {
|
||||
sessionId?.let { maybeNotifyInteraction(it, existing.ask) }
|
||||
if (ask.questions.isNotEmpty()) {
|
||||
val updated = existing.copy(ask = ask.copy(answers = existing.ask.answers + ask.answers))
|
||||
_pendingAsk.value = updated
|
||||
updateClarifyBatchCard(handler, updated)
|
||||
if (updated.ask.clarifyComplete) {
|
||||
_pendingAsk.value = null
|
||||
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
|
||||
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
_pendingAsk.value?.let { pending -> sessionId?.let { maybeNotifyInteraction(it, pending.ask) } }
|
||||
return
|
||||
}
|
||||
existing?.let { pending ->
|
||||
@@ -6786,8 +6844,11 @@ class ChatViewModel : ViewModel() {
|
||||
publishBackgroundSessionActivity()
|
||||
}
|
||||
val now = restored?.receivedAt ?: System.currentTimeMillis()
|
||||
val cardKey = restored?.cardKey ?: ask.requestId
|
||||
val proposedCardKey = restored?.cardKey ?: ask.requestId
|
||||
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
|
||||
val cardKey = if (restored == null && handler.messages.value.any { message ->
|
||||
message.cards.any { it.id == proposedCardKey }
|
||||
}) "$proposedCardKey-${java.util.UUID.randomUUID()}" else proposedCardKey
|
||||
val expiresAt = ask.timeoutSeconds.takeIf { it > 0 }?.let { now + it * 1_000L }
|
||||
val card = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> HermesCard(
|
||||
@@ -6886,12 +6947,52 @@ class ChatViewModel : ViewModel() {
|
||||
contextKey = contextKey,
|
||||
sessionId = sessionId,
|
||||
receivedAt = now,
|
||||
ownerId = restored?.ownerId ?: java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
if (ask.questions.isNotEmpty()) updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
|
||||
if (ask.clarifyComplete) {
|
||||
_pendingAsk.value = null
|
||||
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
return
|
||||
}
|
||||
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), true) }
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
sessionId?.let { maybeNotifyInteraction(it, ask) }
|
||||
}
|
||||
|
||||
private fun updateClarifyBatchCard(handler: ChatHandler, pending: PendingAsk) {
|
||||
val ask = pending.ask
|
||||
if (ask.questions.isEmpty()) return
|
||||
handler.updateAskCardMessage(
|
||||
pending.messageId,
|
||||
HermesCard(
|
||||
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
|
||||
title = appContext?.getString(R.string.chat_approval_clarify_title) ?: "Hermes needs clarification",
|
||||
accent = HermesCard.Accents.INFO,
|
||||
id = pending.cardKey,
|
||||
clarifyBatch = HermesCardClarifyBatch(
|
||||
questions = ask.questions.map { question ->
|
||||
val key = clarifyQuestionCardKey(pending.cardKey, question.qid)
|
||||
HermesCardClarifyQuestion(
|
||||
key = key,
|
||||
question = question.question,
|
||||
input = HermesCardInput(
|
||||
kind = if (question.choices.isEmpty()) HermesCardInput.Kinds.TEXT else HermesCardInput.Kinds.CHOICE,
|
||||
choices = question.choices,
|
||||
multiSelect = question.multiSelect,
|
||||
allowFreeText = true,
|
||||
),
|
||||
answer = ask.answers[question.qid],
|
||||
submitting = "${pending.ownerId}:$key" in answeredAskIds,
|
||||
)
|
||||
},
|
||||
expiresAtMillis = ask.timeoutSeconds.takeIf { it > 0 }?.let { pending.receivedAt + it * 1_000L },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervised Chat never exposes approval, clarification, sudo, or secret
|
||||
* inputs. Settle the upstream interaction immediately with its safest
|
||||
@@ -6965,21 +7066,35 @@ class ChatViewModel : ViewModel() {
|
||||
fun answerAsk(messageId: String, cardKey: String, value: String) {
|
||||
val handler = chatHandler ?: return
|
||||
val pending = _pendingAsk.value
|
||||
val question = pending?.ask?.questions?.firstOrNull {
|
||||
clarifyQuestionCardKey(pending.cardKey, it.qid) == cardKey
|
||||
}
|
||||
if (pending == null ||
|
||||
pending.cardKey != cardKey ||
|
||||
pending.messageId != messageId ||
|
||||
(if (pending.ask.questions.isNotEmpty()) question == null else pending.cardKey != cardKey) ||
|
||||
pending.contextKey != activeProfileContextKey ||
|
||||
pending.sessionId != handler.currentSessionId.value
|
||||
) {
|
||||
handler.addSystemNotice("This request is no longer active.")
|
||||
return
|
||||
}
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && value.isBlank()) return
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && pending.ask.timeoutSeconds > 0 &&
|
||||
System.currentTimeMillis() >= pending.receivedAt + pending.ask.timeoutSeconds * 1_000L
|
||||
) {
|
||||
expirePendingAsk(GatewayAskExpiry(pending.ask.kind, pending.ask.requestId))
|
||||
return
|
||||
}
|
||||
if (question != null && question.qid in pending.ask.answers) return
|
||||
val gateway = gatewayClient
|
||||
if (gateway == null) {
|
||||
emitError(Exception("Gateway is not connected"), context = "send_message")
|
||||
return
|
||||
}
|
||||
// In-flight guard: one respond RPC per card at a time.
|
||||
if (!answeredAskIds.add(cardKey)) return
|
||||
// Include the request incarnation so a late completion cannot unlock a reused id.
|
||||
val flightKey = "${pending.ownerId}:$cardKey"
|
||||
if (!answeredAskIds.add(flightKey)) return
|
||||
updateClarifyBatchCard(handler, pending)
|
||||
val ask = pending.ask
|
||||
val stampValue = when (ask.kind) {
|
||||
// Empty sudo password = decline — stamp matches the Deny action
|
||||
@@ -6991,11 +7106,19 @@ class ChatViewModel : ViewModel() {
|
||||
else -> value
|
||||
}
|
||||
viewModelScope.launch {
|
||||
fun ownsResponse(): Boolean = chatHandler === handler && gatewayClient === gateway &&
|
||||
activeProfileContextKey == pending.contextKey &&
|
||||
handler.currentSessionId.value == pending.sessionId &&
|
||||
_pendingAsk.value?.ownerId == pending.ownerId
|
||||
if (!ownsResponse()) {
|
||||
answeredAskIds.remove(flightKey)
|
||||
return@launch
|
||||
}
|
||||
val requestId = ask.requestId
|
||||
val result = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = value)
|
||||
GatewayAsk.Kind.CLARIFY ->
|
||||
requestId?.let { gateway.respondClarify(it, value) }
|
||||
requestId?.let { gateway.respondClarify(it, value.trim(), question?.qid) }
|
||||
?: Result.failure(GatewayRpcException("ask has no request id"))
|
||||
GatewayAsk.Kind.SUDO ->
|
||||
requestId?.let { gateway.respondSudo(it, value) }
|
||||
@@ -7006,6 +7129,8 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
answeredAskIds.remove(flightKey)
|
||||
if (!ownsResponse()) return@fold
|
||||
if (response == GatewayAskResponse.EXPIRED) {
|
||||
expirePendingAsk(
|
||||
GatewayAskExpiry(
|
||||
@@ -7015,10 +7140,24 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
return@fold
|
||||
}
|
||||
if (question != null) {
|
||||
val current = requireNotNull(_pendingAsk.value)
|
||||
val updated = current.copy(ask = current.ask.copy(answers = current.ask.answers + (question.qid to value.trim())))
|
||||
updateClarifyBatchCard(handler, updated)
|
||||
if (updated.ask.questions.all { it.qid in updated.ask.answers }) {
|
||||
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
|
||||
_pendingAsk.value = null
|
||||
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
} else {
|
||||
_pendingAsk.value = updated
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
return@fold
|
||||
}
|
||||
// Collapse only after the server confirms — a failed RPC
|
||||
// must leave the card answerable for a retry.
|
||||
handler.recordCardDispatch(pending.messageId, cardKey, stampValue)
|
||||
if (_pendingAsk.value === pending) {
|
||||
if (ownsResponse()) {
|
||||
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
|
||||
_pendingAsk.value = null
|
||||
activeTurnCheckpointKey()?.let {
|
||||
@@ -7028,7 +7167,9 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
answeredAskIds.remove(cardKey)
|
||||
answeredAskIds.remove(flightKey)
|
||||
if (!ownsResponse()) return@fold
|
||||
updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
|
||||
emitError(e, context = "send_message")
|
||||
},
|
||||
)
|
||||
@@ -7053,7 +7194,6 @@ class ChatViewModel : ViewModel() {
|
||||
activeTurnCheckpointKey()?.let {
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
|
||||
}
|
||||
answeredAskIds.remove(pending.cardKey)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
chatHandler?.recordCardDispatch(
|
||||
pending.messageId,
|
||||
@@ -7076,7 +7216,9 @@ class ChatViewModel : ViewModel() {
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY) {
|
||||
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, HermesCardDispatch.EXPIRED_STAMP)
|
||||
} else if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
|
||||
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, "deny")
|
||||
}
|
||||
}
|
||||
@@ -7684,6 +7826,9 @@ class ChatViewModel : ViewModel() {
|
||||
text = ask.ask.text,
|
||||
choices = ask.ask.choices,
|
||||
multiSelect = ask.ask.multiSelect,
|
||||
questions = ask.ask.questions,
|
||||
answers = ask.ask.answers,
|
||||
ownerId = ask.ownerId,
|
||||
smartDenied = ask.ask.smartDenied,
|
||||
envVar = ask.ask.envVar,
|
||||
timeoutSeconds = ask.ask.timeoutSeconds,
|
||||
@@ -7892,6 +8037,8 @@ class ChatViewModel : ViewModel() {
|
||||
text = saved.text,
|
||||
choices = saved.choices,
|
||||
multiSelect = saved.multiSelect,
|
||||
questions = saved.questions,
|
||||
answers = saved.answers,
|
||||
smartDenied = saved.smartDenied,
|
||||
envVar = saved.envVar,
|
||||
timeoutSeconds = saved.timeoutSeconds,
|
||||
@@ -9666,8 +9813,8 @@ class ChatViewModel : ViewModel() {
|
||||
* 0. **Gateway transport** — the server owns the persona end-to-end: the
|
||||
* session is bound to the selected profile (SOUL applied server-side) and
|
||||
* the personality overlay rides `config.set`/`ephemeral_system_prompt`.
|
||||
* The phone sends NO persona/profile prompt (only the phone-status block)
|
||||
* so it can't double-apply. Cases 1–3 are the SSE-fallback rules.
|
||||
* The phone sends no per-turn system context on Gateway.
|
||||
* Cases 1–3 are the explicit API-only transport rules.
|
||||
* 1. **Selected profile with a non-blank [Profile.systemMessage]** —
|
||||
* profile wins outright. Profile is a richer, newer concept than
|
||||
* personality: it bundles model + persona (from the profile's
|
||||
@@ -9682,15 +9829,12 @@ class ChatViewModel : ViewModel() {
|
||||
* configured default.
|
||||
*
|
||||
* The phone-status [appContextSettings] block is appended to whichever
|
||||
* of the above wins (or sent alone in case 3), so the LLM always sees
|
||||
* phone state regardless of persona source.
|
||||
* of the API-only cases above wins (or sent alone in case 3).
|
||||
*/
|
||||
/**
|
||||
* The exact `system_message` (`ephemeral_system_prompt`) injected for a
|
||||
* turn, split into labeled blocks. Single source of truth shared by
|
||||
* [startStream] (which sends [combinedSystemMessage]) and
|
||||
* [previewInjectedContext] (which renders it in the chat audit sheet) so
|
||||
* the preview can never drift from what is actually sent.
|
||||
* Context prepared for the selected transport, split into labeled blocks.
|
||||
* This is a preview, not a delivery receipt or the complete agent prompt.
|
||||
* Gateway has no general per-turn system-context slot.
|
||||
*/
|
||||
data class InjectedContext(
|
||||
val personaPrompt: String?,
|
||||
@@ -9721,6 +9865,8 @@ class ChatViewModel : ViewModel() {
|
||||
* audit UI labels that block "added server-side".
|
||||
*/
|
||||
val personaOwnedServerSide: Boolean,
|
||||
/** False on Gateway; unsupported blocks are excluded from the payload and preview. */
|
||||
val perTurnContextSupported: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -9756,11 +9902,14 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
_sseToolNames.value
|
||||
}
|
||||
val appContextRaw = buildPromptBlock(
|
||||
settings = appContextSettings,
|
||||
snapshot = capturePhoneSnapshot(),
|
||||
availableTools = availableTools,
|
||||
)
|
||||
val appContextRaw = if (!gateway && appContextSettings.master) {
|
||||
buildPromptBlock(
|
||||
settings = appContextSettings,
|
||||
snapshot = capturePhoneSnapshot(),
|
||||
availableTools = availableTools,
|
||||
)
|
||||
} else null
|
||||
val interfaceContext = interfaceContextPrompt?.takeIf { !gateway && it.isNotBlank() }
|
||||
// Gateway has no per-turn system slot. SSE carries the standard
|
||||
// Dashboard route first, then the optional Relay enhancement.
|
||||
val upstreamMediaAvailable = dashboardMediaClientProvider?.invoke() != null
|
||||
@@ -9772,25 +9921,26 @@ class ChatViewModel : ViewModel() {
|
||||
// block (a stable environment fact, like phone status) and before the
|
||||
// per-turn interface context. The per-block fields below null out blanks
|
||||
// only for display.
|
||||
val combined = listOfNotNull(personaPrompt, appContextRaw, mediaCapability, interfaceContextPrompt)
|
||||
val combined = listOfNotNull(personaPrompt, appContextRaw, mediaCapability, interfaceContext)
|
||||
.joinToString("\n\n")
|
||||
.ifBlank { null }
|
||||
return InjectedContext(
|
||||
personaPrompt = personaPrompt,
|
||||
appContext = appContextRaw?.takeIf { it.isNotBlank() },
|
||||
interfaceContext = interfaceContextPrompt?.takeIf { it.isNotBlank() },
|
||||
interfaceContext = interfaceContext,
|
||||
mediaCapability = mediaCapability,
|
||||
relayServerBlocks = relayServerBlocks,
|
||||
relayMediaAvailable = relayMediaAvailable,
|
||||
combinedSystemMessage = combined,
|
||||
transport = streamingEndpoint,
|
||||
personaOwnedServerSide = gateway,
|
||||
perTurnContextSupported = !gateway,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live audit snapshot of what the agent will be injected with on the next
|
||||
* turn — rendered by the chat context sheet. Per-turn voice context is null
|
||||
* Preview of context supported by the selected transport, plus separately
|
||||
* reported Relay configuration. Per-turn voice context is null
|
||||
* here (it's set only on a spoken turn); the UI notes that.
|
||||
*/
|
||||
fun previewInjectedContext(): InjectedContext {
|
||||
@@ -11842,6 +11992,7 @@ data class PendingAsk(
|
||||
/** Stored session id within [contextKey]; approvals are session-scoped upstream. */
|
||||
val sessionId: String?,
|
||||
val receivedAt: Long = System.currentTimeMillis(),
|
||||
val ownerId: String = java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.ui.theme.AppFont
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
import com.hermesandroid.relay.ui.theme.AppearanceNightMode
|
||||
import com.hermesandroid.relay.ui.theme.normalizeAccentHex
|
||||
import com.hermesandroid.relay.ui.theme.AppearanceShape
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetImporter
|
||||
@@ -31,6 +32,7 @@ import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.auth.PairedSession
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.AppearancePreferences
|
||||
import com.hermesandroid.relay.data.PersistedAppearance
|
||||
import com.hermesandroid.relay.data.CustomThemePreset
|
||||
import com.hermesandroid.relay.data.DataManager
|
||||
import com.hermesandroid.relay.data.DemoContent
|
||||
@@ -64,6 +66,10 @@ import com.hermesandroid.relay.data.LEGACY_AUTHENTICATED_DASHBOARD_ROUTE_ROLE
|
||||
import com.hermesandroid.relay.data.ConnectionValidation
|
||||
import com.hermesandroid.relay.data.computeConnectionSecurity
|
||||
import com.hermesandroid.relay.data.normalizeCredentialFreeAuthenticatedDashboardOrigin
|
||||
import com.hermesandroid.relay.data.dashboardHttpConsentMatches
|
||||
import com.hermesandroid.relay.data.dashboardHttpOrigin
|
||||
import com.hermesandroid.relay.data.updatedDashboardHttpConsents
|
||||
import com.hermesandroid.relay.network.upstream.verifySetup
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.BotChatTarget
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
@@ -515,7 +521,7 @@ internal fun resolveEffectiveDashboardUrl(
|
||||
): String {
|
||||
if (connection == null) return ""
|
||||
connection.authenticatedDashboardOrigin
|
||||
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
|
||||
?.let { normalizeCredentialFreeAuthenticatedDashboardOrigin(it, connection.dashboardHttpConsentOrigins) }
|
||||
?.let { return it }
|
||||
// The resolver publishes independently of the active connection. During a
|
||||
// switch its last winner can still belong to the outgoing installation.
|
||||
@@ -571,12 +577,14 @@ internal fun normalizeDashboardAddressForEdit(raw: String): String? {
|
||||
internal fun publicDashboardAddressRequiresHttps(
|
||||
role: String?,
|
||||
normalizedAddress: String,
|
||||
httpConsentOrigins: Set<String> = emptySet(),
|
||||
): Boolean {
|
||||
val uri = runCatching { URI(normalizedAddress) }.getOrNull() ?: return false
|
||||
val explicitRole = role?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
|
||||
val inferredRole = Connection.inferRouteRole(normalizedAddress)
|
||||
return uri.scheme.equals("http", ignoreCase = true) &&
|
||||
(explicitRole == "public" || inferredRole == "public")
|
||||
(explicitRole == "public" || inferredRole == "public") &&
|
||||
!dashboardHttpConsentMatches(normalizedAddress, httpConsentOrigins)
|
||||
}
|
||||
|
||||
internal fun standardApiDashboardSecurityError(
|
||||
@@ -596,6 +604,7 @@ internal data class PendingConnectionDraft(
|
||||
val previousConnectionId: String?,
|
||||
var pairingPayload: com.hermesandroid.relay.ui.components.HermesPairingPayload? = null,
|
||||
var label: String? = null,
|
||||
val dashboardHttpConsentOrigins: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
/** Hide user-confirmed removals immediately while serialized cleanup finishes. */
|
||||
@@ -1178,9 +1187,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private val endpointResolver = EndpointResolver(
|
||||
httpClient = endpointProbeClient,
|
||||
clientForCandidate = { candidate ->
|
||||
candidate.pluginProxyRoutesOrNull()?.let { proxy ->
|
||||
val tokenProvider = { (authManager.authState.value as? AuthState.Paired)?.token }
|
||||
clientForCandidate = { candidate, probeRequestUrl ->
|
||||
val tokenProvider = { (authManager.authState.value as? AuthState.Paired)?.token }
|
||||
candidate.pluginProxyRoutesOrNull()?.takeIf { proxy ->
|
||||
proxy.authority.equals(
|
||||
probeRequestUrl?.let(com.hermesandroid.relay.auth.CertPinStore::hostPortFromUrl),
|
||||
ignoreCase = true,
|
||||
)
|
||||
}?.let { proxy ->
|
||||
if (candidate.hermesReachRouteOrNull() != null) {
|
||||
buildHermesReachClient(
|
||||
baseBuilder = endpointProbeClient.newBuilder(),
|
||||
@@ -1195,6 +1209,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
sessionTokenProvider = tokenProvider,
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
// LAN sometimes stores Secure Link relay URL (wss://…:9443/…).
|
||||
// Pin ONLY when this probe actually hits that authority —
|
||||
// not for plain dashboard :9119 / API :8642 on the same
|
||||
// candidate (pin client's authority guard → IOException).
|
||||
val requestUrl = probeRequestUrl?.trim().orEmpty()
|
||||
if (requestUrl.isBlank()) return@run null
|
||||
val targetAuthority = com.hermesandroid.relay.auth.CertPinStore
|
||||
.hostPortFromUrl(requestUrl)
|
||||
?: return@run null
|
||||
activeConnection.value?.routeCandidates.orEmpty()
|
||||
.mapNotNull { it.pluginProxyRoutesOrNull() }
|
||||
.firstOrNull { routes ->
|
||||
routes.authority.equals(targetAuthority, ignoreCase = true)
|
||||
}
|
||||
?.let { routes ->
|
||||
buildPluginProxyClient(
|
||||
baseBuilder = endpointProbeClient.newBuilder(),
|
||||
routes = routes,
|
||||
sessionTokenProvider = tokenProvider,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
context = application,
|
||||
@@ -1285,6 +1321,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
(authManager.authState.value as? AuthState.Paired)?.token
|
||||
},
|
||||
dashboardHttpClientProvider = ::dashboardHttpClientForRelayIngress,
|
||||
// Secure Link relay HTTP must use the same pin TrustManager as WSS;
|
||||
// default OkHttp only has the system CA store and rejects the leaf.
|
||||
pluginProxyHttpClientProvider = { url ->
|
||||
pluginProxyClientForUrl(
|
||||
url = url,
|
||||
baseClient = relayOkHttp,
|
||||
includeRelaySessionHeader = false,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// Pairing-management collaborator — owns the paired-devices list
|
||||
@@ -1331,6 +1376,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
?: connection?.dashboardAuthProviders.orEmpty(),
|
||||
)
|
||||
},
|
||||
dashboardHttpConsentOriginsProvider = { cid -> dashboardHttpConsentsFor(cid) },
|
||||
pinnedClientProvider = { url, base ->
|
||||
pluginProxyClientForUrl(url, base, includeRelaySessionHeader = false)
|
||||
},
|
||||
@@ -1553,7 +1599,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
?: connectionManager.activeRelayEndpoint.value?.relay?.url
|
||||
?: autoRelayUrlSnapshot()
|
||||
|
||||
private fun pluginProxyClientForUrl(
|
||||
internal fun pluginProxyClientForUrl(
|
||||
url: String,
|
||||
baseClient: OkHttpClient? = null,
|
||||
includeRelaySessionHeader: Boolean = true,
|
||||
@@ -1788,8 +1834,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
origin: String,
|
||||
allowMissingInstallIdentity: Boolean = false,
|
||||
): Boolean {
|
||||
val normalized = normalizeAuthenticatedDashboardOrigin(origin) ?: return false
|
||||
val activeId = connectionStore.activeConnectionId.value ?: return false
|
||||
val normalized = normalizeCredentialFreeAuthenticatedDashboardOrigin(
|
||||
origin, dashboardHttpConsentsFor(activeId),
|
||||
) ?: return false
|
||||
val previous = connectionStore.connections.value
|
||||
.firstOrNull { it.id == activeId }
|
||||
?: return false
|
||||
@@ -1808,7 +1856,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
withTimeoutOrNull(8_000L) {
|
||||
val status = verificationClient.getStatus().getOrNull() ?: return@withTimeoutOrNull false
|
||||
candidateInstallId = status.installId
|
||||
!status.authRequired || verificationClient.currentSession().getOrNull()?.authenticated == true
|
||||
verificationClient.currentSession().getOrNull()?.authenticated == true &&
|
||||
verificationClient.requestWsTicket().isSuccess
|
||||
} == true
|
||||
} finally {
|
||||
verificationClient.shutdown()
|
||||
@@ -1868,6 +1917,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun retireNativeDashboardAuthentication(connectionId: String) =
|
||||
upstreamTransport.retireNativeDashboardAuthentication(connectionId)
|
||||
|
||||
private fun dashboardHttpConsentsFor(connectionId: String): Set<String> =
|
||||
pendingConnectionDraft?.takeIf { it.id == connectionId }?.dashboardHttpConsentOrigins
|
||||
?: connectionStore.connections.value.firstOrNull { it.id == connectionId }
|
||||
?.dashboardHttpConsentOrigins.orEmpty()
|
||||
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? =
|
||||
upstreamTransport.nativeDashboardAuthClientForActive(dashboardUrl)
|
||||
|
||||
@@ -2148,10 +2202,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
@Deprecated("Use relayConnectionState", replaceWith = ReplaceWith("relayConnectionState"))
|
||||
val connectionState: StateFlow<ConnectionState> = relayConnectionState
|
||||
|
||||
// One snapshot from the DataStore emission that also releases splash
|
||||
// readiness. The app root must not compose a first frame from separately
|
||||
// hydrated theme, preset, font, and shape StateFlows.
|
||||
private val _appearance = MutableStateFlow(PersistedAppearance())
|
||||
internal val appearance: StateFlow<PersistedAppearance> = _appearance.asStateFlow()
|
||||
|
||||
// Theme preference — light/dark/auto mode axis.
|
||||
val theme: StateFlow<String> = application.relayDataStore.data
|
||||
.map { preferences ->
|
||||
preferences[AppearancePreferences.themeKey] ?: "auto"
|
||||
AppearanceNightMode.normalizePreference(preferences[AppearancePreferences.themeKey])
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
|
||||
|
||||
@@ -2357,6 +2417,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
get() = profileController.effectiveSessionProfileName
|
||||
val effectiveDisplayProfile: StateFlow<Profile?>
|
||||
get() = profileController.effectiveDisplayProfile
|
||||
val serverDefaultDisplayProfile: StateFlow<Profile?>
|
||||
get() = profileController.serverDefaultDisplayProfile
|
||||
|
||||
fun refreshDashboardProfiles() = profileController.refreshDashboardProfiles()
|
||||
fun refreshDeferredProfileMetadata() = profileController.refreshDeferredProfileMetadata()
|
||||
@@ -2868,12 +2930,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// this Home-Assistant-class persistent-connection use case). Mirrors
|
||||
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
|
||||
viewModelScope.launch {
|
||||
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
|
||||
persistent to turns
|
||||
}.distinctUntilChanged().collect { (persistent, turns) ->
|
||||
combine(
|
||||
gatewayKeepAlive,
|
||||
ActiveTurnKeepAliveRegistry.snapshot,
|
||||
AppForegroundTracker.isForeground,
|
||||
) { persistent, turns, foreground ->
|
||||
Triple(persistent, turns, foreground)
|
||||
}.distinctUntilChanged().collect { (persistent, turns, foreground) ->
|
||||
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
|
||||
val ctx = getApplication<Application>()
|
||||
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
|
||||
GatewayKeepAliveService.update(ctx, persistent, turns, foreground)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5096,6 +5162,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
var prevApiKey: String? = null
|
||||
|
||||
application.relayDataStore.data.collect { preferences ->
|
||||
val persistedAppearance = AppearancePreferences.decode(preferences)
|
||||
_appearance.value = persistedAppearance
|
||||
AppearanceNightMode.applyFromAppearance(persistedAppearance)
|
||||
|
||||
// Restore insecure mode
|
||||
val insecure = preferences[KEY_INSECURE_MODE] ?: false
|
||||
connectionManager.setInsecureMode(insecure)
|
||||
@@ -6457,6 +6527,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val signInRequired: Boolean = false,
|
||||
val authenticated: Boolean = false,
|
||||
val voiceAvailability: StandardVoiceAvailability = StandardVoiceAvailability.Unknown,
|
||||
val httpConsentOrigin: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -6466,9 +6537,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
*/
|
||||
fun probeHermesDashboard(
|
||||
address: String,
|
||||
httpConsentOrigin: String? = null,
|
||||
onResult: (DashboardSetupResult) -> Unit,
|
||||
) {
|
||||
val dashboardUrl = Connection.normalizeApiUrlInput(
|
||||
val dashboardUrl = Connection.normalizeDashboardUrlInput(
|
||||
address,
|
||||
defaultPort = Connection.DEFAULT_DASHBOARD_PORT,
|
||||
)
|
||||
@@ -6482,7 +6554,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
)
|
||||
return
|
||||
}
|
||||
if (publicDashboardAddressRequiresHttps(role = null, normalizedAddress = dashboardUrl)) {
|
||||
if (publicDashboardAddressRequiresHttps(null, dashboardUrl, setOfNotNull(httpConsentOrigin))) {
|
||||
onResult(
|
||||
DashboardSetupResult(
|
||||
ok = false,
|
||||
@@ -6493,27 +6565,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
val client = upstreamTransport.dashboardClientForActive(dashboardUrl, setupProbe = true)
|
||||
try {
|
||||
val statusResult = client.getStatus()
|
||||
val status = statusResult.getOrNull()
|
||||
if (status == null) {
|
||||
onResult(
|
||||
DashboardSetupResult(
|
||||
ok = false,
|
||||
dashboardUrl = dashboardUrl,
|
||||
message = statusResult.exceptionOrNull()?.message
|
||||
?: "Hermes was not found at this address",
|
||||
),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val session = if (status.authRequired) {
|
||||
client.currentSession().getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val authenticated = !status.authRequired || session?.authenticated == true
|
||||
val verification = client.verifySetup()
|
||||
val status = verification.status
|
||||
val authenticated = verification.authenticated
|
||||
val voice = when {
|
||||
!authenticated -> StandardVoiceAvailability.SignInRequired
|
||||
client.audioRoutesPresent() -> StandardVoiceAvailability.Ready
|
||||
@@ -6524,9 +6580,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
ok = true,
|
||||
dashboardUrl = dashboardUrl,
|
||||
message = status.message ?: "Hermes is ready",
|
||||
signInRequired = status.authRequired && !authenticated,
|
||||
signInRequired = !authenticated,
|
||||
authenticated = authenticated,
|
||||
voiceAvailability = voice,
|
||||
httpConsentOrigin = httpConsentOrigin?.takeIf { it == dashboardHttpOrigin(dashboardUrl) },
|
||||
),
|
||||
)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
@@ -6536,7 +6593,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
DashboardSetupResult(
|
||||
ok = false,
|
||||
dashboardUrl = dashboardUrl,
|
||||
message = e.message ?: "Hermes was not found at this address",
|
||||
message = if (e is com.hermesandroid.relay.network.upstream.DashboardLocalAuthenticationRequiredException) {
|
||||
getApplication<Application>().getString(R.string.dashboard_local_auth_help)
|
||||
} else e.message ?: "Hermes was not found at this address",
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
@@ -6549,6 +6608,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun saveDashboardConnection(
|
||||
dashboardUrl: String,
|
||||
discoveredHostname: String? = null,
|
||||
httpConsentOrigin: String? = null,
|
||||
onComplete: (Result<Unit>) -> Unit,
|
||||
) {
|
||||
val normalized = Connection.normalizeDashboardUrlInput(
|
||||
@@ -6559,7 +6619,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
onComplete(Result.failure(IllegalArgumentException("Hermes address is required")))
|
||||
return
|
||||
}
|
||||
if (publicDashboardAddressRequiresHttps(role = null, normalizedAddress = normalized)) {
|
||||
if (publicDashboardAddressRequiresHttps(null, normalized, setOfNotNull(httpConsentOrigin))) {
|
||||
onComplete(Result.failure(IllegalArgumentException("Public Gateway addresses require HTTPS")))
|
||||
return
|
||||
}
|
||||
@@ -6571,7 +6631,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
draft = draft,
|
||||
dashboardUrl = normalized,
|
||||
discoveredHostname = discoveredHostname,
|
||||
)
|
||||
).copy(dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
|
||||
draft.dashboardHttpConsentOrigins, null, normalized, httpConsentOrigin,
|
||||
))
|
||||
true
|
||||
}
|
||||
if (stagedDraft) return@runCatching
|
||||
@@ -6595,6 +6657,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val next = current.copy(
|
||||
label = nextLabel,
|
||||
dashboardUrl = normalized,
|
||||
dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
|
||||
current.dashboardHttpConsentOrigins, current.configuredDashboardUrl, normalized, httpConsentOrigin,
|
||||
),
|
||||
authenticatedDashboardOrigin = current.authenticatedDashboardOrigin
|
||||
?.takeIf {
|
||||
it.trimEnd('/').equals(normalized.trimEnd('/'), ignoreCase = true)
|
||||
@@ -6633,6 +6698,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
*/
|
||||
fun updateDashboardAddress(
|
||||
url: String,
|
||||
httpConsentOrigin: String? = null,
|
||||
onResult: (String?) -> Unit,
|
||||
) {
|
||||
val normalized = normalizeDashboardAddressForEdit(url)
|
||||
@@ -6640,7 +6706,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
onResult("Enter an http:// or https:// Hermes Dashboard address")
|
||||
return
|
||||
}
|
||||
if (publicDashboardAddressRequiresHttps(role = null, normalizedAddress = normalized)) {
|
||||
if (publicDashboardAddressRequiresHttps(null, normalized, setOfNotNull(httpConsentOrigin))) {
|
||||
onResult("Public Gateway addresses require HTTPS")
|
||||
return
|
||||
}
|
||||
@@ -6658,7 +6724,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
return@launch
|
||||
}
|
||||
val originChanged = !sameDashboardBase(current.resolvedDashboardUrl, normalized)
|
||||
val next = withExplicitDashboardAddress(current, normalized)
|
||||
val next = withExplicitDashboardAddress(current, normalized).copy(
|
||||
dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
|
||||
current.dashboardHttpConsentOrigins, current.configuredDashboardUrl, normalized, httpConsentOrigin,
|
||||
),
|
||||
)
|
||||
connectionStore.updateConnection(next)
|
||||
if (originChanged) {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -6745,6 +6815,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
tokenStoreKey = Connection.buildTokenStoreKey(connectionId),
|
||||
dashboardUrl = dashboardUrl,
|
||||
routeCandidates = routes,
|
||||
dashboardHttpConsentOrigins = draft.dashboardHttpConsentOrigins,
|
||||
pairedAt = paired?.let { System.currentTimeMillis() },
|
||||
transportHint = pairedSession?.transportHint,
|
||||
expiresAt = pairedSession?.expiresAt?.let { it * 1000L },
|
||||
@@ -6785,16 +6856,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
private suspend fun probeDashboardAddress(
|
||||
dashboardUrl: String,
|
||||
): Result<Pair<DashboardStatus, DashboardAuthSession?>> {
|
||||
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
|
||||
val client = upstreamTransport.dashboardClientForActive(dashboardUrl, setupProbe = true)
|
||||
return try {
|
||||
withTimeoutOrNull(8_000L) {
|
||||
val status = client.getStatus().getOrElse { throw it }
|
||||
val session = if (status.authRequired) {
|
||||
client.currentSession().getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Result.success(status to session)
|
||||
val verification = client.verifySetup()
|
||||
Result.success(verification.status to verification.session)
|
||||
} ?: Result.failure(java.net.SocketTimeoutException("Dashboard check timed out"))
|
||||
} catch (cancelled: kotlinx.coroutines.CancellationException) {
|
||||
throw cancelled
|
||||
@@ -6817,7 +6883,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
reachable = true,
|
||||
authRequired = status.authRequired,
|
||||
authProviders = status.authProviders,
|
||||
authenticated = if (status.authRequired) session?.authenticated else true,
|
||||
authenticated = session?.authenticated == true,
|
||||
authProvider = session?.provider,
|
||||
message = status.message,
|
||||
gatewayMode = status.gatewayMode,
|
||||
@@ -7298,6 +7364,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
},
|
||||
apiBearerTokenProvider = { authManager.getApiKey() },
|
||||
dashboardHttpClientProvider = ::dashboardHttpClientForRelayIngress,
|
||||
pluginProxyHttpClientProvider = { url ->
|
||||
pluginProxyClientForUrl(
|
||||
url = url,
|
||||
baseClient = relayOkHttp,
|
||||
includeRelaySessionHeader = false,
|
||||
)
|
||||
},
|
||||
dashboardIngressWebSocketRequestProvider = ::dashboardRelayRequestForIngress,
|
||||
).getVoiceConfig()
|
||||
} else {
|
||||
@@ -7738,6 +7811,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
role: String,
|
||||
dashboardUrl: String,
|
||||
original: EndpointCandidate? = null,
|
||||
httpConsentOrigin: String? = null,
|
||||
onResult: (String?) -> Unit,
|
||||
) {
|
||||
if (original?.priority == 0) {
|
||||
@@ -7753,7 +7827,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// Accept bare hosts/IPs — http:// is assumed and the standard
|
||||
// Dashboard/Gateway port defaults to 9119.
|
||||
val trimmedUrl = Connection.normalizeDashboardUrlInput(dashboardUrl)
|
||||
if (publicDashboardAddressRequiresHttps(role, trimmedUrl)) {
|
||||
if (publicDashboardAddressRequiresHttps(role, trimmedUrl, setOfNotNull(httpConsentOrigin))) {
|
||||
onResult("Public Gateway routes require HTTPS")
|
||||
return@launch
|
||||
}
|
||||
@@ -7806,7 +7880,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
val next = (withoutOriginal + candidate)
|
||||
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
|
||||
connectionStore.updateConnection(current.copy(routeCandidates = next))
|
||||
connectionStore.updateConnection(current.copy(
|
||||
routeCandidates = next,
|
||||
dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
|
||||
current.dashboardHttpConsentOrigins, original?.dashboard?.url, trimmedUrl, httpConsentOrigin,
|
||||
),
|
||||
))
|
||||
// Full probe cycle (not a bare refresh) so the just-saved route
|
||||
// immediately shows a reachability verdict in the Routes card.
|
||||
probeNow()
|
||||
@@ -8373,9 +8452,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// --- Shared methods ---
|
||||
|
||||
fun setTheme(theme: String) {
|
||||
val normalized = AppearanceNightMode.normalizePreference(theme)
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[AppearancePreferences.themeKey] = theme
|
||||
preferences[AppearancePreferences.themeKey] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,6 +900,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* we don't re-process older turns when the history list updates. */
|
||||
private var assistantSpeechCursor: AssistantSpeechCursor? = null
|
||||
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
|
||||
private var inboundSpeechGeneration = 0L
|
||||
private var inboundSpeechOwner: Pair<ConversationBinding, String?>? = null
|
||||
private var inboundSpeechObserver: Job? = null
|
||||
private val pendingInboundSpeech = ArrayDeque<Pair<() -> Boolean, String>>()
|
||||
private var inboundSpeechPlaying = false
|
||||
private var sentenceBuffer: StringBuilder = StringBuilder()
|
||||
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
|
||||
private val brokeredToolSpeechKeys = mutableSetOf<String>()
|
||||
@@ -1199,9 +1204,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
|
||||
) {
|
||||
cancelStandardSpeechStream("voice dependencies rewired")
|
||||
retireInboundSpeech()
|
||||
this.chatViewModel?.gatewayInboundSpeechReceiver = null
|
||||
this.voiceClient = voiceClient
|
||||
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
|
||||
this.chatViewModel = chatViewModel
|
||||
chatViewModel.gatewayInboundSpeechReceiver = ::captureInboundSpeechReceiver
|
||||
this.recorder = recorder
|
||||
this.player = player
|
||||
this.realtimePcmPlayer = realtimePcmPlayer
|
||||
@@ -1537,6 +1545,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
|
||||
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
|
||||
if (voiceEngineMode != nextEngineMode) {
|
||||
if (inboundSpeechPlaying) interruptSpeaking(cancelActiveTurn = false)
|
||||
else retireInboundSpeech()
|
||||
}
|
||||
val finalAnswerPolicyChanged = finalAnswerOnly != settings.finalAnswerOnly
|
||||
val realtimeSelectionChanged =
|
||||
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
|
||||
@@ -1675,6 +1687,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
backgroundRun = if (orphanedRun != null) null else it.backgroundRun,
|
||||
)
|
||||
}
|
||||
if (freshEntry) bindInboundSpeechOwner()
|
||||
prewarmRealtimeSession()
|
||||
}
|
||||
|
||||
@@ -1885,6 +1898,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
fun exitVoiceMode() {
|
||||
retireInboundSpeech()
|
||||
cancelPendingListeningStart()
|
||||
// Idempotence guard — added 2026-04-21 after logcat showed the voice-
|
||||
// exit chime playing on every Add-connection tap.
|
||||
@@ -2291,6 +2305,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* listening turn; until then, idle queue-drain callbacks are ignored.
|
||||
*/
|
||||
fun pauseContinuousMode() {
|
||||
retireInboundSpeech()
|
||||
cancelPendingListeningStart()
|
||||
continuousLoopArmed = false
|
||||
continuousListeningPaused = _uiState.value.interactionMode == InteractionMode.Continuous
|
||||
@@ -2427,6 +2442,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* new turn on the next mic tap).
|
||||
*/
|
||||
fun interruptSpeaking(cancelActiveTurn: Boolean = true): Job? {
|
||||
retireInboundSpeech()
|
||||
cancelPendingListeningStart()
|
||||
Log.i(
|
||||
TAG,
|
||||
@@ -3584,6 +3600,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
|
||||
if (inboundSpeechOwner == null) bindInboundSpeechOwner()
|
||||
beginBargeInTurnIfEnabled()
|
||||
startStreamObserver(chatVm)
|
||||
}
|
||||
@@ -4738,6 +4755,99 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
standardSpeechStreamBargeInStarted.set(false)
|
||||
}
|
||||
|
||||
/** The voice overlay owns live completions only for the conversation it entered. */
|
||||
private fun bindInboundSpeechOwner() {
|
||||
val chat = chatViewModel ?: return
|
||||
retireInboundSpeech()
|
||||
inboundSpeechOwner = chat.conversationBinding.value to chat.currentSessionId.value
|
||||
inboundSpeechObserver = viewModelScope.launch {
|
||||
combine(chat.conversationBinding, chat.currentSessionId, _uiState) { binding, id, state ->
|
||||
Triple(binding, id, state)
|
||||
}.collect { (binding, id, _) ->
|
||||
val owner = inboundSpeechOwner ?: return@collect
|
||||
if (owner != (binding to id)) {
|
||||
// The first voice submission may create/adopt a durable session.
|
||||
if (owner.second == null && owner.first.contextKey == binding.contextKey &&
|
||||
voiceTurnSessionFence?.accepts(id, chat.messages.value) == true
|
||||
) {
|
||||
inboundSpeechOwner = binding to id
|
||||
} else {
|
||||
val wasPlaying = inboundSpeechPlaying
|
||||
retireInboundSpeech()
|
||||
if (wasPlaying) interruptSpeaking(cancelActiveTurn = false)
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
drainInboundSpeech()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun retireInboundSpeech() {
|
||||
inboundSpeechGeneration++
|
||||
inboundSpeechOwner = null
|
||||
inboundSpeechObserver?.cancel()
|
||||
inboundSpeechObserver = null
|
||||
pendingInboundSpeech.clear()
|
||||
inboundSpeechPlaying = false
|
||||
}
|
||||
|
||||
/** Called on Main before Chat installs the new live assistant placeholder. */
|
||||
private fun captureInboundSpeechReceiver(): ((String) -> Unit)? {
|
||||
val chat = chatViewModel ?: return null
|
||||
val owner = inboundSpeechOwner ?: return null
|
||||
val generation = inboundSpeechGeneration
|
||||
fun current(): Boolean =
|
||||
generation == inboundSpeechGeneration && _uiState.value.voiceMode &&
|
||||
voiceEngineMode == VoiceEngineMode.HermesVoiceOutput &&
|
||||
inboundSpeechOwner == owner &&
|
||||
owner == (chat.conversationBinding.value to chat.currentSessionId.value)
|
||||
if (owner.second == null || !current()) return null
|
||||
|
||||
// A fast unsolicited start can overtake combine's final local-turn snapshot.
|
||||
// Consume that final snapshot before the new placeholder exists so the two
|
||||
// speech paths cannot narrate the same assistant bubble.
|
||||
if (streamObserverJob?.isActive == true && !chat.isStreaming.value) {
|
||||
assistantSpeechCursor?.let { cursor ->
|
||||
consumeAssistantSpeech(cursor.poll(chat.messages.value), runActive = false)
|
||||
}
|
||||
streamObserverJob?.cancel()
|
||||
}
|
||||
var consumed = false
|
||||
return { text ->
|
||||
if (!consumed) {
|
||||
consumed = true
|
||||
if (current() && sanitizeForTts(text).isNotBlank()) {
|
||||
pendingInboundSpeech.addLast(::current to text)
|
||||
drainInboundSpeech()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for a capture/earlier reply to settle; use the configured output renderer. */
|
||||
private fun drainInboundSpeech(): Boolean {
|
||||
if (pendingInboundSpeech.isEmpty()) return false
|
||||
if (_uiState.value.state != VoiceState.Idle || isMicCaptureActive() ||
|
||||
streamObserverJob?.isActive == true ||
|
||||
chatViewModel?.isStreaming?.value == true ||
|
||||
!agentAudioCompletionDecision().finishNow
|
||||
) return false
|
||||
while (pendingInboundSpeech.isNotEmpty()) {
|
||||
val (current, text) = pendingInboundSpeech.removeAt(0)
|
||||
if (!current()) continue
|
||||
cancelPendingListeningStart()
|
||||
streamComplete = true
|
||||
inboundSpeechPlaying = true
|
||||
resetTtsTurnStats()
|
||||
clearSpokenChunksState()
|
||||
speakSettledFinalAnswer(text)
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -4770,40 +4880,43 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return@collect
|
||||
}
|
||||
|
||||
val batch = cursor.poll(messages)
|
||||
if (finalAnswerOnly) {
|
||||
if (batch.deltas.isNotEmpty()) {
|
||||
onVisualStreamDelta(batch.aggregateText)
|
||||
}
|
||||
} else {
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
}
|
||||
// Tool state can change without text growth.
|
||||
if (!finalAnswerOnly) {
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
}
|
||||
|
||||
if (!runActive && batch.hasTurnAssistant) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (finalAnswerOnly) {
|
||||
speakSettledFinalAnswer(batch.finalAnswerText)
|
||||
} else if (!finishStandardSpeechStream()) {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
consumeAssistantSpeech(cursor.poll(messages), runActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeAssistantSpeech(batch: AssistantSpeechBatch, runActive: Boolean) {
|
||||
if (finalAnswerOnly) {
|
||||
if (batch.deltas.isNotEmpty()) {
|
||||
onVisualStreamDelta(batch.aggregateText)
|
||||
}
|
||||
} else {
|
||||
batch.deltas.forEach { update ->
|
||||
if (update.startsNewBubble) {
|
||||
beginAssistantSpeechBubble()
|
||||
}
|
||||
onStreamDelta(update.text, batch.aggregateText)
|
||||
}
|
||||
}
|
||||
// Tool state can change without text growth.
|
||||
if (!finalAnswerOnly) {
|
||||
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
|
||||
}
|
||||
|
||||
if (!runActive && batch.hasTurnAssistant) {
|
||||
streamComplete = true
|
||||
idleFlushJob?.cancel()
|
||||
idleFlushJob = null
|
||||
if (finalAnswerOnly) {
|
||||
speakSettledFinalAnswer(batch.finalAnswerText)
|
||||
} else if (!finishStandardSpeechStream()) {
|
||||
flushRemainingBuffer()
|
||||
}
|
||||
streamObserverJob?.cancel()
|
||||
scheduleAgentAudioCompletionCheck()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVisualStreamDelta(fullContent: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -5800,6 +5913,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
private fun finishAgentAudioOutput() {
|
||||
inboundSpeechPlaying = false
|
||||
continuousResumeJob = null
|
||||
_responseSpeechActive.value = false
|
||||
stopBargeInListener()
|
||||
@@ -5825,6 +5939,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_uiState.update { it.copy(amplitude = 0f, outputAudioActive = false) }
|
||||
}
|
||||
|
||||
if (drainInboundSpeech()) return
|
||||
if (_uiState.value.interactionMode == InteractionMode.Continuous &&
|
||||
continuousLoopArmed &&
|
||||
_uiState.value.state == VoiceState.Idle
|
||||
@@ -6686,6 +6801,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
retireInboundSpeech()
|
||||
chatViewModel?.gatewayInboundSpeechReceiver = null
|
||||
super.onCleared()
|
||||
voicePreviewJob?.cancel()
|
||||
voicePreviewJob = null
|
||||
|
||||
+11
-13
@@ -193,6 +193,13 @@ class ProfileController(
|
||||
private val _serverDefaultProfileScope = MutableStateFlow<DashboardProfileScope?>(null)
|
||||
val serverDefaultProfileScope: StateFlow<DashboardProfileScope?> =
|
||||
_serverDefaultProfileScope.asStateFlow()
|
||||
/** Default identity independent of the explicit selection; never a routing override. */
|
||||
val serverDefaultDisplayProfile: StateFlow<Profile?> = combine(
|
||||
agentProfiles,
|
||||
serverDefaultProfileScope,
|
||||
) { profiles, serverDefault ->
|
||||
AgentDisplay.effectiveDisplayProfile(null, profiles, serverDefault?.active)
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
private val _serverDefaultProfileSettled = MutableStateFlow(false)
|
||||
|
||||
private fun pendingProfileNameForActiveConnection(): String? {
|
||||
@@ -361,12 +368,8 @@ class ProfileController(
|
||||
activeConnectionId,
|
||||
selectedProfile,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, selected, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
selected?.name ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
) { connectionId, selected, serverDefault ->
|
||||
connectionId to (selected?.name ?: serverDefault?.active)
|
||||
}
|
||||
|
||||
/** Cached bytes fetched from Hermes; always preferred over the local fallback. */
|
||||
@@ -398,12 +401,8 @@ class ProfileController(
|
||||
fun profileIconFlow(profileName: String?): Flow<String?> = combine(
|
||||
activeConnectionId,
|
||||
serverDefaultProfileScope,
|
||||
_gatewayProfiles,
|
||||
) { connectionId, serverDefault, gatewayProfiles ->
|
||||
connectionId to (
|
||||
profileName ?: serverDefault?.active
|
||||
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
|
||||
)
|
||||
) { connectionId, serverDefault ->
|
||||
connectionId to (profileName ?: serverDefault?.active)
|
||||
}
|
||||
.flatMapLatest { (connectionId, serverProfileName) ->
|
||||
if (connectionId == null) return@flatMapLatest flowOf(null)
|
||||
@@ -1282,7 +1281,6 @@ class ProfileController(
|
||||
|
||||
private fun resolveSharedAssetProfileName(): String? =
|
||||
resolveSessionProfileName()
|
||||
?: _gatewayProfiles.value.firstOrNull(Profile::isDefault)?.name
|
||||
|
||||
private companion object {
|
||||
const val LOCAL_PROFILE_ICON_MAX_BYTES = 8_000_000
|
||||
|
||||
+46
-10
@@ -104,6 +104,7 @@ class UpstreamTransportController(
|
||||
private val trustedDashboardUrlProvider: (String) -> String? = { null },
|
||||
/** False when the host's advertised auth topology requires cookie compatibility mode. */
|
||||
private val nativeDashboardBearerEligibleProvider: (String) -> Boolean = { true },
|
||||
private val dashboardHttpConsentOriginsProvider: (String) -> Set<String> = { emptySet() },
|
||||
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
|
||||
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
|
||||
{ _, _ -> null },
|
||||
@@ -190,18 +191,29 @@ class UpstreamTransportController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun dashboardClientTransport(
|
||||
connectionId: String?,
|
||||
url: String,
|
||||
base: okhttp3.OkHttpClient,
|
||||
): okhttp3.OkHttpClient = com.hermesandroid.relay.network.upstream.dashboardClientWithHttpConsent(
|
||||
pinnedClientProvider(url, base) ?: base,
|
||||
url,
|
||||
connectionId?.let(dashboardHttpConsentOriginsProvider).orEmpty(),
|
||||
)
|
||||
|
||||
private fun bearerAuthForTrustedDashboard(
|
||||
connectionId: String,
|
||||
dashboardUrl: String,
|
||||
): DashboardBearerAuth? {
|
||||
if (!nativeDashboardBearerEligibleProvider(connectionId)) return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl, dashboardHttpConsentOriginsProvider(connectionId))) return null
|
||||
val trustedDashboardUrl = trustedDashboardUrlProvider(connectionId)
|
||||
?: (if (activeConnectionIdProvider() == connectionId) dashboardUrlProvider() else null)
|
||||
?: return null
|
||||
return trustedDashboardBearerAuthOrNull(
|
||||
candidate = dashboardUrl,
|
||||
trusted = trustedDashboardUrl,
|
||||
httpConsentOrigins = dashboardHttpConsentOriginsProvider(connectionId),
|
||||
tokenStoreProvider = { dashboardTokenStoreFor(connectionId) },
|
||||
)
|
||||
}
|
||||
@@ -223,7 +235,7 @@ class UpstreamTransportController(
|
||||
dashboardCookieStoreFor(connectionId),
|
||||
bearerAuthForTrustedDashboard(connectionId, normalizedUrl),
|
||||
)
|
||||
pinnedClientProvider(normalizedUrl, base) ?: base
|
||||
dashboardClientTransport(connectionId, normalizedUrl, base)
|
||||
}
|
||||
return DashboardApiClient(
|
||||
baseUrl = normalizedUrl,
|
||||
@@ -257,16 +269,27 @@ class UpstreamTransportController(
|
||||
* [dashboardUrl], falling back to an in-memory cookie store when there is
|
||||
* no active connection (the standard-voice probe path).
|
||||
*/
|
||||
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient {
|
||||
fun dashboardClientForActive(dashboardUrl: String, setupProbe: Boolean = false): DashboardApiClient {
|
||||
val ownerId = activeConnectionIdProvider()
|
||||
val trustedBase = ownerId?.let(trustedDashboardUrlProvider) ?: dashboardUrlProvider()
|
||||
val cookies = if (!setupProbe || (trustedBase != null &&
|
||||
com.hermesandroid.relay.network.upstream.sameDashboardBase(dashboardUrl, trustedBase))) {
|
||||
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore()
|
||||
} else InMemoryDashboardCookieStore()
|
||||
val base = dashboardHttpClientFactory(
|
||||
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
|
||||
cookies,
|
||||
activeConnectionIdProvider()?.let {
|
||||
bearerAuthForTrustedDashboard(it, dashboardUrl)
|
||||
},
|
||||
)
|
||||
val boundedBase = if (setupProbe) base.newBuilder()
|
||||
.callTimeout(8, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build() else base
|
||||
return DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = pinnedClientProvider(dashboardUrl, base) ?: base,
|
||||
okHttpClient = dashboardClientTransport(ownerId, dashboardUrl, boundedBase),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -281,7 +304,7 @@ class UpstreamTransportController(
|
||||
)
|
||||
return DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = pinnedClientProvider(dashboardUrl, base) ?: base,
|
||||
okHttpClient = dashboardClientTransport(activeConnectionIdProvider(), dashboardUrl, base),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,7 +316,7 @@ class UpstreamTransportController(
|
||||
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? {
|
||||
val connectionId = activeConnectionIdProvider() ?: return null
|
||||
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
|
||||
if (!isNativeDashboardTransportEligible(dashboardUrl, dashboardHttpConsentOriginsProvider(connectionId))) {
|
||||
return null
|
||||
}
|
||||
if (!com.hermesandroid.relay.network.upstream.sameDashboardBase(
|
||||
@@ -312,7 +335,7 @@ class UpstreamTransportController(
|
||||
return NativeDashboardAuthClient(
|
||||
baseUrl = dashboardUrl,
|
||||
tokenStore = dashboardTokenStoreFor(connectionId),
|
||||
client = pinnedClientProvider(dashboardUrl, base) ?: base,
|
||||
client = dashboardClientTransport(activeConnectionIdProvider(), dashboardUrl, base),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -331,7 +354,7 @@ class UpstreamTransportController(
|
||||
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
|
||||
},
|
||||
)
|
||||
return (pinnedClientProvider(dashboardUrl, base) ?: base)
|
||||
return (dashboardClientTransport(activeConnectionIdProvider(), dashboardUrl, base))
|
||||
.also { dashboardHttpClientCache = Triple(connectionId, dashboardUrl, it) }
|
||||
}
|
||||
|
||||
@@ -458,8 +481,17 @@ class UpstreamTransportController(
|
||||
}
|
||||
gatewayClientCache?.third?.shutdown()
|
||||
lateinit var client: GatewayChatClient
|
||||
// Dashboard REST already carries the pairing SPKI pin for Secure Link.
|
||||
// The gateway WS upgrade must use the SAME client — a bare OkHttpClient
|
||||
// rejects the self-signed Secure Link cert ("Trust anchor … not found")
|
||||
// and leaves Chat stuck on "Checking gateway…".
|
||||
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
|
||||
val gatewayHttpClient = dashboardRestHttpClients[
|
||||
connectionId to dashboardUrl.trim().trimEnd('/'),
|
||||
]
|
||||
client = GatewayChatClient(
|
||||
initialDashboardClient = dashboardClientFor(connectionId, dashboardUrl),
|
||||
initialDashboardClient = dashboardClient,
|
||||
okHttpClient = gatewayHttpClient,
|
||||
onGatewayUnsupported = {
|
||||
updateGatewayAvailabilityIfCurrent(
|
||||
connectionId,
|
||||
@@ -531,12 +563,16 @@ class UpstreamTransportController(
|
||||
if (cached.activeRequests == 0 && cached.retained == 0) shutdownRouteEntry(cached)
|
||||
}
|
||||
val dashboardClient = dashboardClientFor(connectionId, dashboardUrl)
|
||||
val gatewayHttpClient = dashboardRestHttpClients[
|
||||
connectionId to dashboardUrl.trim().trimEnd('/'),
|
||||
]
|
||||
entry = RouteGatewayEntry(
|
||||
dashboardUrl = dashboardUrl,
|
||||
dashboardClient = dashboardClient,
|
||||
client = GatewayChatClient(
|
||||
initialDashboardClient = dashboardClient,
|
||||
fixedSessionProfile = profile,
|
||||
okHttpClient = gatewayHttpClient,
|
||||
).also { it.setKeepAliveInBackground(gatewayKeepAliveProvider()) },
|
||||
)
|
||||
if (retain) entry.retained = 1 else entry.activeRequests = 1
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.app.KeyguardManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/** Permissions do not grant permission to start a session: a resumed UI action is also required. */
|
||||
data class VoiceOverlayAccess(
|
||||
val microphone: Boolean,
|
||||
val notifications: Boolean,
|
||||
val overlay: Boolean,
|
||||
val unlocked: Boolean,
|
||||
) {
|
||||
val ready: Boolean get() = microphone && notifications && overlay && unlocked
|
||||
|
||||
companion object {
|
||||
fun read(context: Context): VoiceOverlayAccess {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channelEnabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
manager?.getNotificationChannel(VoiceOverlayForegroundService.CHANNEL_ID)?.importance !=
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
return VoiceOverlayAccess(
|
||||
microphone = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled() && channelEnabled,
|
||||
overlay = Settings.canDrawOverlays(context),
|
||||
unlocked = context.getSystemService(KeyguardManager::class.java)?.isKeyguardLocked == false &&
|
||||
context.getSystemService(PowerManager::class.java)?.isInteractive == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
-15
@@ -8,9 +8,13 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -27,31 +31,84 @@ import com.hermesandroid.relay.R
|
||||
* execution state Android requires once [MainActivity] is backgrounded.
|
||||
*/
|
||||
class VoiceOverlayForegroundService : Service() {
|
||||
private var activeSessionId: Long? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val accessMonitor = object : Runnable {
|
||||
override fun run() {
|
||||
val id = activeSessionId ?: return
|
||||
if (VoiceOverlayHost.peek()?.canContinue(id) != true) {
|
||||
endSession()
|
||||
} else {
|
||||
// Notification channels can be disabled without a runtime-permission event.
|
||||
handler.postDelayed(this, 1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val screenOffReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_SCREEN_OFF) endSession()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextCompat.registerReceiver(this, screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForegroundNotification()
|
||||
val id = intent?.getLongExtra(EXTRA_SESSION_ID, -1L) ?: -1L
|
||||
val host = VoiceOverlayHost.peek()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "Notification stop requested")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
if (id == activeSessionId) endSession()
|
||||
else if (activeSessionId == null && host?.sessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// No sticky restart, unowned Intent, or stale callback can start a microphone session.
|
||||
if (intent?.action != ACTION_START || host?.canStart(id) != true) {
|
||||
if (host?.sessionId == id) host.exitVoiceSession(id)
|
||||
if (activeSessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
activeSessionId = id
|
||||
if (!startForegroundNotification() || !host.onServiceReady(id)) {
|
||||
endSession()
|
||||
} else {
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
handler.post(accessMonitor)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.i(TAG, "App task removed; closing voice overlay")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
endSession()
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
unregisterReceiver(screenOffReceiver)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
private fun startForegroundNotification(): Boolean {
|
||||
try {
|
||||
ensureChannel()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
@@ -63,9 +120,9 @@ class VoiceOverlayForegroundService : Service() {
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "Could not foreground voice overlay microphone service", t)
|
||||
VoiceOverlayHost.peek()?.hide()
|
||||
stopSelf()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
@@ -76,8 +133,10 @@ class VoiceOverlayForegroundService : Service() {
|
||||
val openPending = PendingIntent.getActivity(this, 0, openIntent, pendingFlags)
|
||||
val stopPending = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP),
|
||||
activeSessionId?.toInt() ?: 0,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP)
|
||||
.setData(android.net.Uri.parse("hermes-voice-overlay:stop/$activeSessionId"))
|
||||
.putExtra(EXTRA_SESSION_ID, activeSessionId ?: -1L),
|
||||
pendingFlags,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
@@ -93,6 +152,7 @@ class VoiceOverlayForegroundService : Service() {
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.addAction(
|
||||
0,
|
||||
getString(R.string.voice_overlay_notification_stop),
|
||||
@@ -123,14 +183,15 @@ class VoiceOverlayForegroundService : Service() {
|
||||
const val NOTIFICATION_ID = 4715
|
||||
const val ACTION_START = "com.hermesandroid.relay.voice.OVERLAY_MIC_START"
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.voice.OVERLAY_MIC_STOP"
|
||||
const val EXTRA_SESSION_ID = "voice_overlay_session_id"
|
||||
|
||||
fun start(context: Context): Boolean {
|
||||
fun start(context: Context, sessionId: Long): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
return runCatching {
|
||||
ContextCompat.startForegroundService(
|
||||
appContext,
|
||||
Intent(appContext, VoiceOverlayForegroundService::class.java)
|
||||
.setAction(ACTION_START),
|
||||
.setAction(ACTION_START).putExtra(EXTRA_SESSION_ID, sessionId),
|
||||
)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
|
||||
@@ -22,6 +22,8 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -81,6 +84,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
@@ -153,21 +157,78 @@ class VoiceOverlayHost(context: Context) {
|
||||
private var overlayView: View? = null
|
||||
private var overlayOwner: VoiceOverlayLifecycleOwner? = null
|
||||
private var overlayParams: WindowManager.LayoutParams? = null
|
||||
private var generation = android.os.SystemClock.elapsedRealtimeNanos()
|
||||
internal var sessionId: Long? = null
|
||||
private set
|
||||
private var callerLifecycle: Lifecycle? = null
|
||||
private val callerObserver = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
// Keep microphone protection until the real Activity is foreground again.
|
||||
Lifecycle.Event.ON_RESUME -> if (overlayView != null) handoffToApp()
|
||||
Lifecycle.Event.ON_DESTROY -> exitVoiceSession()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun hasOverlayPermission(): Boolean = Settings.canDrawOverlays(appContext)
|
||||
|
||||
fun show(session: VoiceOverlaySession, lifecycle: Lifecycle): Boolean {
|
||||
if (!com.hermesandroid.relay.data.BuildFlavor.voiceSystemOverlay ||
|
||||
!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) ||
|
||||
!VoiceOverlayAccess.read(appContext).ready || !session.uiState.value.voiceMode
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (sessionId != null) return true
|
||||
val id = ++generation
|
||||
sessionId = id
|
||||
fun guarded(action: () -> Unit): () -> Unit = {
|
||||
if (canContinue(id)) action() else exitVoiceSession(id)
|
||||
}
|
||||
sessionState.value = session.copy(
|
||||
onStartListening = guarded(session.onStartListening),
|
||||
onStopListening = guarded(session.onStopListening),
|
||||
onInterrupt = guarded(session.onInterrupt),
|
||||
onPauseAutoMode = guarded(session.onPauseAutoMode),
|
||||
onReturnToHermes = guarded {
|
||||
session.onReturnToHermes()
|
||||
// Already foreground: no lifecycle transition is needed for a safe handoff.
|
||||
if (sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true
|
||||
) handoffToApp()
|
||||
},
|
||||
onExit = { exitVoiceSession(id) },
|
||||
onDismissOverlay = { exitVoiceSession(id) },
|
||||
onResetPosition = guarded { moveTo(24, 96) },
|
||||
)
|
||||
exitCallback = session.onExit
|
||||
callerLifecycle = lifecycle
|
||||
lifecycle.addObserver(callerObserver)
|
||||
if (!VoiceOverlayForegroundService.start(appContext, id)) {
|
||||
exitVoiceSession(id)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private var exitCallback: (() -> Unit)? = null
|
||||
|
||||
private fun handoffToApp() {
|
||||
val id = sessionId ?: return
|
||||
// A fast return from Android Settings must not bypass revocation teardown.
|
||||
if (canContinue(id)) hide() else exitVoiceSession(id)
|
||||
}
|
||||
|
||||
internal fun canStart(id: Long): Boolean = sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true && canContinue(id)
|
||||
|
||||
internal fun canContinue(id: Long): Boolean = sessionId == id &&
|
||||
sessionState.value?.uiState?.value?.voiceMode == true && VoiceOverlayAccess.read(appContext).ready
|
||||
|
||||
/** Called only after startForeground succeeds; a queued service start is not readiness. */
|
||||
@SuppressLint("InflateParams")
|
||||
fun show(session: VoiceOverlaySession): Boolean {
|
||||
sessionState.value = session
|
||||
if (!hasOverlayPermission()) {
|
||||
Log.w(TAG, "show: SYSTEM_ALERT_WINDOW not granted")
|
||||
return false
|
||||
}
|
||||
if (!VoiceOverlayForegroundService.start(appContext)) {
|
||||
Log.w(TAG, "show: microphone foreground service could not start")
|
||||
sessionState.value = null
|
||||
return false
|
||||
}
|
||||
internal fun onServiceReady(id: Long): Boolean {
|
||||
if (!canStart(id)) return false
|
||||
if (overlayView != null) return true
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
@@ -191,9 +252,7 @@ class VoiceOverlayHost(context: Context) {
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
overlayType(),
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
@@ -205,8 +264,6 @@ class VoiceOverlayHost(context: Context) {
|
||||
.onFailure { Log.w(TAG, "addView(voice overlay) failed", it) }
|
||||
.isSuccess
|
||||
if (!added) {
|
||||
sessionState.value = null
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
overlayOwner?.stop()
|
||||
overlayOwner = null
|
||||
return false
|
||||
@@ -214,10 +271,17 @@ class VoiceOverlayHost(context: Context) {
|
||||
|
||||
overlayView = compose
|
||||
overlayParams = params
|
||||
compose.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
moveTo(params.x, params.y)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
sessionId = null
|
||||
exitCallback = null
|
||||
callerLifecycle?.removeObserver(callerObserver)
|
||||
callerLifecycle = null
|
||||
val view = overlayView
|
||||
overlayView = null
|
||||
overlayParams = null
|
||||
@@ -231,17 +295,29 @@ class VoiceOverlayHost(context: Context) {
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
}
|
||||
|
||||
fun exitVoiceSession() {
|
||||
val onExit = sessionState.value?.onExit
|
||||
fun exitVoiceSession(expectedId: Long? = sessionId) {
|
||||
if (expectedId == null || sessionId != expectedId) return
|
||||
val onExit = exitCallback
|
||||
hide()
|
||||
onExit?.invoke()
|
||||
}
|
||||
|
||||
private fun moveBy(dx: Float, dy: Float) {
|
||||
val params = overlayParams ?: return
|
||||
moveTo(params.x + dx.roundToInt(), params.y + dy.roundToInt())
|
||||
}
|
||||
|
||||
private fun moveTo(x: Int, y: Int) {
|
||||
val view = overlayView ?: return
|
||||
val params = overlayParams ?: return
|
||||
params.x = (params.x + dx.roundToInt()).coerceAtLeast(0)
|
||||
params.y = (params.y + dy.roundToInt()).coerceAtLeast(0)
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getSize(size)
|
||||
val nextX = x.coerceIn(0, (size.x - view.width).coerceAtLeast(0))
|
||||
val nextY = y.coerceIn(0, (size.y - view.height).coerceAtLeast(0))
|
||||
if (params.x == nextX && params.y == nextY) return
|
||||
params.x = nextX
|
||||
params.y = nextY
|
||||
runCatching { wm.updateViewLayout(view, params) }
|
||||
.onFailure { Log.w(TAG, "updateViewLayout(voice overlay) failed", it) }
|
||||
}
|
||||
@@ -280,10 +356,12 @@ data class VoiceOverlaySession(
|
||||
val onReturnToHermes: () -> Unit,
|
||||
val onDismissOverlay: () -> Unit,
|
||||
val onExit: () -> Unit,
|
||||
val onResetPosition: () -> Unit = {},
|
||||
val connectionLabel: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun VoiceFloatingOverlayPill(
|
||||
internal fun VoiceFloatingOverlayPill(
|
||||
session: VoiceOverlaySession,
|
||||
onDragBy: (Float, Float) -> Unit,
|
||||
) {
|
||||
@@ -301,20 +379,31 @@ private fun VoiceFloatingOverlayPill(
|
||||
?: stringResource(R.string.voice_overlay_label_default_profile)
|
||||
val stateText = voiceOverlayStateLabel(uiState.state)
|
||||
val overlayWidth = (LocalConfiguration.current.screenWidthDp - 24)
|
||||
.coerceIn(280, 368)
|
||||
.coerceIn(200, 368)
|
||||
.dp
|
||||
|
||||
if (minimized) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
TextButton(onClick = session.onExit) {
|
||||
Text(stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,6 +418,7 @@ private fun VoiceFloatingOverlayPill(
|
||||
},
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 10.dp,
|
||||
) {
|
||||
@@ -382,7 +472,9 @@ private fun VoiceOverlayHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center) {
|
||||
if (LocalConfiguration.current.screenWidthDp >= 360) Box(
|
||||
modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
OverlayCircularWaveformRing(
|
||||
amplitude = uiState.amplitude,
|
||||
state = uiState.state,
|
||||
@@ -396,21 +488,28 @@ private fun VoiceOverlayHeader(
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = listOfNotNull(session.connectionLabel, session.profileName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stateText,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = overlayPrimaryText(uiState, stateText),
|
||||
val primaryText = overlayPrimaryText(uiState, stateText)
|
||||
if (primaryText != stateText) Text(
|
||||
text = primaryText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(38.dp)) {
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(
|
||||
@@ -426,6 +525,9 @@ private fun VoiceOverlayHeader(
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
size = 50.dp,
|
||||
)
|
||||
IconButton(onClick = session.onExit, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +541,10 @@ private fun ExpandedVoiceOverlayBody(
|
||||
session: VoiceOverlaySession,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
modifier = Modifier
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 120).coerceAtLeast(100).dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
OverlayLinearWaveform(
|
||||
amplitude = uiState.amplitude,
|
||||
@@ -502,31 +607,36 @@ private fun ExpandedVoiceOverlayBody(
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.62f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val actions: @Composable (Modifier) -> Unit = { actionModifier ->
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.ExpandMore,
|
||||
label = stringResource(R.string.voice_overlay_minimize),
|
||||
onClick = onMinimize,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
label = stringResource(R.string.voice_overlay_hide),
|
||||
onClick = session.onDismissOverlay,
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
label = stringResource(R.string.voice_overlay_reset_position),
|
||||
onClick = session.onResetPosition,
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
label = stringResource(R.string.voice_overlay_open_hermes),
|
||||
onClick = session.onReturnToHermes,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
}
|
||||
val actionContainer = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
val stackActions = androidx.compose.ui.platform.LocalDensity.current.fontScale > 1.2f &&
|
||||
LocalConfiguration.current.screenWidthDp < 360
|
||||
if (stackActions) {
|
||||
Column(modifier = actionContainer) { actions(Modifier.fillMaxWidth()) }
|
||||
} else {
|
||||
Row(modifier = actionContainer, verticalAlignment = Alignment.CenterVertically) {
|
||||
actions(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +680,7 @@ private fun VoiceOverlayAction(
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(54.dp),
|
||||
modifier = modifier.heightIn(min = 72.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -584,7 +694,7 @@ private fun VoiceOverlayAction(
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -1049,7 +1159,7 @@ private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
fun openHermesFromOverlay(context: Context) {
|
||||
fun openHermesFromOverlay(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
val launchIntent = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
|
||||
?: Intent().setPackage(appContext.packageName)
|
||||
@@ -1057,8 +1167,9 @@ fun openHermesFromOverlay(context: Context) {
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
runCatching { appContext.startActivity(launchIntent) }
|
||||
return runCatching { appContext.startActivity(launchIntent) }
|
||||
.onFailure { Log.w("VoiceOverlayHost", "return to Hermes failed", it) }
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
private class VoiceOverlayLifecycleOwner :
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/** Settings and Voice Focus share the explanation; only Voice Focus supplies a start action. */
|
||||
@Composable
|
||||
fun VoiceOverlaySetupDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)? = null,
|
||||
onBeforePermission: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var access by remember { mutableStateOf(VoiceOverlayAccess.read(context)) }
|
||||
var requestedMicrophone by remember { mutableStateOf(false) }
|
||||
var requestedNotifications by remember { mutableStateOf(false) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
fun open(intent: Intent) {
|
||||
onBeforePermission()
|
||||
failed = runCatching { context.startActivity(intent) }.isFailure
|
||||
}
|
||||
VoiceOverlaySetupContent(
|
||||
access = access,
|
||||
failed = failed,
|
||||
onDismiss = onDismiss,
|
||||
onStart = onStart?.let { start -> {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
if (access.ready && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) start()
|
||||
} },
|
||||
onMicrophone = {
|
||||
if (access.microphone || requestedMicrophone) {
|
||||
open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")))
|
||||
} else {
|
||||
onBeforePermission()
|
||||
requestedMicrophone = true
|
||||
permission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
onNotifications = {
|
||||
if (Build.VERSION.SDK_INT >= 33 && !requestedNotifications &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
onBeforePermission()
|
||||
requestedNotifications = true
|
||||
permission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
|
||||
}
|
||||
},
|
||||
onOverlay = {
|
||||
open(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun VoiceOverlaySetupContent(
|
||||
access: VoiceOverlayAccess,
|
||||
failed: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)?,
|
||||
onMicrophone: () -> Unit,
|
||||
onNotifications: () -> Unit,
|
||||
onOverlay: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.voice_overlay_setup_title)) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_body))
|
||||
AccessButton(R.string.perms_microphone, access.microphone, onMicrophone)
|
||||
AccessButton(R.string.perms_app_notifications, access.notifications, onNotifications)
|
||||
AccessButton(R.string.perms_display_over_apps, access.overlay, onOverlay)
|
||||
if (onStart == null) Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
if (failed) Text(stringResource(R.string.chat_overlay_start_failed), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (onStart != null) TextButton(onClick = onStart, enabled = access.ready) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_start))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cw_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccessButton(@androidx.annotation.StringRes label: Int, granted: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(label) + " · " + stringResource(
|
||||
if (granted) R.string.ncs_access_granted else R.string.chat_open_settings,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VoiceOverlaySettingsCard() {
|
||||
var show by remember { mutableStateOf(false) }
|
||||
Card {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
TextButton(onClick = { show = true }) { Text(stringResource(R.string.onboarding_review_optional_permissions)) }
|
||||
}
|
||||
}
|
||||
if (show) VoiceOverlaySetupDialog(onDismiss = { show = false })
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Pergunta %1$d de %2$d</string>
|
||||
<string name="clarify_batch_complete">Todas as perguntas respondidas</string>
|
||||
<string name="clarify_batch_expired">Esta solicitação terminou</string>
|
||||
<string name="clarify_batch_sending">Enviando resposta…</string>
|
||||
<string name="clarify_batch_answered">Perguntas respondidas (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">interface do agente</string>
|
||||
@@ -289,7 +294,7 @@
|
||||
<string name="cw_cloud_subtitle">Conecte ao seu agente hospedado</string>
|
||||
<string name="cw_server_vps_title">Gateway remoto</string>
|
||||
<string name="cw_server_vps_subtitle">Informe o endereço do Dashboard</string>
|
||||
<string name="cw_relay_optional_note">Endereços privados LAN e Tailscale podem usar HTTP ou HTTPS. Endereços públicos exigem HTTPS. Relay e API direta são opcionais.</string>
|
||||
<string name="cw_relay_optional_note">HTTPS é recomendado. Outros endereços HTTP exigem aceitação explícita do risco. O aplicativo não impõe proteção VPN. Relay e Direct API são opcionais.</string>
|
||||
<string name="cw_cloud_entry_title">Conectar ao Hermes hospedado pela Nous</string>
|
||||
<string name="cw_cloud_entry_description">Insira o endereço do agente mostrado no Nous Portal. Você entrará com segurança depois que o Hermes for encontrado.</string>
|
||||
<string name="cw_cloud_agent_name">Endereço do agente</string>
|
||||
@@ -297,7 +302,7 @@
|
||||
<string name="cw_cloud_slug_hint">Conectaremos a seu-agente.agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_slug_error">Insira exatamente as letras, os números e os hifens antes de .agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_address_placeholder">https://seu-agente.example.com</string>
|
||||
<string name="cw_cloud_address_hint">Use o endereço HTTPS completo exibido para seu agente hospedado.</string>
|
||||
<string name="cw_cloud_address_hint">Use o endereço HTTPS completo exibido para seu agente hospedado, incluindo :porta se ela for fornecida.</string>
|
||||
<string name="cw_cloud_use_custom_address">Usar URL personalizada</string>
|
||||
<string name="cw_cloud_use_nous_address">Usar endereço hospedado pela Nous</string>
|
||||
<string name="cw_before_connecting">Antes de conectar</string>
|
||||
@@ -317,10 +322,10 @@
|
||||
<string name="cw_nearby_enter_address">Informar endereço</string>
|
||||
<string name="cw_other_connection_methods">Outros métodos de conexão</string>
|
||||
<string name="cw_manual_hermes_title">Informe o endereço do gateway</string>
|
||||
<string name="cw_manual_hermes_description">Informe o endereço do Painel usado no navegador. Se ele não abrir neste celular, inicie hermes dashboard e verifique o Wi-Fi ou o Tailscale.</string>
|
||||
<string name="cw_manual_hermes_description">Digite o endereço do Dashboard que pode abrir neste telefone. Para LAN ou VPN, o host precisa de um Dashboard acessível e autenticação. Consulte o guia de configuração se o navegador não conectar.</string>
|
||||
<string name="cw_hermes_address">Endereço do Hermes</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 ou hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">Nenhuma chave de API é necessária. A porta 9119 é usada quando nenhuma porta é informada.</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="cw_hermes_address_hint">Exemplos: 192.168.1.10:9119 ou https://hermes.example.com:10443. Se omitida, hosts privados usam a porta 9119 e HTTPS usa a 443. Nenhuma chave API é necessária.</string>
|
||||
<string name="cw_find_hermes">Procurar Hermes</string>
|
||||
<string name="cw_hermes_found">Hermes encontrado</string>
|
||||
<string name="cw_ready_to_connect">Pronto para conectar</string>
|
||||
@@ -343,12 +348,12 @@
|
||||
<string name="cw_skip_for_now">Pular por enquanto — configure depois em Configurações</string>
|
||||
<string name="cw_back">Voltar</string>
|
||||
<string name="cw_connect_button">Conectar</string>
|
||||
<string name="cw_hermes_label">Hermes</string>
|
||||
<string name="cw_hermes_label_desc">Use isto para Chat e Gerenciar. Pareie o Relay depois somente quando ativar Terminal, Bridge, sessões do Relay ou permissões. O login no painel é o caminho de autenticação upstream preferencial; a chave da API continua sendo a alternativa para o Chat do Android.</string>
|
||||
<string name="cw_hermes_label">Direct API</string>
|
||||
<string name="cw_hermes_label_desc">Direct API é uma conexão de compatibilidade escolhida explicitamente para o chat do servidor API. Manage, sessões do Dashboard e voz padrão exigem login separado no Dashboard. Ela não assume uma conversa existente do Gateway.</string>
|
||||
<string name="cw_api_url_label">URL ou host do servidor API</string>
|
||||
<string name="cw_api_key_label">Chave da API</string>
|
||||
<string name="cw_api_key_placeholder">Valor de API_SERVER_KEY</string>
|
||||
<string name="cw_api_key_hint">Necessária para o Chat do Android até que o transporte do Gateway pelo painel seja ativado.</string>
|
||||
<string name="cw_api_key_hint">Chave bearer apenas para este servidor API. Ela não faz login no Dashboard.</string>
|
||||
<string name="cw_tailscale_label">Acesso remoto — URL do Tailscale (opcional)</string>
|
||||
<string name="cw_dashboard">Painel</string>
|
||||
<string name="cw_dashboard_routes_hint">Rotas: primeiro a padrão, depois o Tailscale quando estiver acessível.</string>
|
||||
@@ -363,7 +368,7 @@
|
||||
<string name="cw_cancel">Cancelar</string>
|
||||
<!-- Method tiles -->
|
||||
<string name="cw_method_hermes_title">Hermes</string>
|
||||
<string name="cw_method_hermes_subtitle">Configuração de API/painel para Chat, Gerenciar, Habilidades, Cron, MCP, Perfis, Modelos e Configurações</string>
|
||||
<string name="cw_method_hermes_subtitle">Chat explícito do servidor API para configurações sem interface ou de compatibilidade</string>
|
||||
<string name="cw_method_scan_title">Ler QR de configuração</string>
|
||||
<string name="cw_method_scan_subtitle">Leia um QR com a URL/chave da API do Hermes; os detalhes do QR do Relay exigem o plugin do Relay</string>
|
||||
<string name="cw_method_pair_code_title">Parear o Relay por código</string>
|
||||
@@ -372,7 +377,7 @@
|
||||
<string name="cw_method_show_code_subtitle">Sem câmera ou QR? Registre o código deste celular no host</string>
|
||||
<!-- Standard connect form -->
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 ou http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">API do Hermes usada pelo Chat e pelas sessões — a porta 8642 da API e http:// são presumidos para hosts sem esquema (a porta 9119 do painel é determinada separadamente)</string>
|
||||
<string name="cw_api_url_supporting">Endereço do servidor para chat Direct API. Hosts sem esquema usam HTTP e a porta 8642.</string>
|
||||
<string name="cw_scan_message">Procurando o painel/a API do Hermes nesta LAN…</string>
|
||||
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e voz — a chave da API é usada apenas em conexões explícitas pela API direta.</string>
|
||||
<string name="cw_pair_relay_section">Parear o Relay (opcional)</string>
|
||||
@@ -462,7 +467,7 @@
|
||||
<string name="cw_timeout_entercode_showcode">O host ainda não aceitou este código de pareamento. Execute o comando de pareamento no host do Hermes (ou confira o código) e toque em Tentar novamente.</string>
|
||||
<string name="cw_timeout_scan">O tempo se esgotou antes de o relay confirmar o pareamento. Verifique se o relay está em execução e acessível nesta rede e tente novamente. Se o pareamento parecer concluído, mas o Hermes ainda estiver inacessível, talvez o servidor API esteja atrás de um Gateway de login.</string>
|
||||
<string name="cw_timeout_other">O tempo de espera pelo relay se esgotou. Verifique se o relay está em execução e se a URL está correta.</string>
|
||||
<string name="cw_camera_denied">Permissão da câmera negada. Faça o pareamento manual — escolha \"Parear o Relay por código\" ou insira a URL do servidor.</string>
|
||||
<string name="cw_camera_denied">Permissão de câmera negada. Escolha Gateway remoto e digite o endereço do Dashboard. Os códigos de pareamento Relay estão em Avançado.</string>
|
||||
<!-- EndpointsCard -->
|
||||
<string name="endpoints_no_routes_stored">Nenhuma rota armazenada para esta conexão.</string>
|
||||
<string name="endpoints_add_route">Adicionar rota</string>
|
||||
@@ -757,7 +762,7 @@
|
||||
<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_share_phone_status_desc">Incluir contexto do app e do telefone em chats apenas por API. Não é enviado nos chats padrão do Gateway.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissões</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Se acessibilidade, captura de tela, sobreposição e notificações foram concedidas</string>
|
||||
<string name="chat_settings_foreground_app">App em primeiro plano</string>
|
||||
@@ -766,7 +771,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Porcentagem atual da bateria. Desativado por padrão.</string>
|
||||
<string name="chat_settings_safety_rails">Proteções de segurança</string>
|
||||
<string name="chat_settings_safety_rails_desc">Quantidade de itens bloqueados, quantidade de verbos destrutivos e temporizador de desativação automática</string>
|
||||
<string name="chat_settings_preview">Prévia</string>
|
||||
<string name="chat_settings_preview">Exemplo de chat apenas por API</string>
|
||||
<string name="chat_settings_no_system_message">(nenhuma mensagem do sistema será enviada)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Analisar anotações de ferramentas</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -2666,6 +2671,7 @@
|
||||
<string name="model_picker_title">Modelo</string>
|
||||
<string name="model_picker_search">Pesquisar modelos ou provedores…</string>
|
||||
<string name="model_picker_empty">Nenhum modelo corresponde à pesquisa</string>
|
||||
<string name="model_picker_no_models">Nenhum modelo disponível. Tente atualizar.</string>
|
||||
<!-- InjectedContextSheet -->
|
||||
<string name="context_sheet_agent_sees">O que o agente vê</string>
|
||||
<string name="context_sheet_transparency">O contexto adicional exato adicionado ao início do próximo turno, para transparência.</string>
|
||||
@@ -3174,7 +3180,7 @@
|
||||
<string name="endpoints_pin_title">Endpoints fixados</string>
|
||||
<string name="endpoints_route_editor_desc">Adicione o endereço do Dashboard/Gateway que este celular deve usar nessa rede.</string>
|
||||
<string name="endpoints_route_name_placeholder">Nome da rota</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z ou host.ts.net</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 ou https://host.ts.net:10443</string>
|
||||
<string name="endpoints_url_supporting_blank">Insira o endereço do Dashboard do servidor</string>
|
||||
<string name="endpoints_url_supporting_enter">Use um endereço de Dashboard http:// ou https://</string>
|
||||
<string name="endpoints_url_supporting_preview">Será testado: %1$s</string>
|
||||
@@ -3196,10 +3202,10 @@
|
||||
<string name="injected_context_phone_status_title">Status do celular</string>
|
||||
<string name="injected_context_relay_not_set">Nenhum relay configurado</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Contexto enviado com a próxima mensagem</string>
|
||||
<string name="injected_context_subtitle">Prévia deste chat. O contexto do servidor é configurado separadamente.</string>
|
||||
<string name="injected_context_title">Contexto injetado</string>
|
||||
<string name="injected_context_turn_not_set">Não definido</string>
|
||||
<string name="injected_context_turn_title">Modo de detecção de turno</string>
|
||||
<string name="injected_context_turn_title">Contexto do turno</string>
|
||||
<string name="model_picker_cd_clear">Limpar</string>
|
||||
<string name="model_picker_count">%d modelos</string>
|
||||
<string name="power_feature_back_desc">Voltar</string>
|
||||
@@ -3360,16 +3366,16 @@
|
||||
<string name="cw_relay_pair_qr">Emparelhar Hermes Relay</string>
|
||||
<string name="cw_relay_pair_qr_desc">Escaneie um QR de configuração do Relay</string>
|
||||
<string name="cw_relay_enter_code">Inserir código de emparelhamento</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard e Gateway estão prontos. Você pode começar a conversar ou abrir Manage.</string>
|
||||
<string name="cw_dashboard_connected_title">Acesso ao Dashboard verificado</string>
|
||||
<string name="cw_dashboard_connected_body">Continue para o Chat. A conexão Gateway e a disponibilidade de voz são verificadas separadamente.</string>
|
||||
<string name="cw_continue">Continuar</string>
|
||||
<string name="cw_timeline_discovered">Hermes encontrado</string>
|
||||
<string name="cw_timeline_discovered_detail">Identidade do Dashboard e endpoint de status verificados</string>
|
||||
<string name="cw_timeline_discovered_detail">Estado do Dashboard obtido</string>
|
||||
<string name="cw_timeline_access">Acesso ao Dashboard</string>
|
||||
<string name="cw_timeline_access_ready">Nenhum login adicional necessário</string>
|
||||
<string name="cw_timeline_authenticated">Autenticação verificada</string>
|
||||
<string name="cw_timeline_ready">Conexão pronta</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage e Voice podem usar este Dashboard</string>
|
||||
<string name="cw_timeline_ready">Pronto para continuar</string>
|
||||
<string name="cw_timeline_ready_detail">Chat conecta ao abrir; a voz é verificada separadamente</string>
|
||||
<string name="active_section_optional_api_fallback">API direta opcional</string>
|
||||
<string name="active_section_api_not_required">Não é necessário quando esta conexão usa o Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">Onde obtenho essa chave?</string>
|
||||
@@ -4386,11 +4392,11 @@
|
||||
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
|
||||
<string name="api_fallback_title">API direta</string>
|
||||
<string name="current_surface_paths_title">Current paths</string>
|
||||
<string name="dashboard_address_editor_body">Defina o endereço do Dashboard e do Gateway que este telefone deve usar. Rotas privadas de LAN e Tailscale podem usar HTTP ou HTTPS; rotas públicas exigem HTTPS.</string>
|
||||
<string name="dashboard_address_editor_body">Defina o endereço do Dashboard e Gateway desta conexão. Inclua :porta quando necessário, por exemplo 192.168.1.10:9119 ou https://hermes.example.com:10443. HTTPS é recomendado. Outros endereços HTTP exigem aceitação explícita do risco.</string>
|
||||
<string name="dashboard_address_editor_title">Endereço do gateway</string>
|
||||
<string name="dashboard_address_label">Dashboard & Gateway address</string>
|
||||
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
|
||||
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
|
||||
<string name="dashboard_address_oidc_hint">Alterar o endereço preserva este gateway e as referências ao histórico, mas uma origem diferente exige novo login. Os retornos OIDC são configurados no Hermes.</string>
|
||||
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="dashboard_address_preview">Will use: %1$s</string>
|
||||
<string name="dashboard_address_required">Informe o endereço do Gateway Hermes</string>
|
||||
<string name="dashboard_gateway_configured">Configured address</string>
|
||||
@@ -4439,4 +4445,18 @@
|
||||
<string name="tool_progress_status_dispatched">Enviado</string>
|
||||
<string name="chat_activity_history_notice">Atividade registrada. As atualizações de progresso não são mantidas; o histórico disponível dos subagentes pode ser aberto somente para leitura.</string>
|
||||
<string name="chat_activity_output_unavailable">A saída não está mais disponível. Este registro preserva o estado registrado do processo.</string>
|
||||
<string name="profile_follow_server_default">Seguir o perfil padrão do servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre outros apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantenha os controles de voz visíveis sobre outro app. O áudio vai para o servidor Hermes configurado. Isso não permite ler a tela nem controlar o telefone. Permita a exibição sobre outros apps nas configurações do Android e volte aqui. É necessária uma notificação do microfone com Parar voz. Fechar a sobreposição ou bloquear a tela encerra a sessão de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar sobreposição de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional nas duas versões. Abra o foco de voz no Chat e escolha Sobreposição. As permissões sozinhas nunca iniciam a escuta.</string>
|
||||
<string name="voice_overlay_reset_position">Redefinir posição</string>
|
||||
<string name="injected_context_gateway_unsupported">Não é enviado no chat do Gateway. Esta conexão não oferece suporte a contexto adicional por turno.</string>
|
||||
<string name="dashboard_http_risk">HTTP não criptografa solicitações a %1$s. Senhas, tokens de sessão e conversas podem ficar expostos. Se depender de uma VPN, você é responsável por manter o tráfego dentro dela, inclusive se ela cair. O aplicativo não detecta nem impõe proteção VPN.</string>
|
||||
<string name="dashboard_http_allow">Aceito o risco e permito HTTP para este endereço</string>
|
||||
<string name="cw_auth_verified">Login verificado</string>
|
||||
<string name="cw_chat_checked_on_open">Verificado ao abrir o Chat</string>
|
||||
<string name="dashboard_local_auth_help">Hermes está acessível, mas as solicitações protegidas não estão autorizadas. Se o Dashboard for encaminhado do loopback, confira o endereço de escuta, o provedor de autenticação e dashboard.public_url no host. Um 401 sozinho não identifica a causa.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">问题 %1$d / %2$d</string>
|
||||
<string name="clarify_batch_complete">所有问题均已回答</string>
|
||||
<string name="clarify_batch_expired">此请求已结束</string>
|
||||
<string name="clarify_batch_sending">正在发送回答…</string>
|
||||
<string name="clarify_batch_answered">已回答的问题(%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">代理界面</string>
|
||||
@@ -310,7 +315,7 @@
|
||||
<string name="cw_cloud_subtitle">连接到你的托管智能体</string>
|
||||
<string name="cw_server_vps_title">远程网关</string>
|
||||
<string name="cw_server_vps_subtitle">输入其 Dashboard 地址</string>
|
||||
<string name="cw_relay_optional_note">私有 LAN 和 Tailscale 地址可使用 HTTP 或 HTTPS。公共地址必须使用 HTTPS。Relay 与 API 后备均为可选。</string>
|
||||
<string name="cw_relay_optional_note">建议使用 HTTPS。其他 HTTP 地址需要明确接受风险。应用不会强制执行 VPN 保护。Relay 和 Direct API 均为可选。</string>
|
||||
<string name="cw_cloud_entry_title">连接到 Nous 托管的 Hermes</string>
|
||||
<string name="cw_cloud_entry_description">输入 Nous Portal 中显示的智能体地址。找到 Hermes 后,你将安全登录。</string>
|
||||
<string name="cw_cloud_agent_name">智能体地址</string>
|
||||
@@ -318,7 +323,7 @@
|
||||
<string name="cw_cloud_slug_hint">我们将连接到 your-agent.agents.nousresearch.com。</string>
|
||||
<string name="cw_cloud_slug_error">请准确输入 .agents.nousresearch.com 前的字母、数字和连字符。</string>
|
||||
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
|
||||
<string name="cw_cloud_address_hint">请使用为托管智能体显示的完整 HTTPS 地址。</string>
|
||||
<string name="cw_cloud_address_hint">请使用为托管智能体显示的完整 HTTPS 地址;如有提供,也请包含 :端口。</string>
|
||||
<string name="cw_cloud_use_custom_address">使用自定义网址</string>
|
||||
<string name="cw_cloud_use_nous_address">使用 Nous 托管地址</string>
|
||||
<string name="cw_before_connecting">连接之前</string>
|
||||
@@ -338,10 +343,10 @@
|
||||
<string name="cw_nearby_enter_address">改为输入地址</string>
|
||||
<string name="cw_other_connection_methods">其他连接方式</string>
|
||||
<string name="cw_manual_hermes_title">输入网关地址</string>
|
||||
<string name="cw_manual_hermes_description">请输入你在浏览器中打开的仪表板地址。如果此手机无法打开,请启动 hermes dashboard 并检查 Wi-Fi 或 Tailscale。</string>
|
||||
<string name="cw_manual_hermes_description">输入可在此手机浏览器中打开的 Dashboard 地址。通过 LAN 或 VPN 访问时,主机需要可达的 Dashboard 和身份验证。若浏览器无法连接,请参阅设置指南。</string>
|
||||
<string name="cw_hermes_address">Hermes 地址</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 或 hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">无需 API 密钥。未指定端口时使用仪表板端口 9119。</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="cw_hermes_address_hint">示例:192.168.1.10:9119 或 https://hermes.example.com:10443。省略时,私有主机使用端口 9119,HTTPS 使用端口 443。无需 API 密钥。</string>
|
||||
<string name="cw_find_hermes">查找 Hermes</string>
|
||||
<string name="cw_hermes_found">已找到 Hermes</string>
|
||||
<string name="cw_ready_to_connect">可以连接</string>
|
||||
@@ -364,12 +369,12 @@
|
||||
<string name="cw_skip_for_now">暂时跳过——稍后在设置中配置</string>
|
||||
<string name="cw_back">上一步</string>
|
||||
<string name="cw_connect_button">连接</string>
|
||||
<string name="cw_hermes_label">Hermes</string>
|
||||
<string name="cw_hermes_label_desc">用于聊天和管理。等启用终端、Bridge、Relay 会话或授权时再配对 Relay。仪表盘登录是首选的上游认证方式;API 密钥仍是 Android 聊天的备用方式。</string>
|
||||
<string name="cw_hermes_label">Direct API</string>
|
||||
<string name="cw_hermes_label_desc">Direct API 是为 API 服务器聊天明确选择的兼容连接。Manage、Dashboard 会话和标准语音需要单独登录 Dashboard。它不会接管现有 Gateway 对话。</string>
|
||||
<string name="cw_api_url_label">API 服务器地址或主机名</string>
|
||||
<string name="cw_api_key_label">API 密钥</string>
|
||||
<string name="cw_api_key_placeholder">API_SERVER_KEY 的值</string>
|
||||
<string name="cw_api_key_hint">在仪表盘网关传输启用前,Android 聊天需要此密钥。</string>
|
||||
<string name="cw_api_key_hint">仅用于此 API 服务器的 Bearer 密钥,不能用于登录 Dashboard。</string>
|
||||
<string name="cw_tailscale_label">远程访问——Tailscale 地址(可选)</string>
|
||||
<string name="cw_dashboard">仪表盘</string>
|
||||
<string name="cw_dashboard_routes_hint">路由:默认优先,Tailscale 在可达时作为备用。</string>
|
||||
@@ -385,7 +390,7 @@
|
||||
|
||||
<!-- 方法卡片 -->
|
||||
<string name="cw_method_hermes_title">Hermes</string>
|
||||
<string name="cw_method_hermes_subtitle">API/仪表盘设置,用于聊天、管理、技能、定时任务、MCP、配置文件、模型和设置</string>
|
||||
<string name="cw_method_hermes_subtitle">为无界面或兼容配置明确选择的 API 服务器聊天</string>
|
||||
<string name="cw_method_scan_title">扫描设置二维码</string>
|
||||
<string name="cw_method_scan_subtitle">扫描包含 API 地址/密钥的二维码;Relay 二维码详情需要 Relay 插件</string>
|
||||
<string name="cw_method_pair_code_title">用配对码配对 Relay</string>
|
||||
@@ -395,7 +400,7 @@
|
||||
|
||||
<!-- 标准连接表单 -->
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 或 http://你的服务器:8642</string>
|
||||
<string name="cw_api_url_supporting">聊天和会话使用的 Hermes API——裸主机名默认使用 API 端口 8642 和 http://(仪表盘的 9119 端口单独推导)</string>
|
||||
<string name="cw_api_url_supporting">Direct API 聊天使用的 API 服务器地址。仅输入主机时使用 HTTP 和端口 8642。</string>
|
||||
<string name="cw_scan_message">正在扫描本局域网寻找 Hermes 仪表盘/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥仅用于明确配置的 Direct API 连接。</string>
|
||||
<string name="cw_pair_relay_section">配对 Relay(可选)</string>
|
||||
@@ -490,7 +495,7 @@
|
||||
<string name="cw_timeout_entercode_showcode">主机尚未接受此配对码。请在 Hermes 主机上运行配对命令(或重新检查配对码),然后点击重试。</string>
|
||||
<string name="cw_timeout_scan">在 Relay 确认配对之前超时。请检查 Relay 是否在运行且在此网络上可达,然后重试。如果配对似乎成功但仍无法访问 Hermes,则 API 服务器可能位于登录网关之后。</string>
|
||||
<string name="cw_timeout_other">等待 Relay 超时。请检查 Relay 是否正在运行以及地址是否正确。</string>
|
||||
<string name="cw_camera_denied">摄像头权限被拒绝。请改为手动配对——选择"用配对码配对 Relay"或输入您的服务器地址。</string>
|
||||
<string name="cw_camera_denied">相机权限被拒绝。请选择远程 Gateway 并输入 Dashboard 地址。Relay 配对码位于高级选项中。</string>
|
||||
|
||||
<!-- EndpointsCard -->
|
||||
<string name="endpoints_no_routes_stored">此连接尚未存储任何路由。</string>
|
||||
@@ -796,7 +801,7 @@
|
||||
<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_share_phone_status_desc">在仅使用 API 的聊天中包含应用和手机上下文。标准 Gateway 聊天不会发送此内容。</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 权限</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">无障碍、屏幕捕获、悬浮窗、通知是否已授权</string>
|
||||
<string name="chat_settings_foreground_app">前台应用</string>
|
||||
@@ -805,7 +810,7 @@
|
||||
<string name="chat_settings_battery_level_desc">当前电池百分比。默认关闭。</string>
|
||||
<string name="chat_settings_safety_rails">安全护栏</string>
|
||||
<string name="chat_settings_safety_rails_desc">黑名单数量、破坏性动词数量和自动禁用计时器</string>
|
||||
<string name="chat_settings_preview">预览</string>
|
||||
<string name="chat_settings_preview">仅使用 API 的聊天示例</string>
|
||||
<string name="chat_settings_no_system_message">(不会发送系统消息)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">解析工具标注</string>
|
||||
<string name="chat_settings_experimental">实验性</string>
|
||||
@@ -2777,6 +2782,7 @@
|
||||
<string name="model_picker_title">模型</string>
|
||||
<string name="model_picker_search">搜索模型或提供商…</string>
|
||||
<string name="model_picker_empty">没有匹配您搜索的模型</string>
|
||||
<string name="model_picker_no_models">没有可用的模型。请尝试刷新。</string>
|
||||
|
||||
<!-- InjectedContextSheet -->
|
||||
<string name="context_sheet_agent_sees">代理看到的内容</string>
|
||||
@@ -3273,7 +3279,7 @@
|
||||
<string name="endpoints_pin_title">固定端点</string>
|
||||
<string name="endpoints_route_editor_desc">添加此手机在该网络上应使用的 Dashboard/Gateway 地址。</string>
|
||||
<string name="endpoints_route_name_placeholder">路由名称</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z 或 host.ts.net</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 或 https://host.ts.net:10443</string>
|
||||
<string name="endpoints_url_supporting_blank">输入服务器的 Dashboard 地址</string>
|
||||
<string name="endpoints_url_supporting_enter">使用 http:// 或 https:// Dashboard 地址</string>
|
||||
<string name="endpoints_url_supporting_preview">将测试:%1$s</string>
|
||||
@@ -3295,10 +3301,10 @@
|
||||
<string name="injected_context_phone_status_title">手机状态</string>
|
||||
<string name="injected_context_relay_not_set">未配置 Relay</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">随下一条消息发送的上下文</string>
|
||||
<string name="injected_context_subtitle">此聊天的预览。服务器端上下文单独配置。</string>
|
||||
<string name="injected_context_title">注入上下文</string>
|
||||
<string name="injected_context_turn_not_set">未设置</string>
|
||||
<string name="injected_context_turn_title">轮换检测模式</string>
|
||||
<string name="injected_context_turn_title">本轮上下文</string>
|
||||
<string name="model_picker_cd_clear">清空</string>
|
||||
<string name="model_picker_count">%d 个模型</string>
|
||||
<string name="power_feature_back_desc">返回</string>
|
||||
@@ -3512,16 +3518,16 @@
|
||||
<string name="cw_relay_pair_qr">配对 Hermes Relay</string>
|
||||
<string name="cw_relay_pair_qr_desc">扫描 Relay 设置二维码</string>
|
||||
<string name="cw_relay_enter_code">输入配对码</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes 已连接</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard 和 Gateway 已就绪。你可以开始聊天或打开 Manage。</string>
|
||||
<string name="cw_dashboard_connected_title">Dashboard 访问已验证</string>
|
||||
<string name="cw_dashboard_connected_body">继续进入 Chat。Gateway 连接和语音可用性会分别检查。</string>
|
||||
<string name="cw_continue">继续</string>
|
||||
<string name="cw_timeline_discovered">已发现 Hermes</string>
|
||||
<string name="cw_timeline_discovered_detail">已验证 Dashboard 标识和状态端点</string>
|
||||
<string name="cw_timeline_discovered_detail">已获取 Dashboard 状态</string>
|
||||
<string name="cw_timeline_access">Dashboard 访问</string>
|
||||
<string name="cw_timeline_access_ready">无需额外登录</string>
|
||||
<string name="cw_timeline_authenticated">身份验证已确认</string>
|
||||
<string name="cw_timeline_ready">连接已就绪</string>
|
||||
<string name="cw_timeline_ready_detail">Chat、Manage 和 Voice 可以使用此 Dashboard</string>
|
||||
<string name="cw_timeline_ready">可以继续</string>
|
||||
<string name="cw_timeline_ready_detail">打开 Chat 时连接,语音可用性单独检查</string>
|
||||
<string name="active_section_optional_api_fallback">可选 Direct API</string>
|
||||
<string name="active_section_api_not_required">此连接使用 Hermes Dashboard 时不需要配置。</string>
|
||||
<string name="active_section_where_api_key">从哪里获取此密钥?</string>
|
||||
@@ -4467,11 +4473,11 @@
|
||||
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
|
||||
<string name="api_fallback_title">直接 API</string>
|
||||
<string name="current_surface_paths_title">Current paths</string>
|
||||
<string name="dashboard_address_editor_body">设置此手机使用的 Dashboard 和 Gateway 地址。私有 LAN 与 Tailscale 路由可使用 HTTP 或 HTTPS;公共路由必须使用 HTTPS。</string>
|
||||
<string name="dashboard_address_editor_body">设置此连接的 Dashboard 和 Gateway 地址。需要时请包含 :端口,例如 192.168.1.10:9119 或 https://hermes.example.com:10443。建议使用 HTTPS。其他 HTTP 地址需要明确接受风险。</string>
|
||||
<string name="dashboard_address_editor_title">网关地址</string>
|
||||
<string name="dashboard_address_label">Dashboard & Gateway address</string>
|
||||
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
|
||||
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
|
||||
<string name="dashboard_address_oidc_hint">更改地址会保留此 Gateway 和历史记录引用,但更换源后需要重新登录。OIDC 回调在 Hermes 上配置。</string>
|
||||
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="dashboard_address_preview">Will use: %1$s</string>
|
||||
<string name="dashboard_address_required">输入 Hermes Gateway 地址</string>
|
||||
<string name="dashboard_gateway_configured">Configured address</string>
|
||||
@@ -4520,4 +4526,18 @@
|
||||
<string name="tool_progress_status_dispatched">已分派</string>
|
||||
<string name="chat_activity_history_notice">已记录的活动。进度更新不会保留;可用的子代理历史记录可以以只读方式打开。</string>
|
||||
<string name="chat_activity_output_unavailable">输出已不可用。此条目保留了记录的进程状态。</string>
|
||||
<string name="profile_follow_server_default">跟随服务器默认配置</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">在其他应用上使用语音</string>
|
||||
<string name="voice_overlay_setup_body">在其他应用上保持语音控件可见。音频会发送到你配置的 Hermes 服务器。这不会授权读取屏幕或控制手机。请在 Android 设置中允许显示在其他应用上层,然后返回此处。必须启用带有停止操作的麦克风通知。关闭悬浮窗或锁定屏幕会结束语音会话。</string>
|
||||
<string name="voice_overlay_setup_start">启动语音悬浮窗</string>
|
||||
<string name="voice_overlay_settings_hint">两个版本均可选择使用。在聊天的语音专注模式中选择悬浮窗。仅授予权限不会开始监听。</string>
|
||||
<string name="voice_overlay_reset_position">重置位置</string>
|
||||
<string name="injected_context_gateway_unsupported">Gateway 聊天不会发送此内容。此连接不支持每轮附加上下文。</string>
|
||||
<string name="dashboard_http_risk">HTTP 不会加密发送到 %1$s 的请求。密码、会话令牌和对话可能泄露。如果依赖 VPN,您有责任确保流量始终处于 VPN 内,包括 VPN 断开时。应用不会检测或强制执行 VPN 保护。</string>
|
||||
<string name="dashboard_http_allow">我接受风险并允许此地址使用 HTTP</string>
|
||||
<string name="cw_auth_verified">登录已验证</string>
|
||||
<string name="cw_chat_checked_on_open">打开 Chat 时验证</string>
|
||||
<string name="dashboard_local_auth_help">Hermes 可达,但受保护的请求未获授权。如果 Dashboard 从回环地址转发,请检查主机的监听地址、身份验证提供方和 dashboard.public_url。仅凭 401 无法确定原因。</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Frage %1$d von %2$d</string>
|
||||
<string name="clarify_batch_complete">Alle Fragen beantwortet</string>
|
||||
<string name="clarify_batch_expired">Diese Anfrage ist beendet</string>
|
||||
<string name="clarify_batch_sending">Antwort wird gesendet…</string>
|
||||
<string name="clarify_batch_answered">Beantwortete Fragen (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">Agentenoberfläche</string>
|
||||
@@ -310,7 +315,7 @@
|
||||
<string name="cw_cloud_subtitle">Mit deinem gehosteten Agenten verbinden</string>
|
||||
<string name="cw_server_vps_title">Remote-Gateway</string>
|
||||
<string name="cw_server_vps_subtitle">Dashboard-Adresse eingeben</string>
|
||||
<string name="cw_relay_optional_note">Private LAN- und Tailscale-Adressen dürfen HTTP oder HTTPS verwenden. Öffentliche Adressen erfordern HTTPS. Relay und Direct API sind optional.</string>
|
||||
<string name="cw_relay_optional_note">HTTPS wird empfohlen. Andere HTTP-Adressen erfordern eine ausdrückliche Risikoannahme. Die App erzwingt keinen VPN-Schutz. Relay und Direct API sind optional.</string>
|
||||
<string name="cw_cloud_entry_title">Mit von Nous gehostetem Hermes verbinden</string>
|
||||
<string name="cw_cloud_entry_description">Gib die im Nous Portal angezeigte Agentenadresse ein. Nach dem Auffinden von Hermes meldest du dich sicher an.</string>
|
||||
<string name="cw_cloud_agent_name">Agentenadresse</string>
|
||||
@@ -318,7 +323,7 @@
|
||||
<string name="cw_cloud_slug_hint">Wir verbinden dich mit dein-agent.agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_slug_error">Gib exakt die Buchstaben, Zahlen und Bindestriche vor .agents.nousresearch.com ein.</string>
|
||||
<string name="cw_cloud_address_placeholder">https://dein-agent.example.com</string>
|
||||
<string name="cw_cloud_address_hint">Verwende die vollständige HTTPS-Adresse, die für deinen gehosteten Agenten angezeigt wird.</string>
|
||||
<string name="cw_cloud_address_hint">Verwende die vollständige HTTPS-Adresse, die für deinen gehosteten Agenten angezeigt wird, einschließlich :Port, falls angegeben.</string>
|
||||
<string name="cw_cloud_use_custom_address">Benutzerdefinierte URL verwenden</string>
|
||||
<string name="cw_cloud_use_nous_address">Von Nous gehostete Adresse verwenden</string>
|
||||
<string name="cw_before_connecting">Vor dem Verbinden</string>
|
||||
@@ -338,10 +343,10 @@
|
||||
<string name="cw_nearby_enter_address">Stattdessen Adresse eingeben</string>
|
||||
<string name="cw_other_connection_methods">Andere Verbindungsmethoden</string>
|
||||
<string name="cw_manual_hermes_title">Gateway-Adresse eingeben</string>
|
||||
<string name="cw_manual_hermes_description">Gib die Dashboard-Adresse ein, die du im Browser öffnest. Wenn sie auf diesem Smartphone nicht geöffnet wird, starte hermes dashboard und prüfe WLAN oder Tailscale.</string>
|
||||
<string name="cw_manual_hermes_description">Gib die Dashboard-Adresse ein, die sich auf diesem Telefon öffnen lässt. Für LAN oder VPN benötigt der Host ein erreichbares Dashboard mit Authentifizierung. Wenn der Browser keine Verbindung herstellt, lies die Einrichtungsanleitung.</string>
|
||||
<string name="cw_hermes_address">Hermes-Adresse</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 oder hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">Kein API-Schlüssel erforderlich. Ohne Port wird Dashboard-Port 9119 verwendet.</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="cw_hermes_address_hint">Beispiele: 192.168.1.10:9119 oder https://hermes.example.com:10443. Ohne Angabe verwenden private Hosts Port 9119 und HTTPS Port 443. Kein API-Schlüssel nötig.</string>
|
||||
<string name="cw_find_hermes">Hermes suchen</string>
|
||||
<string name="cw_hermes_found">Hermes gefunden</string>
|
||||
<string name="cw_ready_to_connect">Verbindungsbereit</string>
|
||||
@@ -364,12 +369,12 @@
|
||||
<string name="cw_skip_for_now">Vorerst überspringen — später in den Einstellungen einrichten</string>
|
||||
<string name="cw_back">Zurück</string>
|
||||
<string name="cw_connect_button">Verbinden</string>
|
||||
<string name="cw_hermes_label">Hermes</string>
|
||||
<string name="cw_hermes_label_desc">Verwende dies für Chat und Verwaltung. Kopple Relay später nur, wenn du Terminal, Bridge, Relay-Sitzungen oder Berechtigungen aktivierst. Die Dashboard-Anmeldung ist der bevorzugte vorgelagerte Authentifizierungsweg; der API-Schlüssel bleibt die Ausweichlösung für Android-Chat.</string>
|
||||
<string name="cw_hermes_label">Direct API</string>
|
||||
<string name="cw_hermes_label_desc">Direct API ist eine ausdrücklich gewählte Kompatibilitätsverbindung für API-Server-Chats. Manage, Dashboard-Sitzungen und Standard-Sprachfunktionen erfordern eine separate Dashboard-Anmeldung. Bestehende Gateway-Unterhaltungen wechseln nicht zu Direct API.</string>
|
||||
<string name="cw_api_url_label">API-Server-URL oder Host</string>
|
||||
<string name="cw_api_key_label">API-Schlüssel</string>
|
||||
<string name="cw_api_key_placeholder">Wert aus API_SERVER_KEY</string>
|
||||
<string name="cw_api_key_hint">Für Android-Chat erforderlich, bis der Dashboard-Gateway-Transport aktiviert ist.</string>
|
||||
<string name="cw_api_key_hint">Bearer-Schlüssel nur für diesen API-Server. Er meldet dich nicht am Dashboard an.</string>
|
||||
<string name="cw_tailscale_label">Fernzugriff — Tailscale-URL (optional)</string>
|
||||
<string name="cw_dashboard">Dashboard</string>
|
||||
<string name="cw_dashboard_routes_hint">Routen: zuerst Standard, bei Erreichbarkeit Tailscale als Ausweichroute.</string>
|
||||
@@ -385,7 +390,7 @@
|
||||
|
||||
<!-- Method tiles -->
|
||||
<string name="cw_method_hermes_title">Hermes</string>
|
||||
<string name="cw_method_hermes_subtitle">API-/Dashboard-Einrichtung für Chat, Verwaltung, Skills, Cron, MCP, Profile, Modelle und Einstellungen</string>
|
||||
<string name="cw_method_hermes_subtitle">Ausdrücklich gewählter API-Server-Chat für Headless- oder Kompatibilitätskonfigurationen</string>
|
||||
<string name="cw_method_scan_title">Einrichtungs-QR-Code scannen</string>
|
||||
<string name="cw_method_scan_subtitle">Scanne einen QR-Code mit API-URL/-Schlüssel für Hermes; Relay-QR-Daten erfordern das Relay-Plugin</string>
|
||||
<string name="cw_method_pair_code_title">Relay per Code koppeln</string>
|
||||
@@ -395,7 +400,7 @@
|
||||
|
||||
<!-- Standard connect form -->
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 oder http://dein-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Von Chat und Sitzungen verwendete Hermes-API — bei reinen Hosts werden API-Port 8642 und http:// angenommen (Dashboard-Port 9119 wird separat abgeleitet)</string>
|
||||
<string name="cw_api_url_supporting">API-Server-Adresse für Direct-API-Chat. Reine Hostnamen verwenden HTTP und Port 8642.</string>
|
||||
<string name="cw_scan_message">Dieses LAN wird nach Hermes-Dashboard/API durchsucht…</string>
|
||||
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel gilt nur für eine explizite Direct-API-Verbindung.</string>
|
||||
<string name="cw_pair_relay_section">Relay koppeln (optional)</string>
|
||||
@@ -490,7 +495,7 @@
|
||||
<string name="cw_timeout_entercode_showcode">Der Host hat diesen Kopplungscode noch nicht angenommen. Führe den Kopplungsbefehl auf deinem Hermes-Host aus (oder prüfe den Code erneut) und tippe dann auf Erneut versuchen.</string>
|
||||
<string name="cw_timeout_scan">Zeitüberschreitung, bevor das Relay die Kopplung bestätigt hat. Prüfe, ob das Relay läuft und in diesem Netzwerk erreichbar ist, und versuche es erneut. Falls die Kopplung erfolgreich scheint, Hermes aber weiterhin nicht erreichbar ist, befindet sich der API-Server möglicherweise hinter einem Anmelde-Gateway.</string>
|
||||
<string name="cw_timeout_other">Zeitüberschreitung beim Warten auf das Relay. Prüfe, ob das Relay läuft und die URL stimmt.</string>
|
||||
<string name="cw_camera_denied">Kameraberechtigung abgelehnt. Kopple stattdessen manuell — wähle \"Relay per Code koppeln\" oder gib deine Server-URL ein.</string>
|
||||
<string name="cw_camera_denied">Kamerazugriff verweigert. Wähle Remote-Gateway und gib die Dashboard-Adresse ein. Relay-Kopplungscodes findest du unter Erweitert.</string>
|
||||
|
||||
<!-- EndpointsCard -->
|
||||
<string name="endpoints_no_routes_stored">Für diese Verbindung sind keine Routen gespeichert.</string>
|
||||
@@ -799,7 +804,7 @@
|
||||
<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_share_phone_status_desc">App- und Smartphone-Kontext in reinen API-Chats mitsenden. Wird in Standard-Gateway-Chats nicht gesendet.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + Berechtigungen</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Ob Bedienungshilfe, Bildschirmaufnahme, Overlay und Benachrichtigungen erlaubt sind</string>
|
||||
<string name="chat_settings_foreground_app">App im Vordergrund</string>
|
||||
@@ -808,7 +813,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Aktueller Akkustand in Prozent. Standardmäßig aus.</string>
|
||||
<string name="chat_settings_safety_rails">Schutzmechanismen</string>
|
||||
<string name="chat_settings_safety_rails_desc">Anzahl gesperrter Einträge und destruktiver Verben sowie Timer zur automatischen Deaktivierung</string>
|
||||
<string name="chat_settings_preview">Vorschau</string>
|
||||
<string name="chat_settings_preview">Beispiel für einen reinen API-Chat</string>
|
||||
<string name="chat_settings_no_system_message">(es wird keine Systemnachricht gesendet)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Werkzeughinweise auswerten</string>
|
||||
<string name="chat_settings_experimental">Experimentell</string>
|
||||
@@ -2783,6 +2788,7 @@
|
||||
<string name="model_picker_title">Modell</string>
|
||||
<string name="model_picker_search">Modelle oder Anbieter suchen…</string>
|
||||
<string name="model_picker_empty">Keine Modelle entsprechen deiner Suche</string>
|
||||
<string name="model_picker_no_models">Keine Modelle verfügbar. Versuche es mit Aktualisieren.</string>
|
||||
|
||||
<!-- InjectedContextSheet -->
|
||||
<string name="context_sheet_agent_sees">Was der Agent sieht</string>
|
||||
@@ -3342,7 +3348,7 @@
|
||||
<string name="endpoints_pin_title">Angeheftete Endpunkte</string>
|
||||
<string name="endpoints_route_editor_desc">Füge die Dashboard-/Gateway-Adresse hinzu, die dieses Telefon in diesem Netzwerk verwenden soll.</string>
|
||||
<string name="endpoints_route_name_placeholder">Routenname</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z oder host.ts.net</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 oder https://host.ts.net:10443</string>
|
||||
<string name="endpoints_url_supporting_blank">Dashboard-Adresse des Servers eingeben</string>
|
||||
<string name="endpoints_url_supporting_enter">Eine http://- oder https://-Dashboard-Adresse verwenden</string>
|
||||
<string name="endpoints_url_supporting_preview">Wird getestet: %1$s</string>
|
||||
@@ -3364,10 +3370,10 @@
|
||||
<string name="injected_context_phone_status_title">Smartphone-Status</string>
|
||||
<string name="injected_context_relay_not_set">Kein Relay konfiguriert</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Kontext, der mit der nächsten Nachricht gesendet wird</string>
|
||||
<string name="injected_context_subtitle">Vorschau für diesen Chat. Serverseitiger Kontext wird separat konfiguriert.</string>
|
||||
<string name="injected_context_title">Eingefügter Kontext</string>
|
||||
<string name="injected_context_turn_not_set">Nicht festgelegt</string>
|
||||
<string name="injected_context_turn_title">Modus zur Durchlauferkennung</string>
|
||||
<string name="injected_context_turn_title">Nachrichtenkontext</string>
|
||||
<string name="model_picker_cd_clear">Leeren</string>
|
||||
<string name="model_picker_count">%d Modelle</string>
|
||||
<string name="power_feature_back_desc">Zurück</string>
|
||||
@@ -3580,16 +3586,16 @@
|
||||
<string name="cw_relay_pair_qr">Hermes Relay koppeln</string>
|
||||
<string name="cw_relay_pair_qr_desc">Einen Relay-Einrichtungs-QR-Code scannen</string>
|
||||
<string name="cw_relay_enter_code">Pairing-Code eingeben</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes ist verbunden</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard und Gateway sind bereit. Du kannst chatten oder Manage öffnen.</string>
|
||||
<string name="cw_dashboard_connected_title">Dashboard-Zugriff bestätigt</string>
|
||||
<string name="cw_dashboard_connected_body">Weiter zu Chat. Gateway-Verbindung und Sprachverfügbarkeit werden getrennt geprüft.</string>
|
||||
<string name="cw_continue">Weiter</string>
|
||||
<string name="cw_timeline_discovered">Hermes gefunden</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard-Identität und Status-Endpunkt bestätigt</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard-Status abgerufen</string>
|
||||
<string name="cw_timeline_access">Dashboard-Zugriff</string>
|
||||
<string name="cw_timeline_access_ready">Keine weitere Anmeldung erforderlich</string>
|
||||
<string name="cw_timeline_authenticated">Authentifizierung bestätigt</string>
|
||||
<string name="cw_timeline_ready">Verbindung bereit</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage und Voice können dieses Dashboard verwenden</string>
|
||||
<string name="cw_timeline_ready">Bereit zum Fortfahren</string>
|
||||
<string name="cw_timeline_ready_detail">Chat verbindet sich beim Öffnen; Sprachverfügbarkeit wird getrennt geprüft</string>
|
||||
<string name="active_section_optional_api_fallback">Optionale Direct API</string>
|
||||
<string name="active_section_api_not_required">Nicht erforderlich, wenn diese Verbindung das Hermes Dashboard verwendet.</string>
|
||||
<string name="active_section_where_api_key">Wo erhalte ich diesen Schlüssel?</string>
|
||||
@@ -4543,11 +4549,11 @@
|
||||
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
|
||||
<string name="api_fallback_title">Direct API</string>
|
||||
<string name="current_surface_paths_title">Current paths</string>
|
||||
<string name="dashboard_address_editor_body">Lege die Dashboard- und Gateway-Adresse für dieses Telefon fest. Private LAN- und Tailscale-Routen dürfen HTTP oder HTTPS verwenden; öffentliche Routen erfordern HTTPS.</string>
|
||||
<string name="dashboard_address_editor_body">Lege die Dashboard- und Gateway-Adresse dieser Verbindung fest. Gib bei Bedarf :port an, zum Beispiel 192.168.1.10:9119 oder https://hermes.example.com:10443. HTTPS wird empfohlen. Andere HTTP-Adressen erfordern deine ausdrückliche Risikoannahme.</string>
|
||||
<string name="dashboard_address_editor_title">Gateway-Adresse</string>
|
||||
<string name="dashboard_address_label">Dashboard & Gateway address</string>
|
||||
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
|
||||
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
|
||||
<string name="dashboard_address_oidc_hint">Eine neue Adresse erhält dieses Gateway und seine Verlaufsverweise. Bei einem anderen Ursprung musst du dich erneut anmelden. OIDC-Rückrufe werden auf Hermes konfiguriert.</string>
|
||||
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="dashboard_address_preview">Will use: %1$s</string>
|
||||
<string name="dashboard_address_required">Gib die Hermes-Gateway-Adresse ein</string>
|
||||
<string name="dashboard_gateway_configured">Configured address</string>
|
||||
@@ -4596,4 +4602,18 @@
|
||||
<string name="tool_progress_status_dispatched">Übergeben</string>
|
||||
<string name="chat_activity_history_notice">Gespeicherte Aktivität. Fortschrittsmeldungen werden nicht gespeichert; der verfügbare Verlauf der Unteragenten kann schreibgeschützt geöffnet werden.</string>
|
||||
<string name="chat_activity_output_unavailable">Die Ausgabe ist nicht mehr verfügbar. Dieser Eintrag enthält den gespeicherten Prozessstatus.</string>
|
||||
<string name="profile_follow_server_default">Serverstandard folgen</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Sprache über anderen Apps</string>
|
||||
<string name="voice_overlay_setup_body">Sprachsteuerung bleibt über anderen Apps sichtbar. Audio geht an deinen konfigurierten Hermes-Server. Dies erlaubt weder Bildschirmlesen noch Telefonsteuerung. Erlaube die Anzeige über anderen Apps in den Android-Einstellungen und kehre zurück. Eine Mikrofonbenachrichtigung mit Stopp-Aktion ist erforderlich. Schließen des Overlays oder Sperren des Bildschirms beendet die Sprachsitzung.</string>
|
||||
<string name="voice_overlay_setup_start">Sprach-Overlay starten</string>
|
||||
<string name="voice_overlay_settings_hint">In beiden Versionen optional. Öffne den Sprachfokus im Chat und wähle Overlay. Berechtigungen allein starten kein Zuhören.</string>
|
||||
<string name="voice_overlay_reset_position">Position zurücksetzen</string>
|
||||
<string name="injected_context_gateway_unsupported">Wird im Gateway-Chat nicht gesendet. Diese Verbindung unterstützt keinen zusätzlichen Kontext pro Nachricht.</string>
|
||||
<string name="dashboard_http_risk">HTTP verschlüsselt Anfragen an %1$s nicht. Passwörter, Sitzungstoken und Unterhaltungen können offengelegt werden. Wenn du ein VPN nutzt, bist du dafür verantwortlich, dass der Verkehr darin bleibt, auch bei VPN-Ausfall. Die App erkennt oder erzwingt keinen VPN-Schutz.</string>
|
||||
<string name="dashboard_http_allow">Ich akzeptiere das Risiko und erlaube HTTP für diese Adresse</string>
|
||||
<string name="cw_auth_verified">Anmeldung bestätigt</string>
|
||||
<string name="cw_chat_checked_on_open">Wird beim Öffnen von Chat geprüft</string>
|
||||
<string name="dashboard_local_auth_help">Hermes ist erreichbar, aber geschützte Anfragen sind nicht autorisiert. Wird das Dashboard von Loopback weitergeleitet, prüfe Bind-Adresse, Authentifizierungsanbieter und dashboard.public_url auf dem Host. Ein 401 allein bestimmt die Ursache nicht.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Pregunta %1$d de %2$d</string>
|
||||
<string name="clarify_batch_complete">Todas las preguntas respondidas</string>
|
||||
<string name="clarify_batch_expired">Esta solicitud ha finalizado</string>
|
||||
<string name="clarify_batch_sending">Enviando respuesta…</string>
|
||||
<string name="clarify_batch_answered">Preguntas respondidas (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">interfaz del agente</string>
|
||||
@@ -272,7 +277,7 @@
|
||||
<string name="cw_cloud_subtitle">Conéctate a tu agente alojado</string>
|
||||
<string name="cw_server_vps_title">Gateway remoto</string>
|
||||
<string name="cw_server_vps_subtitle">Introduce la dirección del Dashboard</string>
|
||||
<string name="cw_relay_optional_note">Las direcciones privadas LAN y Tailscale pueden usar HTTP o HTTPS. Las direcciones públicas requieren HTTPS. Relay y la alternativa API son opcionales.</string>
|
||||
<string name="cw_relay_optional_note">Se recomienda HTTPS. Otras direcciones HTTP requieren aceptar el riesgo. La aplicación no impone protección VPN. Relay y Direct API son opcionales.</string>
|
||||
<string name="cw_cloud_entry_title">Conectarse a Hermes alojado por Nous</string>
|
||||
<string name="cw_cloud_entry_description">Introduce la dirección del agente que aparece en Nous Portal. Iniciarás sesión de forma segura después de encontrar Hermes.</string>
|
||||
<string name="cw_cloud_agent_name">Dirección del agente</string>
|
||||
@@ -280,7 +285,7 @@
|
||||
<string name="cw_cloud_slug_hint">Nos conectaremos a tu-agente.agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_slug_error">Introduce exactamente las letras, números y guiones anteriores a .agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_address_placeholder">https://tu-agente.example.com</string>
|
||||
<string name="cw_cloud_address_hint">Usa la dirección HTTPS completa que aparece para tu agente alojado.</string>
|
||||
<string name="cw_cloud_address_hint">Usa la dirección HTTPS completa que aparece para tu agente alojado, incluido :puerto si se proporciona.</string>
|
||||
<string name="cw_cloud_use_custom_address">Usar URL personalizada</string>
|
||||
<string name="cw_cloud_use_nous_address">Usar dirección alojada por Nous</string>
|
||||
<string name="cw_before_connecting">Antes de conectar</string>
|
||||
@@ -300,10 +305,10 @@
|
||||
<string name="cw_nearby_enter_address">Introducir la dirección</string>
|
||||
<string name="cw_other_connection_methods">Otros métodos de conexión</string>
|
||||
<string name="cw_manual_hermes_title">Introduce la dirección del gateway</string>
|
||||
<string name="cw_manual_hermes_description">Introduce la dirección del panel que abres en el navegador. Si no abre en este teléfono, inicia hermes dashboard y comprueba Wi-Fi o Tailscale.</string>
|
||||
<string name="cw_manual_hermes_description">Introduce la dirección del Dashboard que puedes abrir en este teléfono. Para LAN o VPN, el host necesita un Dashboard accesible y autenticación. Consulta la guía de configuración si el navegador no conecta.</string>
|
||||
<string name="cw_hermes_address">Dirección de Hermes</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 o hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">No se necesita una clave de API. Se usa el puerto 9119 si no incluyes uno.</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="cw_hermes_address_hint">Ejemplos: 192.168.1.10:9119 o https://hermes.example.com:10443. Si se omite, los hosts privados usan el puerto 9119 y HTTPS usa el 443. No se necesita clave API.</string>
|
||||
<string name="cw_find_hermes">Buscar Hermes</string>
|
||||
<string name="cw_hermes_found">Hermes encontrado</string>
|
||||
<string name="cw_ready_to_connect">Listo para conectar</string>
|
||||
@@ -326,12 +331,12 @@
|
||||
<string name="cw_skip_for_now">Saltar por ahora: configurar más tarde en Configuración</string>
|
||||
<string name="cw_back">Atrás</string>
|
||||
<string name="cw_connect_button">Conectar</string>
|
||||
<string name="cw_hermes_label">Hermes</string>
|
||||
<string name="cw_hermes_label_desc">Utilice esto para chatear y administrar. Empareje Relay más tarde solo cuando habilite Terminal, sesiones Bridge, Relay o concesiones. El inicio de sesión en el panel es la ruta de autenticación ascendente preferida; la clave API sigue siendo la alternativa del chat Android.</string>
|
||||
<string name="cw_hermes_label">Direct API</string>
|
||||
<string name="cw_hermes_label_desc">Direct API es una conexión de compatibilidad elegida explícitamente para el chat del servidor API. Manage, las sesiones del Dashboard y la voz estándar requieren iniciar sesión por separado en el Dashboard. No sustituye una conversación existente del Gateway.</string>
|
||||
<string name="cw_api_url_label">URL o host del servidor API</string>
|
||||
<string name="cw_api_key_label">Clave API</string>
|
||||
<string name="cw_api_key_placeholder">Valor de API_SERVER_KEY</string>
|
||||
<string name="cw_api_key_hint">Necesario para el chat Android hasta que se habilite el transporte gateway del panel.</string>
|
||||
<string name="cw_api_key_hint">Clave bearer solo para este servidor API. No inicia sesión en el Dashboard.</string>
|
||||
<string name="cw_tailscale_label">Acceso remoto: URL Tailscale (opcional)</string>
|
||||
<string name="cw_dashboard">Panel</string>
|
||||
<string name="cw_dashboard_routes_hint">Rutas: predeterminada primero, Tailscale alternativa cuando sea accesible.</string>
|
||||
@@ -345,7 +350,7 @@
|
||||
<string name="cw_update_button">Actualizar</string>
|
||||
<string name="cw_cancel">Cancelar</string>
|
||||
<string name="cw_method_hermes_title">Hermes</string>
|
||||
<string name="cw_method_hermes_subtitle">Configuración de API/dashboard para chat, administración, habilidades, cron, MCP, perfiles, modelos y configuraciones</string>
|
||||
<string name="cw_method_hermes_subtitle">Chat explícito del servidor API para instalaciones sin interfaz o de compatibilidad</string>
|
||||
<string name="cw_method_scan_title">Configuración de escaneo QR</string>
|
||||
<string name="cw_method_scan_subtitle">Escanee un QR con API URL/key para Hermes; Los detalles de Relay QR requieren el complemento Relay</string>
|
||||
<string name="cw_method_pair_code_title">Emparejar Relay por código</string>
|
||||
@@ -353,7 +358,7 @@
|
||||
<string name="cw_method_show_code_title">Mostrar código Relay</string>
|
||||
<string name="cw_method_show_code_subtitle">¿Sin cámara o QR? Registra el código de este teléfono en el anfitrión</string>
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 o http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API utilizado por Chat y sesiones — API puerto 8642 y http:// asumido para hosts desnudos (el 9119 del tablero se deriva por separado)</string>
|
||||
<string name="cw_api_url_supporting">Dirección del servidor para el chat Direct API. Los hosts sin esquema usan HTTP y el puerto 8642.</string>
|
||||
<string name="cw_scan_message">Escaneando esta LAN en busca de Hermes dashboard/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API solo se usa para la alternativa directa opcional mediante API.</string>
|
||||
<string name="cw_pair_relay_section">Emparejar Relay (opcional)</string>
|
||||
@@ -443,7 +448,7 @@
|
||||
<string name="cw_timeout_entercode_showcode">El anfitrión aún no ha aceptado este código de vinculación. Ejecuta el comando de emparejamiento en tu host Hermes (o vuelve a comprobar el código) y, a continuación, toca Reintentar.</string>
|
||||
<string name="cw_timeout_scan">Se agotó el tiempo antes de que el relay confirmara el emparejamiento. Compruebe que el relay se esté ejecutando y sea accesible en esta red y, a continuación, vuelva a intentarlo. Si el emparejamiento parece tener éxito pero aún no se puede acceder a Hermes, el servidor API puede estar detrás de un inicio de sesión gateway.</string>
|
||||
<string name="cw_timeout_other">Se agotó el tiempo de espera para el relay. Verifique que relay se esté ejecutando y que la URL sea correcta.</string>
|
||||
<string name="cw_camera_denied">Permiso de cámara denegado. En su lugar, empareje manualmente: elija \"Empareje Relay mediante el código\" o ingrese la URL de su servidor.</string>
|
||||
<string name="cw_camera_denied">Permiso de cámara denegado. Elige Gateway remoto e introduce la dirección del Dashboard. Los códigos de emparejamiento Relay están en Avanzado.</string>
|
||||
<string name="endpoints_no_routes_stored">No hay rutas almacenadas para esta conexión.</string>
|
||||
<string name="endpoints_add_route">Agregar ruta</string>
|
||||
<string name="endpoints_resolving">Resolviendo…</string>
|
||||
@@ -724,7 +729,7 @@
|
||||
<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_share_phone_status_desc">Incluir contexto de la app y el teléfono en chats solo por API. No se envía en los chats estándar de Gateway.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permisos</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Si se concede accesibilidad, captura de pantalla, superposición o notificaciones</string>
|
||||
<string name="chat_settings_foreground_app">Aplicación en primer plano</string>
|
||||
@@ -733,7 +738,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Porcentaje de batería actual. Desactivado de forma predeterminada.</string>
|
||||
<string name="chat_settings_safety_rails">Barandillas de seguridad</string>
|
||||
<string name="chat_settings_safety_rails_desc">Recuento de listas de bloqueo, recuento de verbos destructivos y temporizador de desactivación automática</string>
|
||||
<string name="chat_settings_preview">Avance</string>
|
||||
<string name="chat_settings_preview">Ejemplo de chat solo por API</string>
|
||||
<string name="chat_settings_no_system_message">(no se enviará ningún mensaje del sistema)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Anotaciones de la herramienta de análisis</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -2546,6 +2551,7 @@
|
||||
<string name="model_picker_title">Modelo</string>
|
||||
<string name="model_picker_search">Buscar modelos o proveedores…</string>
|
||||
<string name="model_picker_empty">Ningún modelo coincide con tu búsqueda</string>
|
||||
<string name="model_picker_no_models">No hay modelos disponibles. Prueba a actualizar.</string>
|
||||
<string name="context_sheet_agent_sees">Lo que ve el agente</string>
|
||||
<string name="context_sheet_transparency">El contexto adicional exacto antepuesto a tu próximo turno, para mayor transparencia.</string>
|
||||
<string name="context_sheet_persona">Persona/perfil</string>
|
||||
@@ -3006,7 +3012,7 @@
|
||||
<string name="endpoints_pin_title">Puntos finales fijados</string>
|
||||
<string name="endpoints_route_editor_desc">Añada la dirección de Dashboard/Gateway que debe usar este teléfono en esa red.</string>
|
||||
<string name="endpoints_route_name_placeholder">Nombre de la ruta</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z o host.ts.net</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 o https://host.ts.net:10443</string>
|
||||
<string name="endpoints_url_supporting_blank">Introduzca la dirección de Dashboard del servidor</string>
|
||||
<string name="endpoints_url_supporting_enter">Use una dirección de Dashboard http:// o https://</string>
|
||||
<string name="endpoints_url_supporting_preview">Se probará: %1$s</string>
|
||||
@@ -3028,10 +3034,10 @@
|
||||
<string name="injected_context_phone_status_title">Estado del teléfono</string>
|
||||
<string name="injected_context_relay_not_set">Ningún relay configurado</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Contexto enviado con el siguiente mensaje.</string>
|
||||
<string name="injected_context_subtitle">Vista previa de este chat. El contexto del servidor se configura por separado.</string>
|
||||
<string name="injected_context_title">Contexto inyectado</string>
|
||||
<string name="injected_context_turn_not_set">No establecido</string>
|
||||
<string name="injected_context_turn_title">Modo de detección de giro</string>
|
||||
<string name="injected_context_turn_title">Contexto del turno</string>
|
||||
<string name="model_picker_cd_clear">Borrar</string>
|
||||
<string name="model_picker_count">Modelos %d</string>
|
||||
<string name="power_feature_back_desc">Atrás</string>
|
||||
@@ -3269,16 +3275,16 @@
|
||||
<string name="cw_relay_pair_qr">Vincular Hermes Relay</string>
|
||||
<string name="cw_relay_pair_qr_desc">Escanea un QR de configuración de Relay</string>
|
||||
<string name="cw_relay_enter_code">Introducir código de vinculación</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard y Gateway están listos. Puedes empezar a chatear o abrir Manage.</string>
|
||||
<string name="cw_dashboard_connected_title">Acceso al Dashboard verificado</string>
|
||||
<string name="cw_dashboard_connected_body">Continúa a Chat. La conexión Gateway y la disponibilidad de voz se comprueban por separado.</string>
|
||||
<string name="cw_continue">Continuar</string>
|
||||
<string name="cw_timeline_discovered">Hermes encontrado</string>
|
||||
<string name="cw_timeline_discovered_detail">Identidad del Dashboard y endpoint de estado verificados</string>
|
||||
<string name="cw_timeline_discovered_detail">Estado del Dashboard obtenido</string>
|
||||
<string name="cw_timeline_access">Acceso al Dashboard</string>
|
||||
<string name="cw_timeline_access_ready">No se requiere otro inicio de sesión</string>
|
||||
<string name="cw_timeline_authenticated">Autenticación verificada</string>
|
||||
<string name="cw_timeline_ready">Conexión lista</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage y Voice pueden usar este Dashboard</string>
|
||||
<string name="cw_timeline_ready">Listo para continuar</string>
|
||||
<string name="cw_timeline_ready_detail">Chat conecta al abrirse; la voz se comprueba por separado</string>
|
||||
<string name="active_section_optional_api_fallback">Alternativa directa opcional mediante API</string>
|
||||
<string name="active_section_api_not_required">No es necesaria cuando esta conexión usa Hermes Dashboard.</string>
|
||||
<string name="active_section_where_api_key">¿Dónde obtengo esta clave?</string>
|
||||
@@ -4234,11 +4240,11 @@
|
||||
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
|
||||
<string name="api_fallback_title">API directa</string>
|
||||
<string name="current_surface_paths_title">Current paths</string>
|
||||
<string name="dashboard_address_editor_body">Define la dirección del Dashboard y Gateway que usará este teléfono. Las rutas privadas LAN y Tailscale pueden usar HTTP o HTTPS; las rutas públicas requieren HTTPS.</string>
|
||||
<string name="dashboard_address_editor_body">Configura la dirección del Dashboard y Gateway de esta conexión. Incluye :puerto cuando sea necesario, por ejemplo 192.168.1.10:9119 o https://hermes.example.com:10443. Se recomienda HTTPS. Otras direcciones HTTP requieren aceptar expresamente el riesgo.</string>
|
||||
<string name="dashboard_address_editor_title">Dirección del gateway</string>
|
||||
<string name="dashboard_address_label">Dashboard & Gateway address</string>
|
||||
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
|
||||
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
|
||||
<string name="dashboard_address_oidc_hint">Cambiar la dirección conserva este gateway y sus referencias al historial, pero un origen distinto requiere volver a iniciar sesión. Las devoluciones OIDC se configuran en Hermes.</string>
|
||||
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="dashboard_address_preview">Will use: %1$s</string>
|
||||
<string name="dashboard_address_required">Introduce la dirección del Gateway Hermes</string>
|
||||
<string name="dashboard_gateway_configured">Configured address</string>
|
||||
@@ -4287,4 +4293,18 @@
|
||||
<string name="tool_progress_status_dispatched">Enviado</string>
|
||||
<string name="chat_activity_history_notice">Actividad registrada. No se conservan las actualizaciones de progreso; el historial disponible de los subagentes se puede abrir en modo de solo lectura.</string>
|
||||
<string name="chat_activity_output_unavailable">La salida ya no está disponible. Esta entrada conserva el estado registrado del proceso.</string>
|
||||
<string name="profile_follow_server_default">Seguir el perfil predeterminado del servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre otras apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantén los controles de voz visibles sobre otra app. El audio se envía a tu servidor Hermes configurado. Esto no permite leer la pantalla ni controlar el teléfono. Permite mostrar sobre otras apps en los ajustes de Android y vuelve aquí. Se requiere una notificación del micrófono con Detener voz. Cerrar la ventana o bloquear la pantalla termina la sesión de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar ventana de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional en ambas versiones. Abre el enfoque de voz en Chat y elige Superposición. Los permisos por sí solos nunca inician la escucha.</string>
|
||||
<string name="voice_overlay_reset_position">Restablecer posición</string>
|
||||
<string name="injected_context_gateway_unsupported">No se envía en el chat de Gateway. Esta conexión no admite contexto adicional por turno.</string>
|
||||
<string name="dashboard_http_risk">HTTP no cifra las solicitudes a %1$s. Las contraseñas, los tokens de sesión y las conversaciones podrían quedar expuestos. Si dependes de una VPN, eres responsable de mantener el tráfico dentro de ella, incluso si falla. La aplicación no detecta ni impone protección VPN.</string>
|
||||
<string name="dashboard_http_allow">Acepto el riesgo y permito HTTP para esta dirección</string>
|
||||
<string name="cw_auth_verified">Inicio de sesión verificado</string>
|
||||
<string name="cw_chat_checked_on_open">Se verifica al abrir Chat</string>
|
||||
<string name="dashboard_local_auth_help">Hermes es accesible, pero las solicitudes protegidas no están autorizadas. Si el Dashboard se reenvía desde loopback, comprueba la dirección de escucha, el proveedor de autenticación y dashboard.public_url en el host. Un 401 por sí solo no identifica la causa.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">質問 %1$d / %2$d</string>
|
||||
<string name="clarify_batch_complete">すべての質問に回答しました</string>
|
||||
<string name="clarify_batch_expired">このリクエストは終了しました</string>
|
||||
<string name="clarify_batch_sending">回答を送信中…</string>
|
||||
<string name="clarify_batch_answered">回答済みの質問(%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">エージェントインターフェース</string>
|
||||
@@ -310,7 +315,7 @@
|
||||
<string name="cw_cloud_subtitle">ホスト済みエージェントに接続します</string>
|
||||
<string name="cw_server_vps_title">リモートゲートウェイ</string>
|
||||
<string name="cw_server_vps_subtitle">Dashboard アドレスを入力</string>
|
||||
<string name="cw_relay_optional_note">プライベート LAN と Tailscale のアドレスは HTTP または HTTPS を使用できます。公開アドレスには HTTPS が必要です。Relay と Direct API は任意です。</string>
|
||||
<string name="cw_relay_optional_note">HTTPS を推奨します。それ以外の HTTP アドレスには明示的なリスク同意が必要です。アプリは VPN 保護を強制しません。Relay と Direct API は任意です。</string>
|
||||
<string name="cw_cloud_entry_title">Nous ホスト版 Hermes に接続</string>
|
||||
<string name="cw_cloud_entry_description">Nous Portal に表示されるエージェントのアドレスを入力してください。Hermes が見つかった後、安全にサインインします。</string>
|
||||
<string name="cw_cloud_agent_name">エージェントのアドレス</string>
|
||||
@@ -318,7 +323,7 @@
|
||||
<string name="cw_cloud_slug_hint">your-agent.agents.nousresearch.com に接続します。</string>
|
||||
<string name="cw_cloud_slug_error">.agents.nousresearch.com の前にある英字、数字、ハイフンを正確に入力してください。</string>
|
||||
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
|
||||
<string name="cw_cloud_address_hint">ホスト済みエージェントに表示される完全な HTTPS アドレスを使用してください。</string>
|
||||
<string name="cw_cloud_address_hint">ホスト済みエージェントに表示される完全な HTTPS アドレスを使用し、指定されている場合は :ポートも含めてください。</string>
|
||||
<string name="cw_cloud_use_custom_address">カスタム URL を使用</string>
|
||||
<string name="cw_cloud_use_nous_address">Nous ホスト版アドレスを使用</string>
|
||||
<string name="cw_before_connecting">接続する前に</string>
|
||||
@@ -338,10 +343,10 @@
|
||||
<string name="cw_nearby_enter_address">アドレスを入力</string>
|
||||
<string name="cw_other_connection_methods">その他の接続方法</string>
|
||||
<string name="cw_manual_hermes_title">ゲートウェイのアドレスを入力</string>
|
||||
<string name="cw_manual_hermes_description">ブラウザで開くダッシュボードアドレスを入力します。このスマートフォンで開けない場合は hermes dashboard を起動し、Wi-Fi または Tailscale を確認してください。</string>
|
||||
<string name="cw_manual_hermes_description">この端末のブラウザーで開ける Dashboard アドレスを入力してください。LAN や VPN では、ホストの Dashboard が到達可能で認証を設定済みである必要があります。接続できない場合はセットアップガイドを確認してください。</string>
|
||||
<string name="cw_hermes_address">Hermes アドレス</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 または hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">API キーは不要です。ポートを省略するとダッシュボードの 9119 番ポートを使用します。</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="cw_hermes_address_hint">例: 192.168.1.10:9119 または https://hermes.example.com:10443。省略した場合、プライベートホストはポート 9119、HTTPS はポート 443 を使用します。API キーは不要です。</string>
|
||||
<string name="cw_find_hermes">Hermes を検索</string>
|
||||
<string name="cw_hermes_found">Hermes が見つかりました</string>
|
||||
<string name="cw_ready_to_connect">接続できます</string>
|
||||
@@ -364,12 +369,12 @@
|
||||
<string name="cw_skip_for_now">今はスキップします — 後で [設定] で設定します</string>
|
||||
<string name="cw_back">戻る</string>
|
||||
<string name="cw_connect_button">接続する</string>
|
||||
<string name="cw_hermes_label">Hermes</string>
|
||||
<string name="cw_hermes_label_desc">チャットと管理に使用します。後でターミナル、Bridge、Relay セッション、または許可を有効にする場合にのみ、Relay をペアリングします。ダッシュボード サインインは、優先されるアップストリーム認証パスです。 API キーは Android チャット フォールバックのままです。</string>
|
||||
<string name="cw_hermes_label">Direct API</string>
|
||||
<string name="cw_hermes_label_desc">Direct API は、API サーバーのチャット用に明示的に選ぶ互換接続です。Manage、Dashboard のセッション、標準音声には別途 Dashboard へのサインインが必要です。既存の Gateway の会話を引き継ぐものではありません。</string>
|
||||
<string name="cw_api_url_label">API サーバー URL またはホスト</string>
|
||||
<string name="cw_api_key_label">API キー</string>
|
||||
<string name="cw_api_key_placeholder">API_SERVER_KEY の値</string>
|
||||
<string name="cw_api_key_hint">ダッシュボード Gateway トランスポートが有効になるまで、Android チャットに必要です。</string>
|
||||
<string name="cw_api_key_hint">この API サーバー専用の Bearer キーです。Dashboard へのサインインには使えません。</string>
|
||||
<string name="cw_tailscale_label">リモート アクセス — Tailscale URL (オプション)</string>
|
||||
<string name="cw_dashboard">ダッシュボード</string>
|
||||
<string name="cw_dashboard_routes_hint">ルート: 最初はデフォルト、到達可能な場合は Tailscale フォールバック。</string>
|
||||
@@ -385,7 +390,7 @@
|
||||
|
||||
<!-- Method tiles -->
|
||||
<string name="cw_method_hermes_title">Hermes</string>
|
||||
<string name="cw_method_hermes_subtitle">API/チャット、管理、スキル、Cron、MCP、プロファイル、モデル、設定のダッシュボード設定</string>
|
||||
<string name="cw_method_hermes_subtitle">ヘッドレス構成や互換用途で明示的に使う API サーバーチャット</string>
|
||||
<string name="cw_method_scan_title">スキャン設定 QR</string>
|
||||
<string name="cw_method_scan_subtitle">QR を API URL/キーで Hermes にスキャンします。 Relay QR の詳細には Relay プラグインが必要です</string>
|
||||
<string name="cw_method_pair_code_title">Relay をコードでペアリングする</string>
|
||||
@@ -395,7 +400,7 @@
|
||||
|
||||
<!-- Standard connect form -->
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 または http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API チャットとセッションで使用されます — API ポート 8642 および http:// はベア ホストとして想定されます (ダッシュボードの 9119 は個別に派生します)</string>
|
||||
<string name="cw_api_url_supporting">Direct API チャット用の API サーバーアドレス。ホスト名だけの場合は HTTP とポート 8642 を使います。</string>
|
||||
<string name="cw_scan_message">この LAN をスキャンして Hermes ダッシュボード/API を探しています…</string>
|
||||
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。API キーは明示的な Direct API 接続でのみ使います。</string>
|
||||
<string name="cw_pair_relay_section">Relay のペア (オプション)</string>
|
||||
@@ -490,7 +495,7 @@
|
||||
<string name="cw_timeout_entercode_showcode">ホストはまだこのペアリング コードを受け入れていません。 Hermes ホストでペアリング コマンドを実行し (またはコードを再確認し)、[再試行] をタップします。</string>
|
||||
<string name="cw_timeout_scan">Relayがペアリングを確認する前にタイムアウトしました。Relayが実行中であり、このネットワーク上で到達可能であることを確認してから、再試行してください。ペアリングが成功したように見えても、Hermes にまだ到達できない場合は、API サーバーがログイン Gatewayの背後にある可能性があります。</string>
|
||||
<string name="cw_timeout_other">Relay待ちでタイムアウト。Relayが動作していること、URL が正しいことを確認してください。</string>
|
||||
<string name="cw_camera_denied">カメラの許可が拒否されました。代わりに手動でペアリングします。「コードで Relay をペアリング」を選択するか、サーバー URL を入力します。</string>
|
||||
<string name="cw_camera_denied">カメラの権限が拒否されました。リモート Gateway を選び、Dashboard アドレスを入力してください。Relay のペアリングコードは詳細設定にあります。</string>
|
||||
|
||||
<!-- EndpointsCard -->
|
||||
<string name="endpoints_no_routes_stored">この接続にはルートが保存されていません。</string>
|
||||
@@ -796,7 +801,7 @@
|
||||
<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_share_phone_status_desc">API のみのチャットにアプリと端末のコンテキストを含めます。標準の Gateway チャットでは送信されません。</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 権限</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">アクセシビリティ、画面キャプチャ、オーバーレイ、通知が許可されているかどうか</string>
|
||||
<string name="chat_settings_foreground_app">フォアグラウンドアプリ</string>
|
||||
@@ -805,7 +810,7 @@
|
||||
<string name="chat_settings_battery_level_desc">現在のバッテリーのパーセント。デフォルトではオフです。</string>
|
||||
<string name="chat_settings_safety_rails">安全レール</string>
|
||||
<string name="chat_settings_safety_rails_desc">ブロックリストの数、破壊的な動詞の数、および自動無効化タイマー</string>
|
||||
<string name="chat_settings_preview">プレビュー</string>
|
||||
<string name="chat_settings_preview">API のみのチャットの例</string>
|
||||
<string name="chat_settings_no_system_message">(システムメッセージは送信されません)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">解析ツールの注釈</string>
|
||||
<string name="chat_settings_experimental">実験的</string>
|
||||
@@ -2791,6 +2796,7 @@
|
||||
<string name="model_picker_title">モデル</string>
|
||||
<string name="model_picker_search">モデルまたはプロバイダーを検索…</string>
|
||||
<string name="model_picker_empty">検索に一致するモデルはありません</string>
|
||||
<string name="model_picker_no_models">利用可能なモデルがありません。更新してください。</string>
|
||||
|
||||
<!-- InjectedContextSheet -->
|
||||
<string name="context_sheet_agent_sees">エージェントが見ているもの</string>
|
||||
@@ -3349,7 +3355,7 @@
|
||||
<string name="endpoints_pin_title">固定されたエンドポイント</string>
|
||||
<string name="endpoints_route_editor_desc">このネットワークで電話が使用する Dashboard/Gateway アドレスを追加します。</string>
|
||||
<string name="endpoints_route_name_placeholder">路線名</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z または host.ts.net</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 または https://host.ts.net:10443</string>
|
||||
<string name="endpoints_url_supporting_blank">サーバーの Dashboard アドレスを入力します</string>
|
||||
<string name="endpoints_url_supporting_enter">http:// または https:// の Dashboard アドレスを使用します</string>
|
||||
<string name="endpoints_url_supporting_preview">テスト対象: %1$s</string>
|
||||
@@ -3371,10 +3377,10 @@
|
||||
<string name="injected_context_phone_status_title">電話のステータス</string>
|
||||
<string name="injected_context_relay_not_set">Relayが設定されていません</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">次のメッセージで送信されるコンテキスト</string>
|
||||
<string name="injected_context_subtitle">このチャットのプレビューです。サーバー側のコンテキストは別途設定されます。</string>
|
||||
<string name="injected_context_title">挿入されたコンテキスト</string>
|
||||
<string name="injected_context_turn_not_set">未設定</string>
|
||||
<string name="injected_context_turn_title">回転検出モード</string>
|
||||
<string name="injected_context_turn_title">ターンのコンテキスト</string>
|
||||
<string name="model_picker_cd_clear">クリア</string>
|
||||
<string name="model_picker_count">%d モデル</string>
|
||||
<string name="power_feature_back_desc">戻る</string>
|
||||
@@ -3576,16 +3582,16 @@
|
||||
<string name="cw_relay_pair_qr">Hermes Relay をペアリング</string>
|
||||
<string name="cw_relay_pair_qr_desc">Relay セットアップ QR をスキャン</string>
|
||||
<string name="cw_relay_enter_code">ペアリングコードを入力</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes に接続しました</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard と Gateway の準備ができました。チャットを開始するか Manage を開けます。</string>
|
||||
<string name="cw_dashboard_connected_title">Dashboard アクセス確認済み</string>
|
||||
<string name="cw_dashboard_connected_body">Chat に進みます。Gateway 接続と音声の利用可否は別途確認します。</string>
|
||||
<string name="cw_continue">続行</string>
|
||||
<string name="cw_timeline_discovered">Hermes を検出</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard の識別情報とステータスエンドポイントを確認済み</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard の状態を取得しました</string>
|
||||
<string name="cw_timeline_access">Dashboard アクセス</string>
|
||||
<string name="cw_timeline_access_ready">追加のサインインは不要です</string>
|
||||
<string name="cw_timeline_authenticated">認証を確認済み</string>
|
||||
<string name="cw_timeline_ready">接続準備完了</string>
|
||||
<string name="cw_timeline_ready_detail">Chat、Manage、Voice でこの Dashboard を使用できます</string>
|
||||
<string name="cw_timeline_ready">続行できます</string>
|
||||
<string name="cw_timeline_ready_detail">Chat は開く際に接続し、音声の利用可否は別途確認します</string>
|
||||
<string name="active_section_optional_api_fallback">任意の Direct API</string>
|
||||
<string name="active_section_api_not_required">この接続が Hermes Dashboard を使用する場合は必要ありません。</string>
|
||||
<string name="active_section_where_api_key">このキーはどこで入手しますか?</string>
|
||||
@@ -4538,11 +4544,11 @@
|
||||
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
|
||||
<string name="api_fallback_title">Direct API</string>
|
||||
<string name="current_surface_paths_title">Current paths</string>
|
||||
<string name="dashboard_address_editor_body">この端末で使う Dashboard と Gateway のアドレスを設定します。プライベート LAN と Tailscale ルートは HTTP または HTTPS を使用でき、公開ルートには HTTPS が必要です。</string>
|
||||
<string name="dashboard_address_editor_body">この接続の Dashboard と Gateway のアドレスを設定します。必要に応じて :ポートを指定してください(例: 192.168.1.10:9119 または https://hermes.example.com:10443)。HTTPS を推奨します。それ以外の HTTP アドレスにはリスクへの明示的な同意が必要です。</string>
|
||||
<string name="dashboard_address_editor_title">ゲートウェイのアドレス</string>
|
||||
<string name="dashboard_address_label">Dashboard & Gateway address</string>
|
||||
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
|
||||
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
|
||||
<string name="dashboard_address_oidc_hint">アドレスを変更しても Gateway と履歴への参照は保持されますが、接続元が変わる場合は再サインインが必要です。OIDC コールバックは Hermes 側で設定します。</string>
|
||||
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="dashboard_address_preview">Will use: %1$s</string>
|
||||
<string name="dashboard_address_required">Hermes Gateway のアドレスを入力してください</string>
|
||||
<string name="dashboard_gateway_configured">Configured address</string>
|
||||
@@ -4591,4 +4597,18 @@
|
||||
<string name="tool_progress_status_dispatched">ディスパッチ済み</string>
|
||||
<string name="chat_activity_history_notice">記録されたアクティビティです。進捗の更新は保存されません。利用可能なサブエージェントの履歴は読み取り専用で開けます。</string>
|
||||
<string name="chat_activity_output_unavailable">出力は利用できなくなりました。この項目には記録されたプロセスの状態が残っています。</string>
|
||||
<string name="profile_follow_server_default">サーバーのデフォルトに従う</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">他のアプリ上で音声を使用</string>
|
||||
<string name="voice_overlay_setup_body">他のアプリ上に音声操作を表示します。音声は設定したHermesサーバーに送信されます。画面の読み取りや端末操作は許可されません。Androidの設定で他のアプリ上への表示を許可し、ここに戻ってください。停止操作付きのマイク通知が必要です。表示を閉じるか画面をロックすると音声セッションは終了します。</string>
|
||||
<string name="voice_overlay_setup_start">音声オーバーレイを開始</string>
|
||||
<string name="voice_overlay_settings_hint">両方のビルドで任意に利用できます。チャットの音声フォーカスでオーバーレイを選択します。権限の付与だけで録音は始まりません。</string>
|
||||
<string name="voice_overlay_reset_position">位置をリセット</string>
|
||||
<string name="injected_context_gateway_unsupported">Gateway チャットでは送信されません。この接続はターンごとの追加コンテキストに対応していません。</string>
|
||||
<string name="dashboard_http_risk">HTTP は %1$s へのリクエストを暗号化しません。パスワード、セッショントークン、会話が露出する可能性があります。VPN に依存する場合、VPN 切断時も含め、通信を VPN 内に保つ責任は利用者にあります。アプリは VPN の保護を検出も強制もしません。</string>
|
||||
<string name="dashboard_http_allow">リスクを理解し、このアドレスで HTTP を許可します</string>
|
||||
<string name="cw_auth_verified">サインイン確認済み</string>
|
||||
<string name="cw_chat_checked_on_open">Chat を開く際に確認します</string>
|
||||
<string name="dashboard_local_auth_help">Hermes には到達できますが、保護されたリクエストが認可されていません。Dashboard をループバックから転送している場合は、ホストのバインドアドレス、認証プロバイダー、dashboard.public_url を確認してください。401 だけでは原因を特定できません。</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Вопрос %1$d из %2$d</string>
|
||||
<string name="clarify_batch_complete">Все вопросы отвечены</string>
|
||||
<string name="clarify_batch_expired">Этот запрос завершён</string>
|
||||
<string name="clarify_batch_sending">Отправка ответа…</string>
|
||||
<string name="clarify_batch_answered">Отвеченные вопросы (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">интерфейс агента</string>
|
||||
@@ -293,7 +298,7 @@
|
||||
<string name="cw_cloud_subtitle">Подключитесь к своему размещённому агенту</string>
|
||||
<string name="cw_server_vps_title">Удалённый шлюз</string>
|
||||
<string name="cw_server_vps_subtitle">Введите адрес Dashboard</string>
|
||||
<string name="cw_relay_optional_note">Частные адреса LAN и Tailscale могут использовать HTTP или HTTPS. Публичным адресам требуется HTTPS. Relay и Direct API необязательны.</string>
|
||||
<string name="cw_relay_optional_note">Рекомендуется HTTPS. Для других HTTP-адресов нужно явно принять риск. Приложение не обеспечивает защиту VPN. Relay и Direct API необязательны.</string>
|
||||
<string name="cw_cloud_entry_title">Подключиться к Hermes на хостинге Nous</string>
|
||||
<string name="cw_cloud_entry_description">Введите адрес агента, указанный в Nous Portal. После обнаружения Hermes вы безопасно войдёте в систему.</string>
|
||||
<string name="cw_cloud_agent_name">Адрес агента</string>
|
||||
@@ -301,7 +306,7 @@
|
||||
<string name="cw_cloud_slug_hint">Будет выполнено подключение к ваш-агент.agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_slug_error">Точно введите буквы, цифры и дефисы перед .agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
|
||||
<string name="cw_cloud_address_hint">Используйте полный HTTPS-адрес, указанный для вашего размещённого агента.</string>
|
||||
<string name="cw_cloud_address_hint">Используйте полный HTTPS-адрес, указанный для вашего размещённого агента, включая :порт, если он указан.</string>
|
||||
<string name="cw_cloud_use_custom_address">Использовать собственный URL</string>
|
||||
<string name="cw_cloud_use_nous_address">Использовать адрес хостинга Nous</string>
|
||||
<string name="cw_before_connecting">Перед подключением</string>
|
||||
@@ -328,10 +333,10 @@
|
||||
<string name="cw_relay_pair_qr_desc">Просканируйте QR-код настройки Relay</string>
|
||||
<string name="cw_relay_enter_code">Введите код сопоставления Relay</string>
|
||||
<string name="cw_manual_hermes_title">Введите адрес шлюза</string>
|
||||
<string name="cw_manual_hermes_description">Введите адрес панели управления, который вы открываете в браузере. Если он не открывается на этом телефоне, запустите панель управления Гермесом и проверьте Wi-Fi или Tailscale.</string>
|
||||
<string name="cw_manual_hermes_description">Введите адрес Dashboard, который открывается на этом телефоне. Для LAN или VPN на хосте нужен доступный Dashboard с аутентификацией. Если браузер не подключается, откройте руководство по настройке.</string>
|
||||
<string name="cw_hermes_address">Адрес Гермеса</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 или hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">API-ключ не требуется. Порт панели управления 9119 используется, если порт не указан.</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="cw_hermes_address_hint">Примеры: 192.168.1.10:9119 или https://hermes.example.com:10443. Если порт не указан, частные хосты используют 9119, а HTTPS — 443. Ключ API не нужен.</string>
|
||||
<string name="cw_find_hermes">Найти Гермеса</string>
|
||||
<string name="cw_hermes_found">Гермес найден</string>
|
||||
<string name="cw_ready_to_connect">Готов к подключению</string>
|
||||
@@ -341,16 +346,16 @@
|
||||
<string name="cw_could_not_verify">Не удалось проверить</string>
|
||||
<string name="cw_choose_another">Выбрать другой</string>
|
||||
<string name="cw_sign_in_to_hermes">Войти в Гермес</string>
|
||||
<string name="cw_dashboard_connected_title">Гермес подключен</string>
|
||||
<string name="cw_dashboard_connected_body">Панель управления и шлюз готовы. Вы можете начать чат или открыть управление.</string>
|
||||
<string name="cw_dashboard_connected_title">Доступ к Dashboard подтверждён</string>
|
||||
<string name="cw_dashboard_connected_body">Перейдите в чат. Подключение Gateway и доступность голоса проверяются отдельно.</string>
|
||||
<string name="cw_continue">Продолжить</string>
|
||||
<string name="cw_timeline_discovered">Обнаружен Гермес</string>
|
||||
<string name="cw_timeline_discovered_detail">Проверен идентификатор панели управления и endpoint состояния</string>
|
||||
<string name="cw_timeline_discovered_detail">Статус Dashboard получен</string>
|
||||
<string name="cw_timeline_access">Доступ к панели управления</string>
|
||||
<string name="cw_timeline_access_ready">Дополнительный вход не требуется</string>
|
||||
<string name="cw_timeline_authenticated">Аутентификация проверена</string>
|
||||
<string name="cw_timeline_ready">Подключение готово</string>
|
||||
<string name="cw_timeline_ready_detail">Чат, Управление и Голос могут использовать эту панель управления</string>
|
||||
<string name="cw_timeline_ready">Можно продолжить</string>
|
||||
<string name="cw_timeline_ready_detail">Чат подключается при открытии; голос проверяется отдельно</string>
|
||||
<string name="cw_semantics_hermes_available">Гермес доступен</string>
|
||||
<string name="cw_semantics_capability">%1$s: %2$s</string>
|
||||
<string name="cw_connect_description">Используйте эти параметры для совместимости только с API, настройки пользователя или альтернативного сопряжения Relay.</string>
|
||||
@@ -364,12 +369,12 @@
|
||||
<string name="cw_skip_for_now">Пропустить на данный момент — настроить позже в настройках</string>
|
||||
<string name="cw_back">Назад</string>
|
||||
<string name="cw_connect_button">Подключить</string>
|
||||
<string name="cw_hermes_label">Гермес</string>
|
||||
<string name="cw_hermes_label_desc">Используйте это для Чата и Управления. Сопрягайте плагин Relay позже только при включении Терминала, Моста, сессий Relay или разрешений. Вход на панели управления является предпочтительным способом аутентификации; ключ API остается резервным для Android Chat.</string>
|
||||
<string name="cw_hermes_label">Direct API</string>
|
||||
<string name="cw_hermes_label_desc">Direct API — явно выбранное совместимое подключение для чата API-сервера. Для Manage, сеансов Dashboard и стандартного голоса нужен отдельный вход в Dashboard. Существующий разговор Gateway не переносится в Direct API.</string>
|
||||
<string name="cw_api_url_label">URL или хост сервера API</string>
|
||||
<string name="cw_api_key_label">Ключ API</string>
|
||||
<string name="cw_api_key_placeholder">Значение из API_SERVER_KEY</string>
|
||||
<string name="cw_api_key_hint">Необходимо для Android Chat, пока не включен транспорт шлюза панели управления.</string>
|
||||
<string name="cw_api_key_hint">Bearer-ключ только для этого API-сервера. Он не выполняет вход в Dashboard.</string>
|
||||
<string name="cw_tailscale_label">Удалённый доступ — URL Tailscale (необязательно)</string>
|
||||
<string name="cw_dashboard">Панель управления</string>
|
||||
<string name="cw_dashboard_routes_hint">Маршруты: по умолчанию сначала, Tailscale при достижимости.</string>
|
||||
@@ -383,7 +388,7 @@
|
||||
<string name="cw_update_button">Обновить</string>
|
||||
<string name="cw_cancel">Отмена</string>
|
||||
<string name="cw_method_hermes_title">Подключение только через API</string>
|
||||
<string name="cw_method_hermes_subtitle">Совместимость и резервное копирование, когда Панель управления/Шлюз недоступны</string>
|
||||
<string name="cw_method_hermes_subtitle">Явно выбранный чат API-сервера для работы без интерфейса или совместимости</string>
|
||||
<string name="cw_method_scan_title">Сканирование QR-кода настройки</string>
|
||||
<string name="cw_method_scan_subtitle">Сканируйте QR-код с URL/ключом API для Гермеса; детали QR-кода Relay требуют плагина Relay</string>
|
||||
<string name="cw_method_pair_code_title">Сопряжение Relay по коду</string>
|
||||
@@ -391,7 +396,7 @@
|
||||
<string name="cw_method_show_code_title">Показать код Relay</string>
|
||||
<string name="cw_method_show_code_subtitle">Нет камеры или QR-кода? Зарегистрируйте код этого телефона на хосте</string>
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 или http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">API Гермеса, используемый Чатом и сеансами — порт API 8642 и http:// предполагаются для голых хостов (порт 9119 панели управления выводится отдельно)</string>
|
||||
<string name="cw_api_url_supporting">Адрес API-сервера для чата Direct API. Для хоста без схемы используются HTTP и порт 8642.</string>
|
||||
<string name="cw_scan_message">Сканирование этой локальной сети на предмет панели управления/API Гермеса…</string>
|
||||
<string name="cw_dashboard_signin_hint">Войдите через панель управления, чтобы разблокировать Управление и голос — ключ API предназначен только для явно настроенного Direct API.</string>
|
||||
<string name="cw_pair_relay_section">Сопряжение Relay (необязательно)</string>
|
||||
@@ -486,7 +491,7 @@
|
||||
<string name="cw_timeout_entercode_showcode">Хост ещё не принял этот код подключения. Запустите команду подключения на вашем хосте Гермеса (или проверьте код), затем нажмите Повторить.</string>
|
||||
<string name="cw_timeout_scan">Истекло время ожидания подтверждения подключения Relay. Проверьте, что Relay работает и доступно в этой сети, затем повторите. Если подключение кажется успешным, но Гермес всё ещё недоступен, сервер API может быть за брандмауэром.</string>
|
||||
<string name="cw_timeout_other">Истекло время ожидания ответа от Relay. Проверьте, что Relay работает и URL-адрес правильный.</string>
|
||||
<string name="cw_camera_denied">Доступ к камере запрещён. Подключитесь вручную — выберите "Подключить Relay по коду" или введите URL-адрес сервера.</string>
|
||||
<string name="cw_camera_denied">Доступ к камере запрещён. Выберите удалённый Gateway и введите адрес Dashboard. Коды сопряжения Relay доступны в дополнительных настройках.</string>
|
||||
<string name="endpoints_no_routes_stored">Нет сохранённых маршрутов для этого подключения.</string>
|
||||
<string name="endpoints_add_route">Добавить маршрут</string>
|
||||
<string name="endpoints_resolving">Разрешение…</string>
|
||||
@@ -773,7 +778,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Уведомления о фоновом чате</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Уведомлять, когда Гермес требует ввода или заканчивает работу, когда приложение находится в фоновом режиме</string>
|
||||
<string name="chat_settings_share_phone_status">Поделиться статусом телефона с агентом</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Включите короткое системное сообщение о приложении и телефоне в каждом обороте чата</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Добавлять контекст приложения и телефона в чаты только через API. В стандартных чатах Gateway он не отправляется.</string>
|
||||
<string name="chat_settings_bridge_permissions">Мост + разрешения</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Разрешения на доступность, захват экрана, наложение, уведомления</string>
|
||||
<string name="chat_settings_foreground_app">Приложение на переднем плане</string>
|
||||
@@ -782,7 +787,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Текущий процент заряда батареи. По умолчанию выключено.</string>
|
||||
<string name="chat_settings_safety_rails">Ограждения безопасности</string>
|
||||
<string name="chat_settings_safety_rails_desc">Количество в черном списке, количество деструктивных глаголов и таймер автоматического отключения</string>
|
||||
<string name="chat_settings_preview">Предпросмотр</string>
|
||||
<string name="chat_settings_preview">Пример чата только через API</string>
|
||||
<string name="chat_settings_no_system_message">(системное сообщение не будет отправлено)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Анализ аннотаций инструментов</string>
|
||||
<string name="chat_settings_experimental">Экспериментальные</string>
|
||||
@@ -2771,6 +2776,7 @@
|
||||
<string name="model_picker_title">Модель</string>
|
||||
<string name="model_picker_search">Поиск моделей или поставщиков\&#8230;</string>
|
||||
<string name="model_picker_empty">Нет моделей, соответствующих вашему запросу</string>
|
||||
<string name="model_picker_no_models">Нет доступных моделей. Попробуйте обновить список.</string>
|
||||
<string name="context_sheet_agent_sees">Что видит агент</string>
|
||||
<string name="context_sheet_transparency">Точный дополнительный контекст, добавляемый к вашему следующему ходу, для прозрачности.</string>
|
||||
<string name="context_sheet_persona">Персона / профиль</string>
|
||||
@@ -3210,7 +3216,7 @@
|
||||
<string name="endpoints_pin_title">Закрепленные конечные точки</string>
|
||||
<string name="endpoints_route_editor_desc">Добавьте адрес Dashboard/Gateway, который это устройство должно использовать в этой сети.</string>
|
||||
<string name="endpoints_route_name_placeholder">Название маршрута</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z или host.ts.net</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 или https://host.ts.net:10443</string>
|
||||
<string name="endpoints_url_supporting_blank">Введите адрес Dashboard сервера</string>
|
||||
<string name="endpoints_url_supporting_enter">Используйте адрес Dashboard с http:// или https://</string>
|
||||
<string name="endpoints_url_supporting_preview">Будет протестировано: %1$s</string>
|
||||
@@ -3234,10 +3240,10 @@
|
||||
<string name="injected_context_phone_status_title">Статус телефона</string>
|
||||
<string name="injected_context_relay_not_set">плагин Relay не настроен</string>
|
||||
<string name="injected_context_relay_title">плагин Relay</string>
|
||||
<string name="injected_context_subtitle">Контекст отправляется с следующим сообщением</string>
|
||||
<string name="injected_context_subtitle">Предпросмотр для этого чата. Контекст на стороне сервера настраивается отдельно.</string>
|
||||
<string name="injected_context_title">Внедренный контекст</string>
|
||||
<string name="injected_context_turn_not_set">Не установлено</string>
|
||||
<string name="injected_context_turn_title">Режим обнаружения Turn</string>
|
||||
<string name="injected_context_turn_title">Контекст хода</string>
|
||||
<string name="model_picker_cd_clear">Очистить</string>
|
||||
<string name="model_picker_count">%d моделей</string>
|
||||
<string name="power_feature_back_desc">Назад</string>
|
||||
@@ -4282,11 +4288,11 @@
|
||||
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
|
||||
<string name="api_fallback_title">Direct API</string>
|
||||
<string name="current_surface_paths_title">Current paths</string>
|
||||
<string name="dashboard_address_editor_body">Укажите адрес Dashboard и Gateway для этого телефона. Частные маршруты LAN и Tailscale могут использовать HTTP или HTTPS; публичным маршрутам требуется HTTPS.</string>
|
||||
<string name="dashboard_address_editor_body">Укажите адрес Dashboard и Gateway для этого подключения. При необходимости добавьте :порт, например 192.168.1.10:9119 или https://hermes.example.com:10443. Рекомендуется HTTPS. Для других HTTP-адресов необходимо явно принять риск.</string>
|
||||
<string name="dashboard_address_editor_title">Адрес шлюза</string>
|
||||
<string name="dashboard_address_label">Dashboard & Gateway address</string>
|
||||
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
|
||||
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
|
||||
<string name="dashboard_address_oidc_hint">Изменение адреса сохраняет шлюз и ссылки на историю, но другой источник требует повторного входа. Обратные вызовы OIDC настраиваются в Hermes.</string>
|
||||
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="dashboard_address_preview">Will use: %1$s</string>
|
||||
<string name="dashboard_address_required">Введите адрес Hermes Gateway</string>
|
||||
<string name="dashboard_gateway_configured">Configured address</string>
|
||||
@@ -4335,4 +4341,18 @@
|
||||
<string name="tool_progress_status_dispatched">Отправлено</string>
|
||||
<string name="chat_activity_history_notice">Сохранённая активность. Обновления хода работы не сохраняются; доступную историю субагентов можно открыть только для чтения.</string>
|
||||
<string name="chat_activity_output_unavailable">Вывод больше недоступен. Эта запись сохраняет зафиксированное состояние процесса.</string>
|
||||
<string name="profile_follow_server_default">Следовать профилю сервера по умолчанию</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Голос поверх других приложений</string>
|
||||
<string name="voice_overlay_setup_body">Голосовые элементы управления видны поверх другого приложения. Аудио отправляется на настроенный сервер Hermes. Это не разрешает чтение экрана или управление телефоном. Разрешите показ поверх приложений в настройках Android и вернитесь сюда. Требуется уведомление микрофона с кнопкой остановки. Закрытие окна или блокировка экрана завершает голосовой сеанс.</string>
|
||||
<string name="voice_overlay_setup_start">Начать голосовой оверлей</string>
|
||||
<string name="voice_overlay_settings_hint">Необязательно в обеих сборках. Откройте голосовой фокус в чате и выберите оверлей. Одни разрешения никогда не включают прослушивание.</string>
|
||||
<string name="voice_overlay_reset_position">Сбросить положение</string>
|
||||
<string name="injected_context_gateway_unsupported">Не отправляется в чате Gateway. Это подключение не поддерживает дополнительный контекст для каждого хода.</string>
|
||||
<string name="dashboard_http_risk">HTTP не шифрует запросы к %1$s. Пароли, токены сеансов и разговоры могут стать доступны посторонним. При использовании VPN вы отвечаете за то, чтобы трафик оставался в нём, в том числе при его отключении. Приложение не определяет и не обеспечивает защиту VPN.</string>
|
||||
<string name="dashboard_http_allow">Я принимаю риск и разрешаю HTTP для этого адреса</string>
|
||||
<string name="cw_auth_verified">Вход подтверждён</string>
|
||||
<string name="cw_chat_checked_on_open">Проверяется при открытии чата</string>
|
||||
<string name="dashboard_local_auth_help">Hermes доступен, но защищённые запросы не авторизованы. Если Dashboard перенаправляется с loopback, проверьте адрес прослушивания, провайдер аутентификации и dashboard.public_url на хосте. Сам по себе код 401 не определяет причину.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Question %1$d of %2$d</string>
|
||||
<string name="clarify_batch_complete">All questions answered</string>
|
||||
<string name="clarify_batch_expired">This request has ended</string>
|
||||
<string name="clarify_batch_sending">Sending answer…</string>
|
||||
<string name="clarify_batch_answered">Answered questions (%1$d)</string>
|
||||
<string name="chat_debug_title" translatable="false">Session diagnostics</string>
|
||||
<string name="chat_debug_open" translatable="false">Open session diagnostics</string>
|
||||
<string name="chat_debug_close" translatable="false">Close session diagnostics</string>
|
||||
@@ -344,7 +349,7 @@
|
||||
<string name="cw_cloud_subtitle">Connect to your hosted agent</string>
|
||||
<string name="cw_server_vps_title">Remote gateway</string>
|
||||
<string name="cw_server_vps_subtitle">Enter its Dashboard address</string>
|
||||
<string name="cw_relay_optional_note">Private LAN and Tailscale addresses may use HTTP or HTTPS. Public addresses require HTTPS. Relay and Direct API are optional.</string>
|
||||
<string name="cw_relay_optional_note">HTTPS is recommended. Other HTTP addresses need explicit risk acknowledgement. The app does not enforce VPN protection. Relay and Direct API are optional.</string>
|
||||
<string name="cw_cloud_entry_title">Connect to Nous-hosted Hermes</string>
|
||||
<string name="cw_cloud_entry_description">Enter the agent address shown in Nous Portal. You’ll sign in securely after Hermes is found.</string>
|
||||
<string name="cw_cloud_agent_name">Agent address</string>
|
||||
@@ -352,7 +357,7 @@
|
||||
<string name="cw_cloud_slug_hint">We’ll connect to your-agent.agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_slug_error">Enter the exact letters, numbers, and hyphens before .agents.nousresearch.com.</string>
|
||||
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
|
||||
<string name="cw_cloud_address_hint">Use the complete HTTPS address shown for your hosted agent.</string>
|
||||
<string name="cw_cloud_address_hint">Use the complete HTTPS address shown for your hosted agent, including :port if provided.</string>
|
||||
<string name="cw_cloud_use_custom_address">Use custom URL</string>
|
||||
<string name="cw_cloud_use_nous_address">Use Nous-hosted address</string>
|
||||
<string name="cw_before_connecting">Before you connect</string>
|
||||
@@ -379,10 +384,10 @@
|
||||
<string name="cw_relay_pair_qr_desc">Scan a Relay setup QR</string>
|
||||
<string name="cw_relay_enter_code">Enter a Relay pairing code</string>
|
||||
<string name="cw_manual_hermes_title">Enter gateway address</string>
|
||||
<string name="cw_manual_hermes_description">Enter the Dashboard address you open in a browser. If it does not open on this phone, start hermes dashboard and check Wi-Fi or Tailscale.</string>
|
||||
<string name="cw_manual_hermes_description">Enter the Dashboard address you can open on this phone. For LAN or VPN access, the host needs a reachable Dashboard and authentication. See the Setup Guide if the browser cannot connect.</string>
|
||||
<string name="cw_hermes_address">Hermes address</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10 or hermes.example.com</string>
|
||||
<string name="cw_hermes_address_hint">No API key is needed. Dashboard port 9119 is used when no port is included.</string>
|
||||
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="cw_hermes_address_hint">Examples: 192.168.1.10:9119 or https://hermes.example.com:10443. If omitted, private hosts use port 9119 and HTTPS uses port 443. No API key is needed.</string>
|
||||
<string name="cw_find_hermes">Find Hermes</string>
|
||||
<string name="cw_hermes_found">Hermes found</string>
|
||||
<string name="cw_ready_to_connect">Ready to connect</string>
|
||||
@@ -392,16 +397,16 @@
|
||||
<string name="cw_could_not_verify">Couldn’t verify</string>
|
||||
<string name="cw_choose_another">Choose another</string>
|
||||
<string name="cw_sign_in_to_hermes">Sign in to Hermes</string>
|
||||
<string name="cw_dashboard_connected_title">Hermes is connected</string>
|
||||
<string name="cw_dashboard_connected_body">Dashboard and Gateway are ready. You can start chatting or open Manage.</string>
|
||||
<string name="cw_dashboard_connected_title">Dashboard access verified</string>
|
||||
<string name="cw_dashboard_connected_body">Continue to Chat. The Gateway connection and voice availability are checked separately.</string>
|
||||
<string name="cw_continue">Continue</string>
|
||||
<string name="cw_timeline_discovered">Hermes discovered</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard identity and status endpoint verified</string>
|
||||
<string name="cw_timeline_discovered_detail">Dashboard status retrieved</string>
|
||||
<string name="cw_timeline_access">Dashboard access</string>
|
||||
<string name="cw_timeline_access_ready">No additional sign-in required</string>
|
||||
<string name="cw_timeline_authenticated">Authentication verified</string>
|
||||
<string name="cw_timeline_ready">Connection ready</string>
|
||||
<string name="cw_timeline_ready_detail">Chat, Manage, and Voice can use this Dashboard</string>
|
||||
<string name="cw_timeline_ready">Ready to continue</string>
|
||||
<string name="cw_timeline_ready_detail">Chat connects when opened; voice availability is checked separately</string>
|
||||
<string name="cw_semantics_hermes_available">Hermes available</string>
|
||||
<string name="cw_semantics_capability">%1$s: %2$s</string>
|
||||
<string name="cw_connect_description">Use these options for API-only compatibility, custom setup, or alternate Relay pairing.</string>
|
||||
@@ -415,12 +420,12 @@
|
||||
<string name="cw_skip_for_now">Skip for now — set up later in Settings</string>
|
||||
<string name="cw_back">Back</string>
|
||||
<string name="cw_connect_button">Connect</string>
|
||||
<string name="cw_hermes_label">Hermes</string>
|
||||
<string name="cw_hermes_label_desc">Use this for Chat and Manage. Pair Relay later only when you enable Terminal, Bridge, Relay sessions, or grants. Dashboard sign-in is the standard upstream auth path; an API key is only for explicit Direct API connections.</string>
|
||||
<string name="cw_hermes_label">Direct API</string>
|
||||
<string name="cw_hermes_label_desc">Direct API is an explicit compatibility connection for API-server chat. Manage, Dashboard sessions, and standard voice require a separate Dashboard sign-in. It does not take over an existing Gateway conversation.</string>
|
||||
<string name="cw_api_url_label">API server URL or host</string>
|
||||
<string name="cw_api_key_label">API key</string>
|
||||
<string name="cw_api_key_placeholder">Value from API_SERVER_KEY</string>
|
||||
<string name="cw_api_key_hint">Needed for Android Chat until the dashboard gateway transport is enabled.</string>
|
||||
<string name="cw_api_key_hint">Bearer key for this API server only. It does not sign in to Dashboard.</string>
|
||||
<string name="cw_tailscale_label">Remote access — Tailscale URL (optional)</string>
|
||||
<string name="cw_dashboard">Dashboard</string>
|
||||
<string name="cw_dashboard_routes_hint">Routes: default first, Tailscale fallback when reachable.</string>
|
||||
@@ -436,7 +441,7 @@
|
||||
|
||||
<!-- Method tiles -->
|
||||
<string name="cw_method_hermes_title">API-only connection</string>
|
||||
<string name="cw_method_hermes_subtitle">Compatibility and fallback when Dashboard/Gateway is unavailable</string>
|
||||
<string name="cw_method_hermes_subtitle">Explicit API-server chat for headless or compatibility setups</string>
|
||||
<string name="cw_method_scan_title">Scan setup QR</string>
|
||||
<string name="cw_method_scan_subtitle">Scan a QR with API URL/key for Hermes; Relay QR details require the Relay plugin</string>
|
||||
<string name="cw_method_pair_code_title">Pair Relay by code</string>
|
||||
@@ -446,7 +451,7 @@
|
||||
|
||||
<!-- Standard connect form -->
|
||||
<string name="cw_api_url_placeholder">192.168.1.10 or http://your-server:8642</string>
|
||||
<string name="cw_api_url_supporting">Hermes API used by Chat and sessions — API port 8642 and http:// assumed for bare hosts (the dashboard\'s 9119 is derived separately)</string>
|
||||
<string name="cw_api_url_supporting">API-server address for Direct API chat. Bare hosts use HTTP and port 8642.</string>
|
||||
<string name="cw_scan_message">Scanning this LAN for Hermes dashboard/API…</string>
|
||||
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is only for optional Direct API compatibility.</string>
|
||||
<string name="cw_pair_relay_section">Pair Relay (optional)</string>
|
||||
@@ -556,7 +561,7 @@
|
||||
<string name="cw_timeout_entercode_showcode">The host hasn\'t accepted this pairing code yet. Run the pairing command on your Hermes host (or re-check the code), then tap Retry.</string>
|
||||
<string name="cw_timeout_scan">Timed out before the relay confirmed pairing. Check the relay is running and reachable on this network, then Retry. If pairing seems to succeed but Hermes still can\'t be reached, the API server may be behind a login gateway.</string>
|
||||
<string name="cw_timeout_other">Timed out waiting for the relay. Check that the relay is running and the URL is correct.</string>
|
||||
<string name="cw_camera_denied">Camera permission denied. Pair manually instead — choose \"Pair Relay by code\" or enter your server URL.</string>
|
||||
<string name="cw_camera_denied">Camera permission denied. Choose Remote gateway to enter the Dashboard address. Relay pairing codes are available under Advanced.</string>
|
||||
|
||||
<!-- EndpointsCard -->
|
||||
<string name="endpoints_no_routes_stored">No routes stored for this connection.</string>
|
||||
@@ -612,12 +617,12 @@
|
||||
<string name="endpoints_close">Close</string>
|
||||
<string name="endpoints_actions">Endpoint actions</string>
|
||||
<string name="dashboard_address_editor_title">Gateway address</string>
|
||||
<string name="dashboard_address_editor_body">Set the Dashboard and Gateway address this phone should use. Private LAN and Tailscale routes may use HTTP or HTTPS; public routes require HTTPS.</string>
|
||||
<string name="dashboard_address_editor_body">Set the Dashboard and Gateway address for this connection. Include :port when needed, for example 192.168.1.10:9119 or https://hermes.example.com:10443. HTTPS is recommended. Other HTTP addresses need your explicit risk acknowledgement.</string>
|
||||
<string name="dashboard_address_label">Dashboard & Gateway address</string>
|
||||
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
|
||||
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
|
||||
<string name="dashboard_address_required">Enter your Hermes Gateway address</string>
|
||||
<string name="dashboard_address_preview">Will use: %1$s</string>
|
||||
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
|
||||
<string name="dashboard_address_oidc_hint">Changing the address preserves this gateway and its history references, but a different origin requires sign-in again. OIDC callbacks are configured on Hermes.</string>
|
||||
|
||||
<!-- BridgeMasterToggle -->
|
||||
<string name="bmt_agent_control">Agent Control</string>
|
||||
@@ -882,7 +887,7 @@
|
||||
<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_share_phone_status_desc">Include app and phone context in API-only chats. Not sent in standard Gateway chats.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissions</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Whether accessibility, screen capture, overlay, notifications are granted</string>
|
||||
<string name="chat_settings_foreground_app">Foreground app</string>
|
||||
@@ -891,7 +896,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Current battery percent. Off by default.</string>
|
||||
<string name="chat_settings_safety_rails">Safety rails</string>
|
||||
<string name="chat_settings_safety_rails_desc">Blocklist count, destructive-verb count, and auto-disable timer</string>
|
||||
<string name="chat_settings_preview">Preview</string>
|
||||
<string name="chat_settings_preview">Example for API-only chat</string>
|
||||
<string name="chat_settings_no_system_message">(no system message will be sent)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Parse tool annotations</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -3175,6 +3180,7 @@
|
||||
<string name="model_picker_title">Model</string>
|
||||
<string name="model_picker_search">Search models or providers…</string>
|
||||
<string name="model_picker_empty">No models match your search</string>
|
||||
<string name="model_picker_no_models">No models available. Try Refresh.</string>
|
||||
|
||||
<!-- InjectedContextSheet -->
|
||||
<string name="context_sheet_agent_sees">What the agent sees</string>
|
||||
@@ -3740,7 +3746,7 @@
|
||||
<string name="endpoints_pin_title">Pinned endpoints</string>
|
||||
<string name="endpoints_route_editor_desc">Add the Dashboard/Gateway address this phone should use on that network.</string>
|
||||
<string name="endpoints_route_name_placeholder">Route name</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z or host.ts.net</string>
|
||||
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 or https://host.ts.net:10443</string>
|
||||
<string name="endpoints_url_supporting_blank">Enter the server’s Dashboard address</string>
|
||||
<string name="endpoints_url_supporting_enter">Use an http:// or https:// Dashboard address</string>
|
||||
<string name="endpoints_url_supporting_preview">Will test: %1$s</string>
|
||||
@@ -3764,10 +3770,10 @@
|
||||
<string name="injected_context_phone_status_title">Phone status</string>
|
||||
<string name="injected_context_relay_not_set">No relay configured</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Context sent with next message</string>
|
||||
<string name="injected_context_subtitle">Preview for this chat. Server-side context is configured separately.</string>
|
||||
<string name="injected_context_title">Injected context</string>
|
||||
<string name="injected_context_turn_not_set">Not set</string>
|
||||
<string name="injected_context_turn_title">Turn detection mode</string>
|
||||
<string name="injected_context_turn_title">Turn context</string>
|
||||
<string name="model_picker_cd_clear">Clear</string>
|
||||
<string name="model_picker_count">%d models</string>
|
||||
<string name="power_feature_back_desc">Back</string>
|
||||
@@ -4654,4 +4660,18 @@
|
||||
<string name="dev_message_retry_result" translatable="false">Preview retry selected — no request sent.</string>
|
||||
<string name="dev_message_clear" translatable="false">Clear previews</string>
|
||||
<string name="dev_message_clear_desc" translatable="false">Dismiss only these samples; real app messages are preserved.</string>
|
||||
<string name="profile_follow_server_default">Follow server default</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voice over other apps</string>
|
||||
<string name="voice_overlay_setup_body">Keep voice controls visible while using another app. Audio goes to your configured Hermes server. This does not grant screen reading or phone control. Allow display over other apps in Android Settings, then return here. A microphone notification with Stop voice is required. Closing the overlay or locking the screen ends this voice session.</string>
|
||||
<string name="voice_overlay_setup_start">Start voice overlay</string>
|
||||
<string name="voice_overlay_settings_hint">Optional in both builds. Open Voice Focus in Chat and choose Overlay to start. Permissions alone never start listening.</string>
|
||||
<string name="voice_overlay_reset_position">Reset position</string>
|
||||
<string name="injected_context_gateway_unsupported">Not sent in Gateway chat. This connection does not support extra per-turn context.</string>
|
||||
<string name="dashboard_http_risk">HTTP does not encrypt requests to %1$s. Passwords, session tokens, and conversations may be exposed. If you rely on a VPN, you are responsible for keeping traffic inside it, including after VPN loss. The app does not detect or enforce VPN protection.</string>
|
||||
<string name="dashboard_http_allow">I accept the risk and allow HTTP for this address</string>
|
||||
<string name="cw_auth_verified">Sign-in verified</string>
|
||||
<string name="cw_chat_checked_on_open">Verified when Chat opens</string>
|
||||
<string name="dashboard_local_auth_help">Hermes is reachable, but protected requests are not authorized. If this Dashboard is forwarded from loopback, check its bind address, authentication provider, and dashboard.public_url on the host. A 401 alone does not identify the cause.</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
<!--
|
||||
Tier C (C1-C4) sideload-only permissions.
|
||||
|
||||
These are deliberately NOT declared in the main manifest: Google Play
|
||||
policy forbids CALL_PHONE / SEND_SMS / READ_CONTACTS without a default-
|
||||
dialer / default-SMS-app justification, and ACCESS_FINE_LOCATION is
|
||||
flagged alongside auto-dial on the `googlePlay` track. The `sideload`
|
||||
flavor is the only track that ships them.
|
||||
These are deliberately kept outside Play's voice-only capability surface.
|
||||
SMS permissions require an eligible default-handler/approved use on Play;
|
||||
contacts, foreground location and CALL_PHONE have distinct requirements.
|
||||
Their omission here is a product boundary, not a blanket permission ban.
|
||||
|
||||
Each of the four `android_*` tools (location, search_contacts, call,
|
||||
send_sms) runtime-checks the corresponding permission in
|
||||
|
||||
@@ -43,13 +43,13 @@ class AgentDisplayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveDisplayProfile_usesDefaultProfileForDisplayOnly() {
|
||||
fun effectiveDisplayProfile_doesNotAssumeRootWhenScopeIsUnknown() {
|
||||
val effective = AgentDisplay.effectiveDisplayProfile(
|
||||
selectedProfile = null,
|
||||
profiles = listOf(mizu, defaultProfile),
|
||||
)
|
||||
|
||||
assertEquals(defaultProfile, effective)
|
||||
assertNull(effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -86,7 +86,7 @@ class AgentDisplayTest {
|
||||
fun agentName_usesProfileNameNotVerboseDescription() {
|
||||
// The name slot shows the NAME, even when a (verbose) description exists.
|
||||
assertEquals(
|
||||
"Mizu",
|
||||
"mizu",
|
||||
AgentDisplay.agentName(
|
||||
profile = mizu.copy(description = "Builds and maintains the codebase"),
|
||||
selectedPersonality = "friendly",
|
||||
@@ -96,7 +96,7 @@ class AgentDisplayTest {
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Coder",
|
||||
"coder",
|
||||
AgentDisplay.agentName(
|
||||
profile = mizu.copy(name = "coder", description = ""),
|
||||
selectedPersonality = "friendly",
|
||||
@@ -107,11 +107,11 @@ class AgentDisplayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun agentName_usesConciseDefaultDescriptionNotVerboseSummary() {
|
||||
fun agentName_usesDisplayNameAndNeverInfersIdentityFromDescription() {
|
||||
assertEquals(
|
||||
"Victor",
|
||||
AgentDisplay.agentName(
|
||||
profile = defaultProfile.copy(description = "victor"),
|
||||
profile = defaultProfile.copy(displayName = "Victor", description = "Summary"),
|
||||
selectedPersonality = "default",
|
||||
defaultPersonality = "",
|
||||
connectionLabel = "Lab",
|
||||
@@ -119,7 +119,7 @@ class AgentDisplayTest {
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Lab",
|
||||
"default",
|
||||
AgentDisplay.agentName(
|
||||
profile = defaultProfile.copy(description = "Builds and maintains the codebase."),
|
||||
selectedPersonality = "default",
|
||||
@@ -175,7 +175,7 @@ class AgentDisplayTest {
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Lab",
|
||||
"Hermes",
|
||||
AgentDisplay.agentName(
|
||||
profile = null,
|
||||
selectedPersonality = "default",
|
||||
@@ -218,7 +218,7 @@ class AgentDisplayTest {
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Lab",
|
||||
"Hermes",
|
||||
AgentDisplay.agentName(
|
||||
profile = null,
|
||||
selectedPersonality = "none",
|
||||
@@ -255,6 +255,18 @@ class AgentDisplayTest {
|
||||
assertNull(AgentDisplay.effectiveSessionProfileName(null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun confirmedDefaultKeepsItsNameWhileRosterLoadsAndDoesNotBorrowRootMetadata() {
|
||||
val effective = AgentDisplay.effectiveDisplayProfile(null, listOf(defaultProfile), "victor")
|
||||
assertEquals("victor", effective?.name)
|
||||
assertEquals("victor", AgentDisplay.profileDisplayName(effective))
|
||||
assertEquals("default", AgentDisplay.profileDisplayName(defaultProfile))
|
||||
val victor = Profile("victor", "", displayName = "Victor")
|
||||
assertEquals("Victor", AgentDisplay.profileDisplayName(
|
||||
AgentDisplay.effectiveDisplayProfile(null, listOf(defaultProfile, victor), "victor")))
|
||||
assertEquals(defaultProfile, AgentDisplay.effectiveDisplayProfile(defaultProfile, listOf(victor), "victor"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayModelName_hidesGenericApiAlias() {
|
||||
assertNull(AgentDisplay.displayModelName("hermes-agent"))
|
||||
|
||||
@@ -56,6 +56,21 @@ class ChatTurnCheckpointStoreTest {
|
||||
assertEquals(checkpoint, store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialClarifyBatch_roundTripsWithExactQuestionOwnership() = runTest {
|
||||
val checkpoint = sampleCheckpoint().copy(pendingAsk = ChatTurnAskCheckpoint(
|
||||
kind = "CLARIFY", requestId = "batch", text = "", timeoutSeconds = 0,
|
||||
messageId = "ask-batch", cardKey = "batch", receivedAt = now,
|
||||
questions = listOf(
|
||||
com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion("route/a", "Which route?", listOf("Canary")),
|
||||
com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion("environment:b", "Which environments?", listOf("Stage", "Production"), true),
|
||||
), answers = mapOf("route/a" to "Canary"),
|
||||
))
|
||||
store.write(checkpoint)
|
||||
assertEquals(checkpoint, store.read())
|
||||
assertEquals(mapOf("route/a" to "Canary"), store.read()?.pendingAsk?.answers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptJson_isDiscarded() = runTest {
|
||||
dataStore.edit { preferences ->
|
||||
|
||||
@@ -6,6 +6,15 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionCapabilitiesTest {
|
||||
@Test
|
||||
fun emptySetupPlaceholderRetainsGatewayIntentBeforeAddressIsSaved() {
|
||||
val placeholder = connection(dashboardUrl = null, apiServerUrl = "", relayUrl = "")
|
||||
assertEquals(SessionTransport.GATEWAY, placeholder.automaticChatTransport)
|
||||
assertEquals(SessionTransport.GATEWAY, placeholder.chatTransportForPreference("auto"))
|
||||
assertEquals(SessionTransport.SSE, placeholder.chatTransportForPreference("completions"))
|
||||
assertEquals(SessionTransport.SSE, placeholder.copy(apiServerUrl = "http://127.0.0.1:8642").automaticChatTransport)
|
||||
assertEquals(SessionTransport.GATEWAY, placeholder.copy(dashboardUrl = "https://gateway.example.test").automaticChatTransport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyConnection_exposesStandardHermesCapabilities() {
|
||||
|
||||
@@ -329,6 +329,22 @@ class ConnectionDashboardFieldsTest {
|
||||
assertEquals("http://100.75.1.2:9119", Connection.normalizeDashboardUrlInput("100.75.1.2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizeDashboardUrlInput_preservesExplicitPorts() {
|
||||
assertEquals(
|
||||
"http://homelab.lan:9443",
|
||||
Connection.normalizeDashboardUrlInput("homelab.lan:9443"),
|
||||
)
|
||||
assertEquals(
|
||||
"https://hermes.example.com:10443",
|
||||
Connection.normalizeDashboardUrlInput("hermes.example.com:10443"),
|
||||
)
|
||||
assertEquals(
|
||||
"https://hermes.example.com:10443",
|
||||
Connection.normalizeDashboardUrlInput("https://hermes.example.com:10443"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discoveredLabel_prefersHostnameForAnUncustomizedIpLabel() {
|
||||
assertEquals(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
|
||||
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
|
||||
import com.hermesandroid.relay.network.upstream.dashboardClientWithHttpConsent
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import com.hermesandroid.relay.viewmodel.publicDashboardAddressRequiresHttps
|
||||
import io.mockk.mockk
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class DashboardHttpConsentTest {
|
||||
private val address = "http://11.0.0.1:9119"
|
||||
private val consent = setOf(address)
|
||||
|
||||
@Test fun consentDoesNotFollowRedirectsToAnotherOrigin() {
|
||||
MockWebServer().use { first ->
|
||||
MockWebServer().use { second ->
|
||||
first.enqueue(MockResponse().setResponseCode(307).setHeader("Location", second.url("/capture")))
|
||||
val base = first.url("/").toString().trimEnd('/')
|
||||
val client = dashboardClientWithHttpConsent(OkHttpClient(), base, setOf(base))
|
||||
try {
|
||||
client.newCall(Request.Builder().url(first.url("/api/auth/me"))
|
||||
.header("Authorization", "Bearer synthetic-fixture").build()).execute().use {
|
||||
assertEquals(307, it.code)
|
||||
}
|
||||
assertEquals(0, second.requestCount)
|
||||
} finally {
|
||||
client.connectionPool.evictAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun publicHttpRequiresExplicitExactOriginConsent() {
|
||||
assertTrue(publicDashboardAddressRequiresHttps(null, address))
|
||||
assertFalse(publicDashboardAddressRequiresHttps(null, address, consent))
|
||||
assertTrue(publicDashboardAddressRequiresHttps("lan", "http://11.0.0.2:9119", consent))
|
||||
assertTrue(publicDashboardAddressRequiresHttps(null, "http://11.0.0.1:9120", consent))
|
||||
assertNull(dashboardHttpOrigin("http://user:password@11.0.0.1:9119"))
|
||||
assertNull(dashboardHttpOrigin("http://11.0.0.1:9119?token=untrusted"))
|
||||
assertFalse(dashboardHttpConsentRequired("https://11.0.0.1:9119"))
|
||||
assertFalse(dashboardHttpConsentRequired("http://192.168.1.5:9119"))
|
||||
}
|
||||
|
||||
@Test fun persistedConsentBelongsToOneConnectionAndDoesNotChangeSecurityClassification() {
|
||||
val connection = Connection("one", "Gateway", "", "", "one-key",
|
||||
dashboardUrl = address, dashboardHttpConsentOrigins = consent)
|
||||
val restored = Json.decodeFromString<Connection>(Json.encodeToString(connection)).withDashboardDefaults()
|
||||
assertEquals(consent, restored.dashboardHttpConsentOrigins)
|
||||
assertTrue(dashboardHttpConsentMatches(address, restored.dashboardHttpConsentOrigins))
|
||||
assertFalse(dashboardHttpConsentMatches(address, Connection("two", "Other", "", "", "two-key").dashboardHttpConsentOrigins))
|
||||
assertEquals("public", Connection.inferRouteRole(address))
|
||||
assertFalse(isTlsUrl(address))
|
||||
}
|
||||
|
||||
@Test fun changingAnOriginRequiresFreshConsentAndRetiresTheOldException() {
|
||||
val next = "http://11.0.0.2:9119"
|
||||
assertEquals(emptySet<String>(), updatedDashboardHttpConsents(consent, address, next, null))
|
||||
assertEquals(setOf(next), updatedDashboardHttpConsents(consent, address, next, next))
|
||||
assertEquals(emptySet<String>(), updatedDashboardHttpConsents(consent, address, "https://11.0.0.1:9119", null))
|
||||
assertEquals(consent, updatedDashboardHttpConsents(consent, address, "$address/prefix", null))
|
||||
}
|
||||
|
||||
@Test fun nativeAuthenticationUsesTheSameConsentWithoutTrustingOtherBases() {
|
||||
assertFalse(isNativeDashboardTransportEligible(address))
|
||||
assertTrue(isNativeDashboardTransportEligible(address, consent))
|
||||
assertFalse(isNativeDashboardTransportEligible("http://11.0.0.2:9119", consent))
|
||||
assertNull(normalizeCredentialFreeAuthenticatedDashboardOrigin(address))
|
||||
assertEquals(address, normalizeCredentialFreeAuthenticatedDashboardOrigin(address, consent))
|
||||
assertNotNull(trustedDashboardBearerAuthOrNull(address, address, consent) { mockk(relaxed = true) })
|
||||
assertNull(trustedDashboardBearerAuthOrNull("http://11.0.0.2:9119", address, consent) { error("Must not load credentials") })
|
||||
assertNull(trustedDashboardBearerAuthOrNull("$address/other", "$address/hermes", consent) { error("Must not load credentials") })
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,22 @@ import kotlinx.coroutines.sync.withLock
|
||||
* raw DataStore shape that its Context constructor resolves in production.
|
||||
*/
|
||||
class ProfileSelectionStoreTest {
|
||||
@Test
|
||||
fun restartedStoreRetainsIndependentDefaultAndNamedChoicesAcrossConnections() = runBlocking {
|
||||
val data = InMemoryPreferencesDataStore()
|
||||
val first = ProfileSelectionStore(data)
|
||||
first.setSelectedProfile("a", AgentDisplay.SERVER_DEFAULT_PROFILE_KEY)
|
||||
first.setSelectedProfile("b", "default")
|
||||
first.setSelectedProfile("c", "victor")
|
||||
val restarted = ProfileSelectionStore(data)
|
||||
assertEquals(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY, restarted.selectedProfileFlow("a").first())
|
||||
assertEquals("default", restarted.selectedProfileFlow("b").first())
|
||||
assertEquals("victor", restarted.selectedProfileFlow("c").first())
|
||||
restarted.setSelectedProfile("a", "victor")
|
||||
assertEquals("default", restarted.selectedProfileFlow("b").first())
|
||||
assertEquals("victor", restarted.selectedProfileFlow("c").first())
|
||||
}
|
||||
|
||||
|
||||
private val store = ProfileSelectionStore(InMemoryPreferencesDataStore())
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user