Compare commits
147
Commits
android-v1.15.1
...
dev
| 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 | ||
|
|
179080d6d9 | ||
|
|
ac1f42b7d5 | ||
|
|
88591fca89 | ||
|
|
00c5f5daa2 | ||
|
|
69b6c005cd | ||
|
|
c902215a15 | ||
|
|
d39368bc51 | ||
|
|
414dc08b9f | ||
|
|
3b78a17bd7 | ||
|
|
a5efb9ec96 | ||
|
|
e5228f2089 | ||
|
|
c738a2c2c0 | ||
|
|
8ed6b35e76 | ||
|
|
c6d4a15226 | ||
|
|
22c8f76c72 | ||
|
|
a0161f51ef | ||
|
|
7aa3b1c697 | ||
|
|
e7112dda9e | ||
|
|
c1413c494f | ||
|
|
bb23e6ab48 | ||
|
|
816638b46a | ||
|
|
df0fe59b0b | ||
|
|
df4d4c8f2a | ||
|
|
398ad253f1 | ||
|
|
ad4bbb2c86 | ||
|
|
7c5894213d | ||
|
|
82c088aa89 | ||
|
|
ed599995d6 |
@@ -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',
|
||||
|
||||
@@ -115,8 +115,8 @@ assert.match(
|
||||
/ref: \$\{\{ contains\(inputs\.version, '-'\) && 'dev' \|\| 'main' \}\}/,
|
||||
);
|
||||
assert.match(cliReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved CLI\+UI version/);
|
||||
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
|
||||
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
|
||||
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/);
|
||||
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/);
|
||||
assert.match(pluginReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved Plugin version/);
|
||||
assert.match(androidPreflightWorkflow, /Package immutable preflight artifacts/);
|
||||
assert.match(androidApprovalWorkflow, /Android public approval accepts stable SemVer only/);
|
||||
@@ -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 \
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore exact-source tray build cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
|
||||
@@ -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 \
|
||||
@@ -111,4 +115,8 @@ jobs:
|
||||
plugin/tests/test_git_state.py \
|
||||
plugin/tests/test_git_state_write.py \
|
||||
plugin/tests/test_git_state_extras.py \
|
||||
plugin/tests/test_mobile_plugin_store.py
|
||||
plugin/tests/test_mobile_plugin_store.py \
|
||||
plugin/tests/test_android_tool.py \
|
||||
plugin/tests/test_android_navigate.py \
|
||||
plugin/tests/test_phone_platform.py \
|
||||
plugin/tests/test_desktop_tool_availability.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 \
|
||||
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore exact-source tray build cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
|
||||
@@ -107,7 +107,11 @@ jobs:
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_proactive_channel.py \
|
||||
plugin/tests/test_android_phone_status.py
|
||||
plugin/tests/test_android_phone_status.py \
|
||||
plugin/tests/test_android_tool.py \
|
||||
plugin/tests/test_android_navigate.py \
|
||||
plugin/tests/test_phone_platform.py \
|
||||
plugin/tests/test_desktop_tool_availability.py
|
||||
|
||||
package:
|
||||
name: Build and publish Plugin package
|
||||
|
||||
@@ -6,6 +6,76 @@ 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
|
||||
|
||||
- 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.
|
||||
|
||||
## [Android 1.15.1] - 2026-09-02
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Text
|
||||
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.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.longClick
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
|
||||
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
|
||||
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ChatDebugDrawerInstrumentedTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun longPressOpensDiagnosticsBelowHeaderAndCloseRestoresChat() {
|
||||
compose.setContent {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Text("Hermes", Modifier.fillMaxWidth().height(64.dp).testTag("header")
|
||||
.chatDebugHeaderGesture(true, onClick = {}, onHold = { open = true }))
|
||||
ChatDebugOverlay(open, 64.dp, onClose = { open = false }) {
|
||||
ChatDebugDrawer(
|
||||
profile = "Server Default", model = "Example", sessionId = "session",
|
||||
gateway = true, signedIn = true, signInRequired = false,
|
||||
socketState = GatewayConnectionState.Ready, preparing = false,
|
||||
streaming = false, loadingHistory = false, directoryUnavailable = false,
|
||||
failure = null, onClose = { open = false }, onConnections = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val header = compose.onNodeWithTag("header")
|
||||
val before = header.fetchSemanticsNode().boundsInRoot
|
||||
header.performTouchInput { longClick() }
|
||||
compose.onNodeWithText("Session diagnostics").assertIsDisplayed()
|
||||
assertEquals(before, header.fetchSemanticsNode().boundsInRoot)
|
||||
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
|
||||
compose.onNodeWithText("Session diagnostics").assertDoesNotExist()
|
||||
header.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
+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()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.testTag
|
||||
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.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatActivityPhase
|
||||
import com.hermesandroid.relay.data.InMemoryChatActivityStore
|
||||
import com.hermesandroid.relay.data.projectChatActivityReceipts
|
||||
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.ChatActivityReceipt
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
|
||||
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.WebSocket
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/** Real Gateway callbacks drive production activity surfaces through Android lifecycle changes. */
|
||||
class ChatActivityReceiptInstrumentedTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private lateinit var fixture: AndroidGatewayContractFixture
|
||||
private lateinit var gatewayScope: CoroutineScope
|
||||
private lateinit var gateway: GatewayChatClient
|
||||
private lateinit var handler: ChatHandler
|
||||
private lateinit var viewModel: ChatViewModel
|
||||
private lateinit var socket: WebSocket
|
||||
private val owner = AgentDisplay.profileContextKey("fixture-connection", "research")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fixture = AndroidGatewayContractFixture().also { it.profileName = "research" }
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val http = OkHttpClient()
|
||||
gateway = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), okHttpClient = http),
|
||||
okHttpClient = http,
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
|
||||
viewModel = ChatViewModel().also {
|
||||
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setSessionProfileNameProvider { "research" }
|
||||
it.setProfileMessageLoader { Result.success(emptyList()) }
|
||||
it.setChatActivityStore(InMemoryChatActivityStore())
|
||||
it.switchProfileContext(owner, STORED_SESSION_ID)
|
||||
it.updateGatewayClient(gateway)
|
||||
it.setChatVisible(true)
|
||||
}
|
||||
compose.setContent {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val records by viewModel.activityRecords.collectAsStateWithLifecycle()
|
||||
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
|
||||
val retained by viewModel.retainedActivityPreview.collectAsStateWithLifecycle()
|
||||
val childPreview by viewModel.subagentChildPreview.collectAsStateWithLifecycle()
|
||||
val session by viewModel.currentSessionId.collectAsStateWithLifecycle()
|
||||
var sheetOpen by remember { mutableStateOf(false) }
|
||||
MaterialTheme {
|
||||
Column {
|
||||
GatewayBackgroundProcessStrip(
|
||||
processes = emptyList(), subagentActivities = children,
|
||||
subagentPreviewVisibility = SubagentPreviewVisibility(), loading = false,
|
||||
onClick = { viewModel.openCurrentActivityPreview(); sheetOpen = true },
|
||||
modifier = Modifier.testTag("active-activity"),
|
||||
)
|
||||
projectChatActivityReceipts(messages, records, owner, session).forEach { message ->
|
||||
message.activityRecord?.let { record ->
|
||||
ChatActivityReceipt(
|
||||
record = record,
|
||||
onClick = { sheetOpen = viewModel.openRetainedActivity(record) },
|
||||
modifier = Modifier.testTag("activity-receipt"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sheetOpen) {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = retained?.processes.orEmpty(),
|
||||
subagentActivities = retained?.record?.previewActivities() ?: children,
|
||||
subagentChildPreview = childPreview,
|
||||
subagentPreviewVisibility = SubagentPreviewVisibility(),
|
||||
loading = false, stoppingProcessIds = emptySet(),
|
||||
onRefresh = viewModel::refreshBackgroundProcesses,
|
||||
onStop = viewModel::stopBackgroundProcess,
|
||||
onDismissProcess = viewModel::dismissBackgroundProcess,
|
||||
onOpenSubagentChild = viewModel::openSubagentChildPreview,
|
||||
onDismiss = { viewModel.closeActivityPreview(); sheetOpen = false },
|
||||
readOnlyHistory = retained != null,
|
||||
historyNotice = "Recorded activity. Available child history is read-only.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
|
||||
socket = fixture.awaitServerSocket()
|
||||
fixture.awaitRpc("session.resume")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
gateway.shutdown()
|
||||
gatewayScope.cancel()
|
||||
fixture.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detachedCompletionLeavesReopenableReceiptAcrossActivityResume() {
|
||||
viewModel.sendMessage("Delegate a background task")
|
||||
fixture.awaitRpc("prompt.submit")
|
||||
socket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
|
||||
socket.send(fixture.event("subagent.start", buildJsonObject {
|
||||
put("subagent_id", "receipt-child")
|
||||
put("delegation_id", "receipt-delegation")
|
||||
put("task_count", 1)
|
||||
put("goal", "Inspect activity lifecycle")
|
||||
}, LIVE_SESSION_ID))
|
||||
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
|
||||
compose.onNodeWithTag("active-activity").assertIsDisplayed()
|
||||
socket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
|
||||
compose.waitUntil(5_000) { !handler.isStreaming.value }
|
||||
compose.onNodeWithTag("active-activity").assertIsDisplayed()
|
||||
|
||||
socket.send(fixture.event("subagent.complete", buildJsonObject {
|
||||
put("subagent_id", "receipt-child")
|
||||
put("delegation_id", "receipt-delegation")
|
||||
put("status", "completed")
|
||||
}, LIVE_SESSION_ID))
|
||||
compose.waitUntil(5_000) { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
|
||||
compose.onNodeWithTag("active-activity").assertDoesNotExist()
|
||||
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
|
||||
compose.onNodeWithText("Chat activity").assertIsDisplayed()
|
||||
compose.onNodeWithText("Recorded activity. Available child history is read-only.").assertIsDisplayed()
|
||||
compose.onNodeWithText("Stop").assertDoesNotExist()
|
||||
compose.onNodeWithContentDescription("Close activity preview").performClick()
|
||||
compose.onNodeWithTag("activity-receipt").assertIsDisplayed()
|
||||
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
compose.onNodeWithTag("active-activity").assertDoesNotExist()
|
||||
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
|
||||
compose.onNodeWithText("Chat activity").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STORED_SESSION_ID = "20260821_120000_fixture"
|
||||
const val LIVE_SESSION_ID = "fixture-live-1"
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
+184
-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,23 +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
|
||||
@@ -83,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() {
|
||||
@@ -115,9 +125,31 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
compose.setContent {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
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(
|
||||
processes = emptyList(),
|
||||
subagentActivities = children,
|
||||
subagentPreviewVisibility = SubagentPreviewVisibility(),
|
||||
loading = false,
|
||||
onClick = {},
|
||||
modifier = Modifier.testTag("child-activity"),
|
||||
)
|
||||
Text(
|
||||
text = if (streaming) "STREAMING" else "IDLE",
|
||||
modifier = Modifier.testTag("stream-state"),
|
||||
@@ -145,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)
|
||||
@@ -153,6 +301,37 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
fixture.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detachedChildActivity_survivesParentTerminalAndActivityResume() {
|
||||
viewModel.sendMessage("Delegate a background task")
|
||||
fixture.awaitRpc("prompt.submit")
|
||||
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
|
||||
serverSocket.send(fixture.event("subagent.start", buildJsonObject {
|
||||
put("subagent_id", "detached-child")
|
||||
put("goal", "Inspect")
|
||||
}, LIVE_SESSION_ID))
|
||||
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
|
||||
compose.onNodeWithTag("child-activity").assertIsDisplayed()
|
||||
serverSocket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
|
||||
compose.waitUntil(5_000) { !handler.isStreaming.value }
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
|
||||
serverSocket.send(fixture.event("subagent.progress", buildJsonObject {
|
||||
put("subagent_id", "detached-child")
|
||||
put("text", "Still working")
|
||||
}, LIVE_SESSION_ID))
|
||||
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().events.last().text == "Still working" }
|
||||
compose.onNodeWithTag("child-activity").assertIsDisplayed()
|
||||
assertFalse(viewModel.subagentActivities.value.single().isTerminal)
|
||||
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
|
||||
serverSocket.send(fixture.event("subagent.complete", buildJsonObject {
|
||||
put("subagent_id", "detached-child")
|
||||
put("status", "completed")
|
||||
}, LIVE_SESSION_ID))
|
||||
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().isTerminal }
|
||||
compose.onNodeWithTag("child-activity").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() {
|
||||
viewModel.sendMessage("Run a long foreground task")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/** Presentation only: canonical rows retain their wire identity, role and content. */
|
||||
internal fun projectChatActivityReceipts(
|
||||
messages: List<ChatMessage>,
|
||||
records: List<ChatActivityRecord>,
|
||||
scopeKey: String?,
|
||||
sessionId: String?,
|
||||
): List<ChatMessage> {
|
||||
val originals = messages.filterNot {
|
||||
it.clientOnly && it.id.startsWith("activity:") && it.activityRecord != null
|
||||
}
|
||||
if (scopeKey.isNullOrBlank() || sessionId.isNullOrBlank()) {
|
||||
return originals.map { it.copy(activityRecord = null) }
|
||||
}
|
||||
val owned = records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
|
||||
.sortedByDescending { it.updatedAt }
|
||||
.distinctBy { it.id }
|
||||
val represented = mutableSetOf<String>()
|
||||
val canonical = originals.map { message ->
|
||||
val process = message.hermesProcessNotificationOrNull()
|
||||
val delegation = message.activitySourceId?.takeIf { it.startsWith("delegation:") }
|
||||
?.removePrefix("delegation:")?.takeIf { it.isNotBlank() }
|
||||
val kind = when {
|
||||
message.activitySourceId != null -> ChatActivityKind.SUBAGENTS
|
||||
process != null -> ChatActivityKind.PROCESS
|
||||
else -> return@map message.copy(activityRecord = null)
|
||||
}
|
||||
val sourceId = delegation ?: process?.processId
|
||||
val processTerminal = process?.let { notice ->
|
||||
PROCESS_TERMINAL_HEADLINE.matchEntire(notice.headline)?.let { match ->
|
||||
match.groupValues[2].toIntOrNull()?.let { code ->
|
||||
val phase = when {
|
||||
match.groupValues[1].startsWith("terminated by ") -> ChatActivityPhase.CANCELLED
|
||||
code == 0 -> ChatActivityPhase.COMPLETE
|
||||
else -> ChatActivityPhase.FAILED
|
||||
}
|
||||
phase to code
|
||||
}
|
||||
}
|
||||
}
|
||||
// A process id can be reused after a registry restart. A canonical row has
|
||||
// no start-generation field, so multiple generations must remain unmatched.
|
||||
val matching = if (sourceId == null) emptyList() else owned.filter {
|
||||
it.kind == kind && it.sourceId == sourceId
|
||||
}
|
||||
val record = matching.singleOrNull()?.also { represented += it.id }
|
||||
?: ChatActivityRecord(
|
||||
id = "canonical:${message.id}",
|
||||
scopeKey = scopeKey,
|
||||
sessionId = sessionId,
|
||||
kind = kind,
|
||||
sourceId = sourceId ?: "unavailable:${message.id}",
|
||||
title = process?.headline ?: message.content,
|
||||
phase = when {
|
||||
kind == ChatActivityKind.PROCESS -> processTerminal?.first ?: ChatActivityPhase.UNKNOWN
|
||||
(message.activityFailedCount ?: 0) > 0 ->
|
||||
if (message.activityFailedCount == message.activityTaskCount) {
|
||||
ChatActivityPhase.FAILED
|
||||
} else ChatActivityPhase.UNKNOWN
|
||||
(message.activityTaskCount ?: 0) > 0 -> ChatActivityPhase.COMPLETE
|
||||
else -> ChatActivityPhase.UNKNOWN
|
||||
},
|
||||
createdAt = message.timestamp,
|
||||
updatedAt = message.timestamp,
|
||||
taskCount = message.activityTaskCount?.coerceAtLeast(0) ?: 0,
|
||||
processId = process?.processId,
|
||||
exitCode = processTerminal?.second,
|
||||
)
|
||||
// Aggregate completion metadata never rewrites captured child phases.
|
||||
message.copy(activityRecord = record)
|
||||
}
|
||||
val pending = owned.filter { it.phase != ChatActivityPhase.RUNNING && it.id !in represented }
|
||||
.sortedWith(compareBy<ChatActivityRecord> { it.updatedAt }.thenBy { it.id })
|
||||
.map { record ->
|
||||
ChatMessage(
|
||||
id = "activity:${record.id}",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = record.title,
|
||||
timestamp = record.updatedAt,
|
||||
clientOnly = true,
|
||||
activityRecord = record,
|
||||
)
|
||||
}
|
||||
// Stable merge: history order is authoritative even if server timestamps
|
||||
// regress. Only insert local receipts; never sort canonical messages.
|
||||
var next = 0
|
||||
return buildList {
|
||||
canonical.forEach { message ->
|
||||
while (next < pending.size && pending[next].timestamp < message.timestamp) {
|
||||
add(pending[next++])
|
||||
}
|
||||
add(message)
|
||||
}
|
||||
while (next < pending.size) add(pending[next++])
|
||||
}
|
||||
}
|
||||
|
||||
/** Upstream completion envelope only; never search arbitrary command/output text. */
|
||||
private val PROCESS_TERMINAL_HEADLINE = Regex(
|
||||
"""Background process \S+ (completed normally|exited|terminated by [^\r\n]+|marked lost because the process backend disappeared|failed to start) \(exit code (-?\d+)(?:, SIGTERM)?\)\.""",
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
@Serializable
|
||||
enum class ChatActivityKind { SUBAGENTS, PROCESS }
|
||||
|
||||
@Serializable
|
||||
enum class ChatActivityPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
|
||||
|
||||
@Serializable
|
||||
data class ChatActivityChild(
|
||||
val id: String,
|
||||
val childSessionId: String? = null,
|
||||
val goal: String = "",
|
||||
val phase: ChatActivityPhase = ChatActivityPhase.UNKNOWN,
|
||||
val summary: String? = null,
|
||||
)
|
||||
|
||||
/** Local presentation metadata and exact references, never transcripts or process output. */
|
||||
@Serializable
|
||||
data class ChatActivityRecord(
|
||||
val id: String,
|
||||
val scopeKey: String,
|
||||
val sessionId: String,
|
||||
val kind: ChatActivityKind,
|
||||
val sourceId: String,
|
||||
val title: String,
|
||||
val phase: ChatActivityPhase,
|
||||
val createdAt: Long,
|
||||
val updatedAt: Long,
|
||||
val children: List<ChatActivityChild> = emptyList(),
|
||||
val taskCount: Int = 0,
|
||||
val processId: String? = null,
|
||||
val processStartedAt: String? = null,
|
||||
val exitCode: Int? = null,
|
||||
)
|
||||
|
||||
interface ChatActivityStore {
|
||||
/** Recovery is not live evidence: RUNNING becomes UNKNOWN, including child phases. */
|
||||
suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord>
|
||||
suspend fun upsert(record: ChatActivityRecord)
|
||||
suspend fun removeRecord(scopeKey: String, sessionId: String, id: String)
|
||||
suspend fun removeSession(scopeKey: String, sessionId: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded app-private history references in the shared settings DataStore.
|
||||
* Retains 30 days, 128 records overall, 32 per exact owner/session, and 32 children
|
||||
* per record. Titles are 160 characters; goals/summaries 512. Identity fields are
|
||||
* rejected above 512 characters (scope 2048), never truncated into another owner.
|
||||
* The complete encoded envelope is capped at 1 MiB, evicting oldest records first.
|
||||
* Five minutes of future skew allows monotonic local revisions within one clock tick.
|
||||
* All read/modify/write operations occur inside DataStore's serialized edit.
|
||||
*/
|
||||
class DataStoreChatActivityStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ChatActivityStore {
|
||||
constructor(context: Context) : this(context.applicationContext.relayDataStore)
|
||||
|
||||
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> {
|
||||
val raw = try {
|
||||
dataStore.data.first()[CHAT_ACTIVITY_KEY]
|
||||
} catch (_: IOException) {
|
||||
return emptyList()
|
||||
}
|
||||
return boundChatActivities(decodeChatActivities(raw), now())
|
||||
.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
|
||||
.map(ChatActivityRecord::recovered)
|
||||
}
|
||||
|
||||
override suspend fun upsert(record: ChatActivityRecord) {
|
||||
dataStore.edit { preferences ->
|
||||
val records = mergeChatActivity(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), record, now())
|
||||
preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(records)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeSession(scopeKey: String, sessionId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
|
||||
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
|
||||
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
|
||||
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
|
||||
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
|
||||
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
|
||||
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test/ephemeral implementation with the same bounds and recovery semantics. */
|
||||
class InMemoryChatActivityStore(
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ChatActivityStore {
|
||||
private val mutex = Mutex()
|
||||
private var records = emptyList<ChatActivityRecord>()
|
||||
|
||||
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> = mutex.withLock {
|
||||
records = boundChatActivities(records, now())
|
||||
records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
|
||||
.map(ChatActivityRecord::recovered)
|
||||
}
|
||||
|
||||
override suspend fun upsert(record: ChatActivityRecord) = mutex.withLock {
|
||||
records = mergeChatActivity(records, record, now())
|
||||
}
|
||||
|
||||
override suspend fun removeSession(scopeKey: String, sessionId: String) = mutex.withLock {
|
||||
records = boundChatActivities(records, now())
|
||||
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
|
||||
}
|
||||
|
||||
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) = mutex.withLock {
|
||||
records = boundChatActivities(records, now())
|
||||
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
internal const val CHAT_ACTIVITY_MAX_AGE_MS = 30L * 24L * 60L * 60L * 1_000L
|
||||
private const val MAX_RECORDS = 128
|
||||
private const val MAX_SESSION_RECORDS = 32
|
||||
private const val MAX_PAYLOAD_BYTES = 1_048_576
|
||||
private val CHAT_ACTIVITY_KEY = stringPreferencesKey("chat_activity_records_v1")
|
||||
private val activityJson = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@Serializable
|
||||
private data class ChatActivityEnvelope(val version: Int = 1, val records: List<ChatActivityRecord>)
|
||||
|
||||
private fun encodeChatActivities(records: List<ChatActivityRecord>): String =
|
||||
activityJson.encodeToString(ChatActivityEnvelope(records = records))
|
||||
|
||||
internal fun decodeChatActivities(raw: String?): List<ChatActivityRecord> {
|
||||
if (raw == null || raw.length > MAX_PAYLOAD_BYTES || raw.toByteArray().size > MAX_PAYLOAD_BYTES) {
|
||||
return emptyList()
|
||||
}
|
||||
val envelope = runCatching { activityJson.parseToJsonElement(raw) as? JsonObject }.getOrNull()
|
||||
?: return emptyList()
|
||||
val version = runCatching { envelope["version"]?.jsonPrimitive?.intOrNull }.getOrNull()
|
||||
if (version != 1) return emptyList()
|
||||
val rows = envelope["records"] as? JsonArray ?: return emptyList()
|
||||
// One corrupt or newer row must not hide independently valid records.
|
||||
return rows.mapNotNull { row ->
|
||||
runCatching { activityJson.decodeFromJsonElement(ChatActivityRecord.serializer(), row) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatActivityRecord.identity() = Triple(scopeKey, sessionId, id)
|
||||
|
||||
private fun mergeChatActivity(
|
||||
existing: List<ChatActivityRecord>,
|
||||
record: ChatActivityRecord,
|
||||
now: Long,
|
||||
): List<ChatActivityRecord> {
|
||||
// Sorting first also rejects a late write for an older generation of the same record.
|
||||
return boundChatActivities(listOf(record) + existing, now)
|
||||
}
|
||||
|
||||
internal fun boundChatActivities(records: List<ChatActivityRecord>, now: Long): List<ChatActivityRecord> {
|
||||
val counts = mutableMapOf<Pair<String, String>, Int>()
|
||||
val bounded = records.mapNotNull { it.bounded(now) }
|
||||
.sortedByDescending(ChatActivityRecord::updatedAt)
|
||||
.distinctBy { it.identity() }
|
||||
.filter {
|
||||
val owner = it.scopeKey to it.sessionId
|
||||
val count = counts.getOrDefault(owner, 0)
|
||||
counts[owner] = count + 1
|
||||
count < MAX_SESSION_RECORDS
|
||||
}.take(MAX_RECORDS).toMutableList()
|
||||
while (bounded.isNotEmpty() && encodeChatActivities(bounded).toByteArray().size > MAX_PAYLOAD_BYTES) {
|
||||
bounded.removeAt(bounded.lastIndex)
|
||||
}
|
||||
return bounded
|
||||
}
|
||||
|
||||
private fun validIdentity(value: String, max: Int = 512) = value.isNotBlank() && value.length <= max
|
||||
|
||||
private fun ChatActivityRecord.bounded(now: Long): ChatActivityRecord? {
|
||||
if (!validIdentity(scopeKey, 2048) || !validIdentity(sessionId) || !validIdentity(id) ||
|
||||
!validIdentity(sourceId) || (processId != null && !validIdentity(processId)) ||
|
||||
(processStartedAt != null && !validIdentity(processStartedAt)) ||
|
||||
createdAt < 0 || updatedAt < createdAt || updatedAt > now + 300_000L ||
|
||||
now - updatedAt > CHAT_ACTIVITY_MAX_AGE_MS
|
||||
) return null
|
||||
return copy(
|
||||
title = title.take(160),
|
||||
taskCount = taskCount.coerceIn(0, 10_000),
|
||||
children = children.asSequence().filter {
|
||||
validIdentity(it.id) && (it.childSessionId == null || validIdentity(it.childSessionId))
|
||||
}.distinctBy(ChatActivityChild::id).take(32)
|
||||
.map { it.copy(goal = it.goal.take(512), summary = it.summary?.take(512)) }.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ChatActivityRecord.recovered() = copy(
|
||||
phase = if (phase == ChatActivityPhase.RUNNING) ChatActivityPhase.UNKNOWN else phase,
|
||||
children = children.map {
|
||||
if (it.phase == ChatActivityPhase.RUNNING) it.copy(phase = ChatActivityPhase.UNKNOWN) else it
|
||||
},
|
||||
)
|
||||
@@ -170,6 +170,12 @@ data class ChatMessage(
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
val moaReferences: List<MoaReference> = emptyList(),
|
||||
/** Exact upstream identity on a persisted activity-completion marker. */
|
||||
val activitySourceId: String? = null,
|
||||
val activityTaskCount: Int? = null,
|
||||
val activityFailedCount: Int? = null,
|
||||
/** Read-only UI projection; never sent as model history or voice input. */
|
||||
val activityRecord: ChatActivityRecord? = null,
|
||||
)
|
||||
|
||||
data class MessageReaction(
|
||||
|
||||
@@ -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
|
||||
@@ -1451,6 +1466,12 @@ class ChatHandler {
|
||||
val loaded = renderedItems.mapNotNull { item ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
if (displayKind == "hidden") return@mapNotNull null
|
||||
val activitySourceId = if (displayKind == "async_delegation_complete") {
|
||||
item.displayMetadata.stringField("delegation_id")?.let { "delegation:$it" }
|
||||
?: "unavailable:${item.id}"
|
||||
} else null
|
||||
val activityTaskCount = if (activitySourceId != null) item.displayMetadata.intField("task_count") else null
|
||||
val activityFailedCount = if (activitySourceId != null) item.displayMetadata.intField("failed_count") else null
|
||||
val role = when {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" ||
|
||||
@@ -1611,6 +1632,9 @@ class ChatHandler {
|
||||
// this as the same visible row across the post-turn reload.
|
||||
prior.copy(
|
||||
id = messageId,
|
||||
activitySourceId = activitySourceId,
|
||||
activityTaskCount = activityTaskCount,
|
||||
activityFailedCount = activityFailedCount,
|
||||
rowId = item.resolvedRowId,
|
||||
reactions = item.reactions,
|
||||
role = role,
|
||||
@@ -1643,6 +1667,9 @@ class ChatHandler {
|
||||
// nothing local to carry).
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
activitySourceId = activitySourceId,
|
||||
activityTaskCount = activityTaskCount,
|
||||
activityFailedCount = activityFailedCount,
|
||||
rowId = item.resolvedRowId,
|
||||
reactions = item.reactions,
|
||||
role = role,
|
||||
@@ -1713,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)
|
||||
@@ -1974,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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2603,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.",
|
||||
)
|
||||
+124
-14
@@ -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()
|
||||
@@ -431,6 +442,9 @@ class GatewayChatClient(
|
||||
private val lazyLiveSessions = ConcurrentHashMap.newKeySet<String>()
|
||||
private val readyLiveSessions = ConcurrentHashMap.newKeySet<String>()
|
||||
private val sessionReadyWaiters = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
|
||||
private val sessionReadyFailures = ConcurrentHashMap<String, String>()
|
||||
private val _preparingSessionId = MutableStateFlow<String?>(null)
|
||||
val preparingSessionId: StateFlow<String?> = _preparingSessionId.asStateFlow()
|
||||
|
||||
/** Monotonic client-local fence for lazy child watch open/close races. */
|
||||
private val childWatchGeneration = AtomicLong(0)
|
||||
@@ -633,6 +647,13 @@ class GatewayChatClient(
|
||||
@Volatile
|
||||
private var processEventListener: ((GatewayProcessEvent) -> Unit)? = null
|
||||
|
||||
@Volatile
|
||||
private var subagentEventListener: ((String, String?, GatewaySubagentEvent) -> Unit)? = null
|
||||
|
||||
fun setSubagentEventListener(listener: ((String, String?, GatewaySubagentEvent) -> Unit)?) {
|
||||
subagentEventListener = listener
|
||||
}
|
||||
|
||||
/** Process-wide durable-session invalidation/liveness edge. */
|
||||
@Volatile
|
||||
private var sessionDirectoryInvalidationListener: (() -> Unit)? = null
|
||||
@@ -991,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()
|
||||
@@ -1563,7 +1584,10 @@ class GatewayChatClient(
|
||||
.orEmpty()
|
||||
.also { recoveryEvents = null }
|
||||
}
|
||||
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
|
||||
buffered.forEach { event ->
|
||||
dispatchSubagentEvent(event.type, event.payload, event.sessionId)
|
||||
boundTurn.onEvent(event.type, event.payload)
|
||||
}
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
@@ -1573,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 }
|
||||
@@ -1733,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1900,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,
|
||||
@@ -2950,6 +2998,7 @@ class GatewayChatClient(
|
||||
unmatchedTurnCompleteListener = null
|
||||
backgroundInteractionListener = null
|
||||
processEventListener = null
|
||||
subagentEventListener = null
|
||||
sessionDirectoryInvalidationListener = null
|
||||
closeSocket("client shutdown")
|
||||
backgroundCloseJob?.cancel()
|
||||
@@ -3042,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
|
||||
@@ -3070,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,
|
||||
)
|
||||
@@ -3084,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),
|
||||
)
|
||||
@@ -3485,6 +3543,7 @@ class GatewayChatClient(
|
||||
liveId: String,
|
||||
sessionResult: JsonObject? = null,
|
||||
) {
|
||||
sessionReadyFailures[liveId]?.let { throw GatewayPreflightException(it) }
|
||||
val lazy = (sessionResult?.get("info") as? JsonObject)?.booleanField("lazy") == true
|
||||
if (lazy) lazyLiveSessions += liveId
|
||||
if (liveId !in lazyLiveSessions) return
|
||||
@@ -3494,6 +3553,9 @@ class GatewayChatClient(
|
||||
}
|
||||
val waiter = sessionReadyWaiters.computeIfAbsent(liveId) { CompletableDeferred() }
|
||||
if (readyLiveSessions.remove(liveId)) waiter.complete(Unit)
|
||||
sessionReadyFailures[liveId]?.let { waiter.completeExceptionally(GatewayRpcException(it)) }
|
||||
val preparingStoredId = storedSessionId
|
||||
_preparingSessionId.value = preparingStoredId
|
||||
try {
|
||||
withTimeout(sessionReadyTimeoutMs) { waiter.await() }
|
||||
lazyLiveSessions.remove(liveId)
|
||||
@@ -3502,11 +3564,14 @@ class GatewayChatClient(
|
||||
error.message ?: "Hermes session initialization timed out",
|
||||
)
|
||||
} finally {
|
||||
if (_preparingSessionId.value == preparingStoredId) _preparingSessionId.value = null
|
||||
sessionReadyWaiters.remove(liveId, waiter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failSessionReadyWaiters(message: String) {
|
||||
_preparingSessionId.value = null
|
||||
sessionReadyFailures.clear()
|
||||
sessionReadyWaiters.values.forEach {
|
||||
it.completeExceptionally(GatewayRpcException(message))
|
||||
}
|
||||
@@ -3696,6 +3761,7 @@ class GatewayChatClient(
|
||||
if (type == "session.info" && !eventSessionId.isNullOrBlank() &&
|
||||
payload?.booleanField("lazy") != true
|
||||
) {
|
||||
sessionReadyFailures.remove(eventSessionId)
|
||||
readyLiveSessions += eventSessionId
|
||||
sessionReadyWaiters.remove(eventSessionId)?.complete(Unit)
|
||||
}
|
||||
@@ -3705,10 +3771,16 @@ class GatewayChatClient(
|
||||
// immediately so the optimistic prompt remains retryable/Not sent
|
||||
// instead of waiting for the five-minute readiness timeout.
|
||||
if (type == "error" && !eventSessionId.isNullOrBlank() &&
|
||||
(eventSessionId in lazyLiveSessions || sessionReadyWaiters.containsKey(eventSessionId))
|
||||
(eventSessionId in lazyLiveSessions || sessionReadyWaiters.containsKey(eventSessionId) ||
|
||||
payload?.stringField("message")?.startsWith("agent init failed:") == true)
|
||||
) {
|
||||
val message = payload?.stringField("message")
|
||||
?: "Hermes session initialization failed"
|
||||
// A fast deferred build can fail before the lazy RPC acknowledgement.
|
||||
// Retain the exact runtime failure so the subsequent readiness wait
|
||||
// cannot lose that edge and hang until its deadline.
|
||||
if (sessionReadyFailures.size >= 64) sessionReadyFailures.keys.firstOrNull()?.let(sessionReadyFailures::remove)
|
||||
sessionReadyFailures[eventSessionId] = message.take(2_000)
|
||||
lazyLiveSessions.remove(eventSessionId)
|
||||
readyLiveSessions.remove(eventSessionId)
|
||||
sessionReadyWaiters.remove(eventSessionId)
|
||||
@@ -3825,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
|
||||
) {
|
||||
@@ -3852,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(
|
||||
@@ -3915,6 +3991,7 @@ class GatewayChatClient(
|
||||
dispatchProcessEvent(type, payload, eventSessionId)
|
||||
if (consumeCancelledTurnEvent(type, eventSessionId)) return
|
||||
if (consumeSettledTurnTerminal(type, eventSessionId)) return
|
||||
dispatchSubagentEvent(type, payload, eventSessionId)
|
||||
var turn = activeTurn
|
||||
if (turn == null && type == "message.start") {
|
||||
// Unsolicited turns are accepted only with an explicit exact live-
|
||||
@@ -3968,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
|
||||
@@ -3976,10 +4056,25 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver session-scoped process events before the active-turn gate. The
|
||||
* gateway socket is process-wide, so an exact non-blank live id match is
|
||||
* required; missing/foreign ids must never leak another window's process.
|
||||
* Detached child updates belong to the session even between parent turns.
|
||||
* Recheck socket, session, profile and listener ownership on UI dispatch.
|
||||
*/
|
||||
private fun dispatchSubagentEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
|
||||
val liveId = liveSessionId ?: return
|
||||
val storedId = storedSessionId ?: return
|
||||
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
|
||||
val event = GatewayEventMapper.parseSubagentEvent(type, payload) ?: return
|
||||
val profile = liveSessionProfile
|
||||
val socket = webSocket
|
||||
val listener = subagentEventListener ?: return
|
||||
callbackDispatcher {
|
||||
if (webSocket === socket && liveSessionId == liveId && storedSessionId == storedId &&
|
||||
liveSessionProfile == profile && subagentEventListener === listener
|
||||
) listener(storedId, profile, event)
|
||||
}
|
||||
}
|
||||
|
||||
/** Process updates likewise require an exact live-session match before turn admission. */
|
||||
private fun dispatchProcessEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
|
||||
val liveId = liveSessionId ?: return
|
||||
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
|
||||
@@ -4159,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",
|
||||
@@ -4461,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
|
||||
@@ -5224,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,
|
||||
|
||||
+128
-54
@@ -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)
|
||||
@@ -292,34 +340,7 @@ class GatewayEventMapper(
|
||||
"subagent.progress", "subagent.complete",
|
||||
-> {
|
||||
clearActivityStatuses()
|
||||
val phase = when (type) {
|
||||
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
|
||||
"subagent.start" -> GatewaySubagentEvent.Phase.START
|
||||
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
|
||||
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
|
||||
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
|
||||
else -> GatewaySubagentEvent.Phase.COMPLETE
|
||||
}
|
||||
callbacks.onSubagentEvent(
|
||||
GatewaySubagentEvent(
|
||||
phase = phase,
|
||||
taskIndex = payload.int("task_index") ?: 0,
|
||||
taskCount = payload.int("task_count") ?: 1,
|
||||
goal = payload.string("goal") ?: "",
|
||||
status = payload.string("status"),
|
||||
summary = payload.string("summary"),
|
||||
toolName = payload.string("tool_name"),
|
||||
// subagent.tool sets tool_preview AND mirrors it into
|
||||
// text; thinking/progress carry text only.
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
subagentId = payload.string("subagent_id"),
|
||||
childSessionId = payload.string("child_session_id"),
|
||||
parentId = payload.string("parent_id"),
|
||||
depth = payload.int("depth"),
|
||||
model = payload.string("model"),
|
||||
),
|
||||
)
|
||||
parseSubagentEvent(type, payload)?.let(callbacks.onSubagentEvent)
|
||||
}
|
||||
|
||||
"tool.output_risk" -> {
|
||||
@@ -467,6 +488,42 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Shared by turn transcripts and the session-owned activity stream. */
|
||||
fun parseSubagentEvent(type: String, payload: JsonObject?): GatewaySubagentEvent? {
|
||||
if (type !in setOf(
|
||||
"subagent.spawn_requested", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
)) return null
|
||||
val phase = when (type) {
|
||||
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
|
||||
"subagent.start" -> GatewaySubagentEvent.Phase.START
|
||||
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
|
||||
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
|
||||
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
|
||||
else -> GatewaySubagentEvent.Phase.COMPLETE
|
||||
}
|
||||
|
||||
return GatewaySubagentEvent(
|
||||
phase = phase,
|
||||
taskIndex = payload.int("task_index") ?: 0,
|
||||
taskCount = payload.int("task_count") ?: 1,
|
||||
goal = payload.string("goal") ?: "",
|
||||
status = payload.string("status"),
|
||||
summary = payload.string("summary"),
|
||||
toolName = payload.string("tool_name"),
|
||||
// subagent.tool sets tool_preview AND mirrors it into
|
||||
// text; thinking/progress carry text only.
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
subagentId = payload.string("subagent_id"),
|
||||
childSessionId = payload.string("child_session_id"),
|
||||
parentId = payload.string("parent_id"),
|
||||
depth = payload.int("depth"),
|
||||
model = payload.string("model"),
|
||||
delegationId = payload.string("delegation_id"),
|
||||
)
|
||||
}
|
||||
|
||||
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
|
||||
const val COMPACTION_STATUS_KIND = "compacting"
|
||||
const val ERROR_STATUS_KIND = "error"
|
||||
@@ -494,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,
|
||||
@@ -546,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,
|
||||
@@ -625,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
|
||||
@@ -293,6 +321,8 @@ data class GatewaySubagentEvent(
|
||||
val depth: Int? = null,
|
||||
/** Effective child model, when the emitter exposes it. */
|
||||
val model: String? = null,
|
||||
/** Exact delegation group id shared with persisted async completion metadata. */
|
||||
val delegationId: String? = null,
|
||||
) {
|
||||
enum class Phase { SPAWN_REQUESTED, START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
@@ -36,7 +36,7 @@ import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -230,11 +230,7 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
|
||||
// Short-lived snackbar by default; retryable errors get Long so users have
|
||||
// time to tap the action before it auto-dismisses.
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
|
||||
return showSnackbar(
|
||||
message = err.body,
|
||||
actionLabel = err.actionLabel,
|
||||
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
)
|
||||
return showSnackbar(com.hermesandroid.relay.ui.components.HumanErrorVisuals(err))
|
||||
}
|
||||
|
||||
/** Startup chrome should wait for either standard chat surface, not Relay. */
|
||||
@@ -1099,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
|
||||
@@ -1952,6 +1950,8 @@ fun RelayApp() {
|
||||
// child TopAppBar doesn't double-pad when this banner owns the top edge.
|
||||
val activeMessageCount by UiMessageBus.activeCount.collectAsState()
|
||||
val showMessageBanner = activeMessageCount > 0
|
||||
val modalMessageHostActive by UiMessageBus.modalHostActive.collectAsState()
|
||||
val showActionMessage = snackbarHostState.currentSnackbarData != null && !modalMessageHostActive
|
||||
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
|
||||
// sideload = GitHub releases. The handle filters dismissed versions +
|
||||
// throttles checks internally, exposing a surfaceable status for the
|
||||
@@ -1966,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
|
||||
@@ -2077,6 +2077,14 @@ fun RelayApp() {
|
||||
includeStatusBarPadding =
|
||||
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure,
|
||||
)
|
||||
// Action feedback owns layout space at the top, never the composer's
|
||||
// touch area. Modal windows keep their own scoped host.
|
||||
ThemedMessageHost(
|
||||
snackbarHostState,
|
||||
modifier = if (showActionMessage && !showMessageBanner &&
|
||||
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure
|
||||
) Modifier.windowInsetsPadding(WindowInsets.statusBars) else Modifier,
|
||||
)
|
||||
|
||||
// The update banner AND the connection-status indicator now render as
|
||||
// floating overlay TOASTS in the Box below (see the top-overlay Column
|
||||
@@ -2117,7 +2125,7 @@ fun RelayApp() {
|
||||
// participates in the top-inset accounting.
|
||||
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure ||
|
||||
connectionChipVisible ||
|
||||
showMessageBanner
|
||||
showMessageBanner || showActionMessage
|
||||
) {
|
||||
Modifier.consumeWindowInsets(WindowInsets.statusBars)
|
||||
} else {
|
||||
@@ -2125,7 +2133,6 @@ fun RelayApp() {
|
||||
}
|
||||
),
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
if (
|
||||
!suppressGlobalChrome &&
|
||||
@@ -2212,7 +2219,10 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarHost provides snackbarHostState,
|
||||
com.hermesandroid.relay.ui.components.LocalMessageActionHost provides snackbarHostState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/** Visual tone of a transient banner message. */
|
||||
enum class UiMessageSeverity { Info, Success, Status, Warning }
|
||||
enum class UiMessageSeverity { Info, Success, Status, Warning, Error }
|
||||
|
||||
data class UiMessage(
|
||||
val id: Long,
|
||||
@@ -23,6 +23,7 @@ data class UiMessage(
|
||||
sealed interface UiMessageEvent {
|
||||
data class Show(val message: UiMessage) : UiMessageEvent
|
||||
data class Clear(val key: String) : UiMessageEvent
|
||||
data object ClearAll : UiMessageEvent
|
||||
}
|
||||
|
||||
internal fun reduceUiMessages(
|
||||
@@ -30,6 +31,7 @@ internal fun reduceUiMessages(
|
||||
event: UiMessageEvent,
|
||||
maxRetained: Int,
|
||||
): List<UiMessage> = when (event) {
|
||||
UiMessageEvent.ClearAll -> emptyList()
|
||||
is UiMessageEvent.Clear -> current.filterNot { it.key == event.key }
|
||||
is UiMessageEvent.Show -> {
|
||||
val incoming = event.message
|
||||
@@ -41,15 +43,14 @@ internal fun reduceUiMessages(
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wide bus for transient, non-error status/confirmation messages that
|
||||
* App-wide bus for transient status, confirmation, and error messages that
|
||||
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
|
||||
* — a thin banner that takes its own space (content slides down, no overlay),
|
||||
* shows the newest line collapsed, expands to a few recent lines, auto-dismisses
|
||||
* and coalesces duplicates.
|
||||
*
|
||||
* App-owned errors and persistent/actionable messages keep going to the
|
||||
* snackbar so they demand acknowledgement. Upstream keyed AgentNotices may use
|
||||
* the warning tone here because their own sticky/clear lifecycle owns them.
|
||||
* Messages requiring Retry or Undo use ThemedMessageHost and retain their
|
||||
* action-result contract. Upstream keyed AgentNotices own their sticky/clear lifecycle.
|
||||
* Migrate frequent
|
||||
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
|
||||
* [success] / [status] here.
|
||||
@@ -62,6 +63,23 @@ object UiMessageBus {
|
||||
const val STATUS_TTL_MS = 6_000L
|
||||
|
||||
private val counter = AtomicLong(0L)
|
||||
private val hosts = linkedMapOf<Long, Boolean>()
|
||||
private val _activeHost = MutableStateFlow<Long?>(null)
|
||||
internal val activeHost = _activeHost.asStateFlow()
|
||||
private val _modalHostActive = MutableStateFlow(false)
|
||||
internal val modalHostActive = _modalHostActive.asStateFlow()
|
||||
internal fun registerHost(primary: Boolean): Long = synchronized(hosts) {
|
||||
counter.incrementAndGet().also {
|
||||
hosts[it] = primary
|
||||
_activeHost.value = hosts.keys.lastOrNull()
|
||||
_modalHostActive.value = hosts.values.any { primaryHost -> !primaryHost }
|
||||
}
|
||||
}
|
||||
internal fun unregisterHost(id: Long) { synchronized(hosts) {
|
||||
hosts.remove(id)
|
||||
_activeHost.value = hosts.keys.lastOrNull()
|
||||
_modalHostActive.value = hosts.values.any { !it }
|
||||
} }
|
||||
private val _events = MutableSharedFlow<UiMessageEvent>(extraBufferCapacity = 24)
|
||||
val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow()
|
||||
|
||||
@@ -97,6 +115,11 @@ object UiMessageBus {
|
||||
key.trim().takeIf(String::isNotEmpty)?.let { _events.tryEmit(UiMessageEvent.Clear(it)) }
|
||||
}
|
||||
|
||||
fun clearAll() { _events.tryEmit(UiMessageEvent.ClearAll) }
|
||||
|
||||
fun warning(text: String, ttlMillis: Long = STATUS_TTL_MS) = post(text, UiMessageSeverity.Warning, ttlMillis)
|
||||
fun error(text: String, ttlMillis: Long = 10_000L) = post(text, UiMessageSeverity.Error, ttlMillis)
|
||||
|
||||
/** Neutral confirmation/info (e.g. "Pairing code copied"). */
|
||||
fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
|
||||
post(text, UiMessageSeverity.Info, ttlMillis)
|
||||
|
||||
+29
-26
@@ -1,6 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -209,7 +209,7 @@ fun ActiveCardRelayStatusSection(
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
// Pre-resolve strings for action callbacks (non-composable context).
|
||||
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
|
||||
val relayStatusText = when (relayRowState.phase) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
@@ -250,11 +250,7 @@ fun ActiveCardRelayStatusSection(
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.reconnectIfStale()
|
||||
Toast.makeText(
|
||||
context,
|
||||
reconnectingRelayToast,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.status(reconnectingRelayToast)
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
@@ -817,7 +813,7 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
// Pre-resolve strings for action callbacks (non-composable context).
|
||||
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
|
||||
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
|
||||
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
|
||||
@@ -926,21 +922,21 @@ private fun ManualUrlSubsection(
|
||||
relayOverrideVisible = true
|
||||
result.relayUrl?.let { relayUrlInput = it }
|
||||
}
|
||||
Toast.makeText(
|
||||
context,
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
if (result.voiceRoute == "standard") {
|
||||
apiHermesVoiceReachableToast
|
||||
} else {
|
||||
apiRelayVoiceReachableToast
|
||||
}
|
||||
result.apiReachable ->
|
||||
apiReachableVoiceReviewToast
|
||||
else -> cannotReachApiToast
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
val feedback = when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
if (result.voiceRoute == "standard") {
|
||||
apiHermesVoiceReachableToast
|
||||
} else {
|
||||
apiRelayVoiceReachableToast
|
||||
}
|
||||
result.apiReachable -> apiReachableVoiceReviewToast
|
||||
else -> cannotReachApiToast
|
||||
}
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable -> UiMessageBus.success(feedback)
|
||||
result.apiReachable -> UiMessageBus.warning(feedback)
|
||||
else -> UiMessageBus.error(feedback)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
|
||||
@@ -1202,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)
|
||||
@@ -1259,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,
|
||||
@@ -2035,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,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.media.audiofx.Visualizer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -312,110 +312,112 @@ fun AttachmentViewer(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
MessageOverlayScope {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
|
||||
mutableStateOf(initiallyRevealed)
|
||||
}
|
||||
val blurred = !revealed &&
|
||||
attachment.renderMode == AttachmentRenderMode.IMAGE &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
|
||||
|
||||
// --- One shared Share / Save / Open-externally action set ----------
|
||||
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
return@launch
|
||||
}
|
||||
action(bytes)
|
||||
busy = false
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
|
||||
mutableStateOf(initiallyRevealed)
|
||||
}
|
||||
}
|
||||
val blurred = !revealed &&
|
||||
attachment.renderMode == AttachmentRenderMode.IMAGE &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
|
||||
val onShare = {
|
||||
runWithBytes { bytes ->
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
}
|
||||
val onSave = {
|
||||
runWithBytes { bytes ->
|
||||
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
|
||||
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
|
||||
} else {
|
||||
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
|
||||
}
|
||||
when (result) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
|
||||
|
||||
// --- One shared Share / Save / Open-externally action set ----------
|
||||
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
|
||||
return@launch
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
action(bytes)
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
val onOpenExternal = {
|
||||
val cached = attachment.cachedUri
|
||||
if (!cached.isNullOrBlank()) {
|
||||
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
|
||||
} else {
|
||||
|
||||
val onShare = {
|
||||
runWithBytes { bytes ->
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.open(context, uri, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f))
|
||||
.testTag("attachment-viewer"),
|
||||
) {
|
||||
// Body fills; toolbar floats on top. PDF/TEXT add their own top
|
||||
// inset so the first line clears the toolbar.
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when (attachment.renderMode) {
|
||||
AttachmentRenderMode.IMAGE -> ImageBody(
|
||||
attachment = attachment,
|
||||
blurred = blurred,
|
||||
onReveal = { revealed = true },
|
||||
)
|
||||
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
|
||||
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
|
||||
AttachmentRenderMode.PDF -> PdfBody(attachment)
|
||||
AttachmentRenderMode.TEXT -> TextBody(attachment)
|
||||
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
|
||||
val onSave = {
|
||||
runWithBytes { bytes ->
|
||||
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
|
||||
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
|
||||
} else {
|
||||
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
|
||||
}
|
||||
when (result) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location))
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
val onOpenExternal = {
|
||||
val cached = attachment.cachedUri
|
||||
if (!cached.isNullOrBlank()) {
|
||||
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
|
||||
} else {
|
||||
runWithBytes { bytes ->
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.open(context, uri, attachment.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaViewerToolbar(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f))
|
||||
.testTag("attachment-viewer"),
|
||||
) {
|
||||
// Body fills; toolbar floats on top. PDF/TEXT add their own top
|
||||
// inset so the first line clears the toolbar.
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when (attachment.renderMode) {
|
||||
AttachmentRenderMode.IMAGE -> ImageBody(
|
||||
attachment = attachment,
|
||||
blurred = blurred,
|
||||
onReveal = { revealed = true },
|
||||
)
|
||||
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
|
||||
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
|
||||
AttachmentRenderMode.PDF -> PdfBody(attachment)
|
||||
AttachmentRenderMode.TEXT -> TextBody(attachment)
|
||||
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
|
||||
}
|
||||
}
|
||||
|
||||
MediaViewerToolbar(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,164 +454,165 @@ internal fun AttachmentGalleryViewer(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
LaunchedEffect(initiallyRevealedKeys) {
|
||||
initiallyRevealedKeys.forEach { revealed[it] = true }
|
||||
}
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(attachments.indices),
|
||||
pageCount = { attachments.size },
|
||||
)
|
||||
|
||||
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
|
||||
val attachment = attachments[currentIndex]
|
||||
val currentKey = galleryAttachmentKey(attachment, currentIndex)
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val currentBlurred = revealed[currentKey] != true &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
|
||||
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
|
||||
|
||||
// Capture the currently visible attachment in each click lambda. A
|
||||
// swipe while IO is running must not redirect Save/Share to a new page.
|
||||
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
|
||||
if (currentBlurred || busy) return
|
||||
val target = attachment
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val bytes = attachmentBytes(context, target)
|
||||
if (bytes == null) {
|
||||
viewerToast(context, "Couldn't read this image")
|
||||
return@launch
|
||||
}
|
||||
action(target, bytes)
|
||||
} catch (error: Exception) {
|
||||
viewerToast(
|
||||
context,
|
||||
error.message?.takeIf { it.isNotBlank() }
|
||||
?: "Couldn't complete that image action",
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
MessageOverlayScope {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
LaunchedEffect(initiallyRevealedKeys) {
|
||||
initiallyRevealedKeys.forEach { revealed[it] = true }
|
||||
}
|
||||
}
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(attachments.indices),
|
||||
pageCount = { attachments.size },
|
||||
)
|
||||
|
||||
val onShare = {
|
||||
runWithBytes { target, bytes ->
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
}
|
||||
val onSave = {
|
||||
runWithBytes { target, bytes ->
|
||||
when (val result = MediaSaver.saveImage(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
viewerToast(context, "Saved to ${result.location}")
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
|
||||
val attachment = attachments[currentIndex]
|
||||
val currentKey = galleryAttachmentKey(attachment, currentIndex)
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val currentBlurred = revealed[currentKey] != true &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
|
||||
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
|
||||
|
||||
// Capture the currently visible attachment in each click lambda. A
|
||||
// swipe while IO is running must not redirect Save/Share to a new page.
|
||||
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
|
||||
if (currentBlurred || busy) return
|
||||
val target = attachment
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val bytes = attachmentBytes(context, target)
|
||||
if (bytes == null) {
|
||||
UiMessageBus.error("Couldn't read this image")
|
||||
return@launch
|
||||
}
|
||||
action(target, bytes)
|
||||
} catch (error: Exception) {
|
||||
UiMessageBus.error(
|
||||
error.message?.takeIf { it.isNotBlank() }
|
||||
?: "Couldn't complete that image action",
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
viewerToast(context, "Save failed: ${result.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val onOpenExternal: () -> Unit = openExternal@{
|
||||
if (currentBlurred || busy) return@openExternal
|
||||
val target = attachment
|
||||
val cached = target.cachedUri
|
||||
if (!cached.isNullOrBlank()) {
|
||||
runCatching {
|
||||
MediaSaver.open(context, Uri.parse(cached), target.contentType)
|
||||
}.onFailure {
|
||||
viewerToast(context, "Couldn't open this image")
|
||||
}
|
||||
} else {
|
||||
runWithBytes { item, bytes ->
|
||||
|
||||
val onShare = {
|
||||
runWithBytes { target, bytes ->
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
item.fileName,
|
||||
item.contentType,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.open(context, uri, item.contentType)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f)),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 0,
|
||||
pageSpacing = 12.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("attachment-gallery-pager"),
|
||||
) { page ->
|
||||
val pageAttachment = attachments[page]
|
||||
val pageKey = galleryAttachmentKey(pageAttachment, page)
|
||||
val blurred = revealed[pageKey] != true && shouldBlurImage(
|
||||
blurMode,
|
||||
pageAttachment.sensitive,
|
||||
)
|
||||
ImageBody(
|
||||
attachment = pageAttachment,
|
||||
blurred = blurred,
|
||||
onReveal = { revealed[pageKey] = true },
|
||||
)
|
||||
val onSave = {
|
||||
runWithBytes { target, bytes ->
|
||||
when (val result = MediaSaver.saveImage(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
UiMessageBus.success("Saved to ${result.location}")
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
UiMessageBus.error("Save failed: ${result.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val onOpenExternal: () -> Unit = openExternal@{
|
||||
if (currentBlurred || busy) return@openExternal
|
||||
val target = attachment
|
||||
val cached = target.cachedUri
|
||||
if (!cached.isNullOrBlank()) {
|
||||
runCatching {
|
||||
MediaSaver.open(context, Uri.parse(cached), target.contentType)
|
||||
}.onFailure {
|
||||
UiMessageBus.error("Couldn't open this image")
|
||||
}
|
||||
} else {
|
||||
runWithBytes { item, bytes ->
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
item.fileName,
|
||||
item.contentType,
|
||||
)
|
||||
MediaSaver.open(context, uri, item.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaViewerToolbar(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f)),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 0,
|
||||
pageSpacing = 12.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("attachment-gallery-pager"),
|
||||
) { page ->
|
||||
val pageAttachment = attachments[page]
|
||||
val pageKey = galleryAttachmentKey(pageAttachment, page)
|
||||
val blurred = revealed[pageKey] != true && shouldBlurImage(
|
||||
blurMode,
|
||||
pageAttachment.sensitive,
|
||||
)
|
||||
ImageBody(
|
||||
attachment = pageAttachment,
|
||||
blurred = blurred,
|
||||
onReveal = { revealed[pageKey] = true },
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${currentIndex + 1} / ${attachments.size}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.55f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
MediaViewerToolbar(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${currentIndex + 1} / ${attachments.size}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.55f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1191,10 +1194,6 @@ private fun rememberPlayableUri(attachment: Attachment): Uri? {
|
||||
return uri
|
||||
}
|
||||
|
||||
private fun viewerToast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private const val MAX_TEXT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
// --- Non-composable IO helpers ---------------------------------------------
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatActivityKind
|
||||
import com.hermesandroid.relay.data.ChatActivityPhase
|
||||
import com.hermesandroid.relay.data.ChatActivityRecord
|
||||
|
||||
/** Stable transcript entry into the same read-only preview used by active work. */
|
||||
@Composable
|
||||
internal fun ChatActivityReceipt(
|
||||
record: ChatActivityRecord,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val subagents = record.kind == ChatActivityKind.SUBAGENTS
|
||||
val title = stringResource(
|
||||
if (subagents) R.string.chat_activity_receipt_subagents else R.string.chat_activity_receipt_process,
|
||||
)
|
||||
val action = stringResource(
|
||||
if (subagents) R.string.chat_activity_receipt_view_activity else R.string.chat_activity_receipt_view_output,
|
||||
)
|
||||
val status = if (subagents && record.children.isNotEmpty()) {
|
||||
val groups = record.children.groupingBy { it.phase }.eachCount().toMutableMap()
|
||||
val missing = (record.taskCount - record.children.size).coerceAtLeast(0)
|
||||
if (missing > 0) groups[ChatActivityPhase.UNKNOWN] = (groups[ChatActivityPhase.UNKNOWN] ?: 0) + missing
|
||||
val labels = groups.map { (phase, count) ->
|
||||
stringResource(R.string.chat_activity_receipt_count_phase, count, activityPhaseLabel(phase))
|
||||
}
|
||||
labels.joinToString(" · ")
|
||||
} else if (subagents && record.taskCount > 0) {
|
||||
stringResource(R.string.chat_activity_receipt_count_phase, record.taskCount, activityPhaseLabel(record.phase))
|
||||
} else {
|
||||
activityPhaseLabel(record.phase)
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.semantics(mergeDescendants = true) { stateDescription = status }
|
||||
.clickable(role = Role.Button, onClickLabel = action, onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (subagents) Icons.Filled.AccountTree else Icons.Filled.Terminal,
|
||||
contentDescription = null,
|
||||
tint = if (record.phase == ChatActivityPhase.FAILED) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "$title · $status",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = action,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun activityPhaseLabel(phase: ChatActivityPhase): String = stringResource(
|
||||
when (phase) {
|
||||
ChatActivityPhase.RUNNING -> R.string.bg_processes_running
|
||||
ChatActivityPhase.COMPLETE -> R.string.agent_activity_status_completed
|
||||
ChatActivityPhase.FAILED -> R.string.agent_activity_status_failed
|
||||
ChatActivityPhase.CANCELLED -> R.string.task_status_cancelled
|
||||
ChatActivityPhase.UNKNOWN -> R.string.agent_activity_status_unavailable
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.unit.Dp
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.paneTitle
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStepState
|
||||
|
||||
@Composable
|
||||
internal fun Modifier.chatDebugHeaderGesture(enabled: Boolean, onClick: () -> Unit, onHold: () -> Unit): Modifier =
|
||||
combinedClickable(enabled = enabled, onClick = onClick, onLongClick = onHold,
|
||||
onLongClickLabel = stringResource(R.string.chat_debug_open))
|
||||
|
||||
@Composable
|
||||
internal fun BoxScope.ChatDebugOverlay(
|
||||
visible: Boolean,
|
||||
headerHeight: Dp,
|
||||
onClose: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
BackHandler(enabled = visible, onBack = onClose)
|
||||
if (visible) {
|
||||
Box(Modifier.fillMaxSize().padding(top = headerHeight)
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.28f))
|
||||
.clickable(onClick = onClose))
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically { -it } + fadeIn(),
|
||||
exit = slideOutVertically { -it } + fadeOut(),
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = headerHeight).fillMaxWidth().clipToBounds(),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
/** Read-only snapshot of the visible conversation; opening it sends no RPCs. */
|
||||
@Composable
|
||||
internal fun ChatDebugDrawer(
|
||||
profile: String,
|
||||
model: String,
|
||||
sessionId: String?,
|
||||
gateway: Boolean,
|
||||
signedIn: Boolean,
|
||||
signInRequired: Boolean,
|
||||
socketState: GatewayConnectionState,
|
||||
preparing: Boolean,
|
||||
streaming: Boolean,
|
||||
loadingHistory: Boolean,
|
||||
directoryUnavailable: Boolean,
|
||||
failure: String?,
|
||||
onClose: () -> Unit,
|
||||
onConnections: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = stringResource(R.string.chat_debug_title)
|
||||
val safeFailure = DiagnosticsLog.redactReportText(failure)?.take(600)
|
||||
val ready = socketState == GatewayConnectionState.Ready
|
||||
val steps = buildList {
|
||||
if (gateway) {
|
||||
add(ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.chat_debug_sign_in),
|
||||
stringResource(when {
|
||||
signInRequired -> R.string.chat_debug_sign_in_needed
|
||||
signedIn -> R.string.chat_debug_authenticated
|
||||
else -> R.string.chat_debug_auth_unknown
|
||||
}),
|
||||
when {
|
||||
signInRequired -> ConnectionStepState.Failed
|
||||
signedIn -> ConnectionStepState.Done
|
||||
else -> ConnectionStepState.Pending
|
||||
},
|
||||
))
|
||||
add(ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.chat_debug_gateway),
|
||||
stringResource(when (socketState) {
|
||||
GatewayConnectionState.Ready -> R.string.chat_debug_socket_ready
|
||||
GatewayConnectionState.MintingTicket -> R.string.chat_debug_ticket
|
||||
GatewayConnectionState.Connecting -> R.string.chat_debug_socket_connecting
|
||||
GatewayConnectionState.AwaitingReady -> R.string.chat_debug_socket_waiting
|
||||
GatewayConnectionState.Idle -> R.string.chat_debug_socket_idle
|
||||
}),
|
||||
when {
|
||||
ready -> ConnectionStepState.Done
|
||||
signInRequired -> ConnectionStepState.Failed
|
||||
socketState != GatewayConnectionState.Idle -> ConnectionStepState.Active
|
||||
else -> ConnectionStepState.Pending
|
||||
},
|
||||
))
|
||||
}
|
||||
add(ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.chat_debug_session),
|
||||
stringResource(when {
|
||||
preparing -> R.string.chat_debug_preparing_detail
|
||||
loadingHistory -> R.string.chat_debug_history_loading
|
||||
directoryUnavailable -> R.string.chat_debug_history_failed
|
||||
sessionId != null -> R.string.chat_debug_session_selected
|
||||
else -> R.string.chat_debug_session_new
|
||||
}),
|
||||
when {
|
||||
preparing || loadingHistory -> ConnectionStepState.Active
|
||||
directoryUnavailable -> ConnectionStepState.Failed
|
||||
sessionId != null -> ConnectionStepState.Done
|
||||
else -> ConnectionStepState.Pending
|
||||
},
|
||||
))
|
||||
add(ConnectionSetupTimelineStep(
|
||||
stringResource(R.string.chat_debug_response),
|
||||
stringResource(when {
|
||||
safeFailure != null -> R.string.chat_debug_response_failed
|
||||
preparing -> R.string.chat_debug_response_waiting
|
||||
streaming -> R.string.chat_debug_response_active
|
||||
else -> R.string.chat_debug_response_idle
|
||||
}),
|
||||
when {
|
||||
safeFailure != null -> ConnectionStepState.Failed
|
||||
preparing -> ConnectionStepState.Pending
|
||||
streaming -> ConnectionStepState.Active
|
||||
else -> ConnectionStepState.Pending
|
||||
},
|
||||
))
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth().semantics { paneTitle = title },
|
||||
shape = RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Column(Modifier.heightIn(max = 560.dp).verticalScroll(rememberScrollState()).padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge)
|
||||
Text(listOf(if (gateway) "Gateway" else "Direct API", profile, model)
|
||||
.filter(String::isNotBlank).joinToString(" · "), style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.chat_debug_close))
|
||||
}
|
||||
}
|
||||
ConnectionSetupTimeline(steps)
|
||||
if (safeFailure != null) {
|
||||
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(safeFailure, style = MaterialTheme.typography.bodySmall)
|
||||
if (safeFailure.contains("agent init failed", ignoreCase = true)) {
|
||||
Text(stringResource(R.string.chat_debug_init_recovery), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
Text(stringResource(R.string.chat_debug_session_id, sessionId ?: "—"),
|
||||
style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton(onClick = onConnections) { Text(stringResource(R.string.chat_debug_connections)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -107,143 +107,141 @@ fun ChatImageViewer(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
MessageOverlayScope {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
|
||||
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
|
||||
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
|
||||
|
||||
val gestureModifier = Modifier.fillMaxSize().zoomable()
|
||||
val gestureModifier = Modifier.fillMaxSize().zoomable()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BlurredMedia(
|
||||
blurred = blurred,
|
||||
onReveal = { revealed = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when (source) {
|
||||
is ChatImageViewerSource.Coil -> AsyncImage(
|
||||
model = source.model,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
|
||||
is ChatImageViewerSource.Bitmap -> Image(
|
||||
bitmap = source.bitmap,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
|
||||
// Control bar — top-right, inset past the status bar / notch.
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
val cdClose = stringResource(R.string.cd_close_viewer)
|
||||
val errorMsg = context.getString(R.string.image_viewer_error)
|
||||
if (exportAllowed) {
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
BlurredMedia(
|
||||
blurred = blurred,
|
||||
onReveal = { revealed = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when (source) {
|
||||
is ChatImageViewerSource.Coil -> AsyncImage(
|
||||
model = source.model,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
|
||||
is ChatImageViewerSource.Bitmap -> Image(
|
||||
bitmap = source.bitmap,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
|
||||
if (busy) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
|
||||
// Control bar — top-right, inset past the status bar / notch.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
val cdClose = stringResource(R.string.cd_close_viewer)
|
||||
val errorMsg = context.getString(R.string.image_viewer_error)
|
||||
if (exportAllowed) {
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
UiMessageBus.error(errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
UiMessageBus.error(errorMsg)
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
UiMessageBus.success(savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
UiMessageBus.error(failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: android.content.Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -140,18 +139,13 @@ fun ChatTransportStatusBadge(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val textColor = status.textColor()
|
||||
val background = status.backgroundColor()
|
||||
Surface(
|
||||
modifier = modifier.combinedClickable(
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"${status.reason}: ${status.detail}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
UiMessageBus.info("${status.reason}: ${status.detail}")
|
||||
},
|
||||
),
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
|
||||
@@ -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() }
|
||||
|
||||
+16
-4
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -20,10 +22,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStepState
|
||||
|
||||
data class ConnectionSetupTimelineStep(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val state: ConnectionStepState = ConnectionStepState.Done,
|
||||
)
|
||||
|
||||
/** Compact completed-state timeline shared by connection and auth flows. */
|
||||
@@ -43,13 +47,21 @@ fun ConnectionSetupTimeline(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
.background(when (step.state) {
|
||||
ConnectionStepState.Failed -> MaterialTheme.colorScheme.errorContainer
|
||||
ConnectionStepState.Pending -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
}, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
if (step.state == ConnectionStepState.Active) {
|
||||
CircularProgressIndicator(Modifier.size(17.dp), strokeWidth = 2.dp)
|
||||
} else if (step.state == ConnectionStepState.Pending) {
|
||||
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else Icon(
|
||||
imageVector = if (step.state == ConnectionStepState.Failed) Icons.Filled.Close else Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
tint = if (step.state == ConnectionStepState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -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 {
|
||||
@@ -343,11 +346,7 @@ fun ConnectionWizard(
|
||||
// user at the manual pairing paths (URL entry / 6-char code)
|
||||
// instead of leaving them on a vanishing toast with no scanner.
|
||||
step = WizardStep.Nearby
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.cw_camera_denied),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
UiMessageBus.warning(context.getString(R.string.cw_camera_denied))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,6 +563,7 @@ fun ConnectionWizard(
|
||||
connectionViewModel.saveDashboardConnection(
|
||||
dashboardUrl = draft.dashboardUrl,
|
||||
discoveredHostname = draft.discoveredHostname,
|
||||
httpConsentOrigin = draft.httpConsentOrigin,
|
||||
) { saved ->
|
||||
standardBusy = false
|
||||
saved.fold(
|
||||
@@ -656,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(
|
||||
@@ -711,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 },
|
||||
@@ -733,6 +744,7 @@ fun ConnectionWizard(
|
||||
dashboardUrl = result.dashboardUrl,
|
||||
signInRequired = result.signInRequired,
|
||||
discoveredHostname = dashboardSuggestedHostname,
|
||||
httpConsentOrigin = result.httpConsentOrigin,
|
||||
)
|
||||
val existing = findDuplicateFor("", "", result.dashboardUrl)
|
||||
if (existing != null) {
|
||||
@@ -1530,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"
|
||||
@@ -1541,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
|
||||
@@ -1603,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)
|
||||
@@ -1648,6 +1661,7 @@ private fun NearbyHermesStep(
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun NewNearbyHermesStep(
|
||||
busy: Boolean,
|
||||
@@ -1758,10 +1772,9 @@ private fun NewNearbyHermesStep(
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = { openExternalUrl(context, SetupGuideUrl) }) {
|
||||
Icon(
|
||||
@@ -1890,6 +1903,8 @@ private fun DashboardManualStep(
|
||||
intent: DashboardEntryIntent,
|
||||
address: String,
|
||||
onAddressChange: (String) -> Unit,
|
||||
httpConsentOrigin: String?,
|
||||
onHttpConsentChange: (String?) -> Unit,
|
||||
busy: Boolean,
|
||||
error: String?,
|
||||
onBack: () -> Unit,
|
||||
@@ -1908,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(
|
||||
@@ -1963,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(),
|
||||
)
|
||||
@@ -1987,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)
|
||||
@@ -2043,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(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -104,120 +103,119 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.WarningAmber,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
MessageOverlayScope {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.WarningAmber,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.crash_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.crash_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
text = stringResource(R.string.crash_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.crash_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.crash_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.crash_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
if (showDetails) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(appearanceRoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
if (showDetails) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(appearanceRoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
// FlowRow so the actions wrap instead of clipping on narrow /
|
||||
// foldable cover screens now that a fourth (Share) action exists.
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
|
||||
if (!showDetails) {
|
||||
Button(onClick = { showDetails = true }) {
|
||||
Text(stringResource(R.string.crash_review))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, copiedMessage)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
// until they choose to send it — same privacy posture as Copy.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
reportSubject,
|
||||
reportText,
|
||||
chooserTitle = shareTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
// FlowRow so the actions wrap instead of clipping on narrow /
|
||||
// foldable cover screens now that a fourth (Share) action exists.
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
|
||||
if (!showDetails) {
|
||||
Button(onClick = { showDetails = true }) {
|
||||
Text(stringResource(R.string.crash_review))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, noShareMessage)
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(context, if (opened) openedMessage else noBrowserMessage)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
UiMessageBus.success(copiedMessage)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
// until they choose to send it — same privacy posture as Copy.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
reportSubject,
|
||||
reportText,
|
||||
chooserTitle = shareTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
UiMessageBus.warning(noShareMessage)
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
if (opened) UiMessageBus.success(openedMessage)
|
||||
else UiMessageBus.warning(noBrowserMessage)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
+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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
-149
@@ -1,7 +1,7 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -78,159 +78,158 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = entry.category.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
MessageOverlayScope {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = entry.category.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// Metadata rows — only render the ones that are present.
|
||||
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
|
||||
MetaRow("Severity", severityName)
|
||||
MetaRow("Category", entry.category.label)
|
||||
entry.operation?.let { MetaRow("Operation", it) }
|
||||
entry.endpointRole?.let { MetaRow("Route", it) }
|
||||
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
|
||||
entry.requestUrl?.let { MetaRow("Request", it) }
|
||||
if (entry.configuredUrl == null && entry.requestUrl == null) {
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
}
|
||||
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
|
||||
entry.suggestion?.let { suggestion ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Suggested next step",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
val body = entry.stacktrace ?: entry.detail
|
||||
if (body != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 320.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
appearanceRoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = body,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
// Metadata rows — only render the ones that are present.
|
||||
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
|
||||
MetaRow("Severity", severityName)
|
||||
MetaRow("Category", entry.category.label)
|
||||
entry.operation?.let { MetaRow("Operation", it) }
|
||||
entry.endpointRole?.let { MetaRow("Route", it) }
|
||||
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
|
||||
entry.requestUrl?.let { MetaRow("Request", it) }
|
||||
if (entry.configuredUrl == null && entry.requestUrl == null) {
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
}
|
||||
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
|
||||
entry.suggestion?.let { suggestion ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Suggested next step",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.diagnostic_no_detail),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (expectationVisible) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = expectation,
|
||||
onValueChange = { expectation = it },
|
||||
label = { Text("What were you expecting to happen?") },
|
||||
supportingText = {
|
||||
Text(stringResource(R.string.diagnostic_routine_hint))
|
||||
},
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, copiedToast)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
subject = "Hermes-Relay diagnostic — ${entry.title}",
|
||||
text = plainText,
|
||||
chooserTitle = exportChooserTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
toast(context, "Copied — no app found to share to")
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_export)) }
|
||||
Button(
|
||||
enabled = !expectationVisible || expectation.isNotBlank(),
|
||||
onClick = {
|
||||
if (needsExpectation && !expectationVisible) {
|
||||
expectationVisible = true
|
||||
return@Button
|
||||
}
|
||||
// Copy full text first; the GitHub URL only carries the
|
||||
// head of long traces, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
val opened = IssueReport.openUrl(
|
||||
context,
|
||||
IssueReport.buildGithubIssueUrl(
|
||||
title = DiagnosticIssuePrefill.issueTitle(entry),
|
||||
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
|
||||
entry,
|
||||
expectation = expectation.takeIf { expectationVisible },
|
||||
),
|
||||
labels = DiagnosticIssuePrefill.issueLabels(entry),
|
||||
val body = entry.stacktrace ?: entry.detail
|
||||
if (body != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 320.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
appearanceRoundedCornerShape(12.dp),
|
||||
),
|
||||
)
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full diagnostic copied — paste it into the issue if truncated"
|
||||
else "Copied — no browser found to open GitHub",
|
||||
)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = body,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.diagnostic_no_detail),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (expectationVisible) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = expectation,
|
||||
onValueChange = { expectation = it },
|
||||
label = { Text("What were you expecting to happen?") },
|
||||
supportingText = {
|
||||
Text(stringResource(R.string.diagnostic_routine_hint))
|
||||
},
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
UiMessageBus.success(copiedToast)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
subject = "Hermes-Relay diagnostic — ${entry.title}",
|
||||
text = plainText,
|
||||
chooserTitle = exportChooserTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
UiMessageBus.warning("Copied — no app found to share to")
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_export)) }
|
||||
Button(
|
||||
enabled = !expectationVisible || expectation.isNotBlank(),
|
||||
onClick = {
|
||||
if (needsExpectation && !expectationVisible) {
|
||||
expectationVisible = true
|
||||
return@Button
|
||||
}
|
||||
// Copy full text first; the GitHub URL only carries the
|
||||
// head of long traces, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
val opened = IssueReport.openUrl(
|
||||
context,
|
||||
IssueReport.buildGithubIssueUrl(
|
||||
title = DiagnosticIssuePrefill.issueTitle(entry),
|
||||
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
|
||||
entry,
|
||||
expectation = expectation.takeIf { expectationVisible },
|
||||
),
|
||||
labels = DiagnosticIssuePrefill.issueLabels(entry),
|
||||
),
|
||||
)
|
||||
if (opened) UiMessageBus.success("Full diagnostic copied — paste it into the issue if truncated")
|
||||
else UiMessageBus.warning("Copied — no browser found to open GitHub")
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,10 +275,6 @@ internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: android.content.Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */
|
||||
private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
appendLine("Hermes-Relay diagnostic")
|
||||
|
||||
@@ -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()
|
||||
|
||||
+50
-49
@@ -24,12 +24,12 @@ import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.HelpOutline
|
||||
import androidx.compose.material.icons.filled.PauseCircleOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.PauseCircleOutline
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -66,7 +66,6 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -84,27 +83,18 @@ internal fun GatewayBackgroundProcessStrip(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Initial/switch refreshes are silent. The strip appears only after the
|
||||
// session actually owns a process, avoiding a transient "Checking" row on
|
||||
// every ordinary chat open.
|
||||
// Completed work remains reachable from its transcript receipt. Only active
|
||||
// work owns composer space; refreshing history must not resurrect the strip.
|
||||
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
|
||||
if (processes.isEmpty() && visibleActivities.isEmpty()) return
|
||||
|
||||
val running = processes.count { it.isRunning }
|
||||
val runningAgents = visibleActivities.count { !it.isTerminal }
|
||||
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
|
||||
val failedAgents = visibleActivities.count { it.phase == SubagentActivityPhase.FAILED }
|
||||
val interruptedAgents = visibleActivities.count {
|
||||
it.phase == SubagentActivityPhase.INTERRUPTED ||
|
||||
it.phase == SubagentActivityPhase.ENDED_WITH_PARENT
|
||||
}
|
||||
val failureCount = failed + failedAgents
|
||||
if (running == 0 && runningAgents == 0) return
|
||||
val status = when {
|
||||
runningAgents > 0 && running > 0 -> stringResource(
|
||||
R.string.current_chat_activity_summary, runningAgents, running,
|
||||
)
|
||||
runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents)
|
||||
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
|
||||
failureCount > 0 -> "$failureCount ${stringResource(R.string.task_status_failed)}"
|
||||
interruptedAgents > 0 -> stringResource(R.string.agent_activity_status_interrupted)
|
||||
else -> stringResource(R.string.task_status_complete)
|
||||
else -> "$running ${stringResource(R.string.bg_processes_running)}"
|
||||
}
|
||||
val openDescription = stringResource(R.string.current_chat_activity_open)
|
||||
|
||||
@@ -130,24 +120,7 @@ internal fun GatewayBackgroundProcessStrip(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (running > 0 || runningAgents > 0 || loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = when {
|
||||
failureCount > 0 -> Icons.Filled.ErrorOutline
|
||||
interruptedAgents > 0 -> Icons.Filled.PauseCircleOutline
|
||||
else -> Icons.Filled.CheckCircle
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = when {
|
||||
failureCount > 0 -> MaterialTheme.colorScheme.error
|
||||
interruptedAgents > 0 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
}
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.current_chat_activity_title),
|
||||
@@ -157,11 +130,7 @@ internal fun GatewayBackgroundProcessStrip(
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (failureCount > 0 && running == 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Visibility,
|
||||
@@ -190,6 +159,8 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
onDismissProcess: (String) -> Unit,
|
||||
onOpenSubagentChild: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
readOnlyHistory: Boolean = false,
|
||||
historyNotice: String? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
@@ -246,17 +217,19 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_title),
|
||||
stringResource(
|
||||
if (readOnlyHistory) R.string.chat_activity_history_title else R.string.current_chat_activity_title,
|
||||
),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_subtitle),
|
||||
historyNotice ?: stringResource(R.string.current_chat_activity_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (processes.isNotEmpty() && !readOnlyHistory) IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
@@ -300,7 +273,9 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_empty),
|
||||
stringResource(
|
||||
if (readOnlyHistory) R.string.chat_activity_history_empty else R.string.current_chat_activity_empty,
|
||||
),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -341,7 +316,7 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
GatewayProcessRow(
|
||||
process = process,
|
||||
stopping = process.id in stoppingProcessIds,
|
||||
onStop = { onStop(process.id) },
|
||||
onStop = if (readOnlyHistory) null else ({ onStop(process.id) }),
|
||||
onDismiss = null,
|
||||
)
|
||||
}
|
||||
@@ -356,7 +331,7 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
process = process,
|
||||
stopping = false,
|
||||
onStop = null,
|
||||
onDismiss = { onDismissProcess(process.id) },
|
||||
onDismiss = if (readOnlyHistory) null else ({ onDismissProcess(process.id) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -384,7 +359,7 @@ private fun GatewayProcessRow(
|
||||
onDismiss: (() -> Unit)?,
|
||||
) {
|
||||
var expanded by remember(process.id) { mutableStateOf(false) }
|
||||
val failed = !process.isRunning && (process.exitCode ?: 0) != 0
|
||||
val failed = processDisplayPhase(process) == ProcessDisplayPhase.FAILED
|
||||
val output = sanitizeTerminalText(
|
||||
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
|
||||
).trimEnd()
|
||||
@@ -478,9 +453,11 @@ private fun GatewayProcessRow(
|
||||
|
||||
@Composable
|
||||
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
|
||||
val phase = processDisplayPhase(process)
|
||||
val tint: Color = when {
|
||||
failed -> MaterialTheme.colorScheme.error
|
||||
process.isRunning || stopping -> MaterialTheme.colorScheme.primary
|
||||
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
Surface(
|
||||
@@ -501,6 +478,14 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tint,
|
||||
)
|
||||
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> Icon(
|
||||
imageVector = if (phase == ProcessDisplayPhase.UNKNOWN) Icons.Filled.HelpOutline else Icons.Filled.PauseCircleOutline,
|
||||
contentDescription = stringResource(
|
||||
if (phase == ProcessDisplayPhase.UNKNOWN) R.string.agent_activity_status_unavailable else R.string.task_status_cancelled,
|
||||
),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tint,
|
||||
)
|
||||
else -> Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = stringResource(R.string.tool_completed_a11y),
|
||||
@@ -514,8 +499,11 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
|
||||
|
||||
@Composable
|
||||
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
|
||||
val phase = processDisplayPhase(process)
|
||||
val state = when {
|
||||
process.isRunning -> stringResource(R.string.bg_processes_running)
|
||||
phase == ProcessDisplayPhase.UNKNOWN -> stringResource(R.string.agent_activity_status_unavailable)
|
||||
phase == ProcessDisplayPhase.CANCELLED -> stringResource(R.string.task_status_cancelled)
|
||||
failed -> stringResource(R.string.task_status_failed) +
|
||||
process.exitCode?.let { " · exit $it" }.orEmpty()
|
||||
else -> stringResource(R.string.task_status_complete) +
|
||||
@@ -525,6 +513,19 @@ private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
|
||||
if (process.detached) " · recovered" else ""
|
||||
}
|
||||
|
||||
internal enum class ProcessDisplayPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
|
||||
|
||||
/** A recovered or unfamiliar state is not evidence of successful completion. */
|
||||
internal fun processDisplayPhase(process: GatewayProcess): ProcessDisplayPhase = when {
|
||||
process.isRunning -> ProcessDisplayPhase.RUNNING
|
||||
process.status.equals("unknown", ignoreCase = true) -> ProcessDisplayPhase.UNKNOWN
|
||||
process.status.equals("cancelled", ignoreCase = true) ||
|
||||
process.status.equals("canceled", ignoreCase = true) -> ProcessDisplayPhase.CANCELLED
|
||||
process.status.equals("failed", ignoreCase = true) || (process.exitCode ?: 0) != 0 -> ProcessDisplayPhase.FAILED
|
||||
process.exitCode == 0 || process.status.lowercase() in setOf("complete", "completed", "exited") -> ProcessDisplayPhase.COMPLETE
|
||||
else -> ProcessDisplayPhase.UNKNOWN
|
||||
}
|
||||
|
||||
private val ansiTerminalEscape = Regex(
|
||||
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
@@ -616,7 +616,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
private suspend fun shareAttachment(context: Context, attachment: Attachment) {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
|
||||
return
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
@@ -626,7 +626,7 @@ private suspend fun shareAttachment(context: Context, attachment: Attachment) {
|
||||
suspend fun saveAttachment(context: Context, attachment: Attachment) {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
|
||||
return
|
||||
}
|
||||
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
|
||||
@@ -636,13 +636,13 @@ suspend fun saveAttachment(context: Context, attachment: Attachment) {
|
||||
}
|
||||
when (result) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
|
||||
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location))
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +658,7 @@ private suspend fun openAttachmentExternally(context: Context, attachment: Attac
|
||||
if (uri != null) {
|
||||
MediaSaver.open(context, uri, attachment.contentType)
|
||||
} else {
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_open_failed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,10 +774,6 @@ internal suspend fun attachmentBytes(context: Context, attachment: Attachment):
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachmentToast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun emojiAndLabelFor(
|
||||
mode: AttachmentRenderMode,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -64,21 +66,26 @@ private const val MAX_VISIBLE_EXPANDED = 3
|
||||
private const val ROW_MIN_HEIGHT_DP = 34
|
||||
|
||||
/**
|
||||
* Top, thin, info-only banner host. Collects [UiMessageBus] and renders the
|
||||
* Top, thin, themed banner host. Collects [UiMessageBus] and renders the
|
||||
* newest transient message on one line; tapping expands to the recent few
|
||||
* (scrolling past three). It takes its own vertical space — the Scaffold below
|
||||
* reflows, so content slides down smoothly instead of being covered by an
|
||||
* overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a
|
||||
* burst of the same status collapses to one refreshed row.
|
||||
*
|
||||
* App-owned errors stay on the snackbar. Keyed upstream AgentNotices may also
|
||||
* use the warning tone because their sticky/clear lifecycle is server-owned.
|
||||
* Actionable messages use ThemedMessageHost. Modal hosts render above their
|
||||
* dialog while the primary host retains messages for the return to the app.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBannerHost(
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = true,
|
||||
primary: Boolean = true,
|
||||
) {
|
||||
val hostId = remember { UiMessageBus.registerHost(primary) }
|
||||
val activeHost by UiMessageBus.activeHost.collectAsState()
|
||||
val active = activeHost == hostId
|
||||
DisposableEffect(hostId) { onDispose { UiMessageBus.unregisterHost(hostId) } }
|
||||
// Backing queue (oldest first; newest is last). expiresAt is kept in a
|
||||
// parallel map so coalescing/auto-dismiss can address rows by id.
|
||||
val shown = remember { mutableStateListOf<UiMessage>() }
|
||||
@@ -122,12 +129,12 @@ fun MessageBannerHost(
|
||||
}
|
||||
|
||||
// Collapse + report count to the scaffold (for inset accounting).
|
||||
LaunchedEffect(shown.size) {
|
||||
LaunchedEffect(shown.size, active) {
|
||||
if (shown.isEmpty()) expanded = false
|
||||
UiMessageBus.reportActiveCount(shown.size)
|
||||
if (primary) UiMessageBus.reportActiveCount(if (active) shown.size else 0)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { UiMessageBus.reportActiveCount(0) }
|
||||
onDispose { if (primary) UiMessageBus.reportActiveCount(0) }
|
||||
}
|
||||
|
||||
// Mirror the live queue into a retained copy so the exit animation still
|
||||
@@ -147,7 +154,7 @@ fun MessageBannerHost(
|
||||
// under the notch. The smooth "slide" lives in animateContentSize below
|
||||
// (collapsed↔expanded and message-count changes).
|
||||
AnimatedVisibility(
|
||||
visible = shown.isNotEmpty(),
|
||||
visible = active && shown.isNotEmpty(),
|
||||
enter = fadeIn(tween(180)),
|
||||
exit = fadeOut(tween(160)),
|
||||
modifier = modifier,
|
||||
@@ -155,7 +162,7 @@ fun MessageBannerHost(
|
||||
MessageBannerContent(
|
||||
messages = rendered,
|
||||
expanded = expanded,
|
||||
onToggle = { if (rendered.size > 1) expanded = !expanded },
|
||||
onToggle = { expanded = !expanded },
|
||||
includeStatusBarPadding = includeStatusBarPadding,
|
||||
)
|
||||
}
|
||||
@@ -170,6 +177,7 @@ private fun MessageBannerContent(
|
||||
) {
|
||||
val newest = messages.lastOrNull() ?: return
|
||||
val multiple = messages.size > 1
|
||||
val expandable = multiple || newest.text.length > 80 || newest.severity in setOf(UiMessageSeverity.Warning, UiMessageSeverity.Error)
|
||||
val insetModifier = if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
@@ -190,14 +198,14 @@ private fun MessageBannerContent(
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (multiple) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.animateContentSize(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
if (!expanded) {
|
||||
MessageRow(
|
||||
message = newest,
|
||||
trailing = {
|
||||
if (multiple) {
|
||||
if (expandable) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -236,6 +244,7 @@ private fun MessageBannerContent(
|
||||
ordered.forEachIndexed { index, message ->
|
||||
MessageRow(
|
||||
message = message,
|
||||
expanded = true,
|
||||
trailing = {
|
||||
if (index == 0) {
|
||||
Icon(
|
||||
@@ -256,6 +265,7 @@ private fun MessageBannerContent(
|
||||
@Composable
|
||||
private fun MessageRow(
|
||||
message: UiMessage,
|
||||
expanded: Boolean = false,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
@@ -275,7 +285,7 @@ private fun MessageRow(
|
||||
Text(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
maxLines = if (expanded) 12 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -284,22 +294,25 @@ private fun MessageRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun severityContainer(severity: UiMessageSeverity): Color = when (severity) {
|
||||
internal fun severityContainer(severity: UiMessageSeverity): Color = (when (severity) {
|
||||
UiMessageSeverity.Error -> MaterialTheme.colorScheme.errorContainer
|
||||
UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
|
||||
UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
|
||||
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f)
|
||||
UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f)
|
||||
}
|
||||
}).compositeOver(MaterialTheme.colorScheme.surface)
|
||||
|
||||
@Composable
|
||||
private fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
|
||||
internal fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
|
||||
UiMessageSeverity.Error -> MaterialTheme.colorScheme.onErrorContainer
|
||||
UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
private fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
|
||||
internal fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
|
||||
UiMessageSeverity.Error -> Icons.Filled.Warning
|
||||
UiMessageSeverity.Success -> Icons.Filled.CheckCircle
|
||||
UiMessageSeverity.Status -> Icons.Filled.Sync
|
||||
UiMessageSeverity.Warning -> Icons.Filled.Warning
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -92,68 +92,70 @@ fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.support_bundle_privacy, state.recordCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 160.dp, max = 420.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
appearanceRoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = state.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
)
|
||||
MessageOverlayScope {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.support_bundle_privacy, state.recordCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 160.dp, max = 420.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
appearanceRoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = state.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
Button(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
if (!IssueReport.share(
|
||||
context,
|
||||
subject = chooser,
|
||||
text = state.text,
|
||||
chooserTitle = chooser,
|
||||
)
|
||||
) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
UiMessageBus.success(copied)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
Button(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
if (!IssueReport.share(
|
||||
context,
|
||||
subject = chooser,
|
||||
text = state.text,
|
||||
chooserTitle = chooser,
|
||||
)
|
||||
) {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
UiMessageBus.warning(noShare)
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarVisuals
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.ui.UiMessageSeverity
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
|
||||
val LocalMessageActionHost = staticCompositionLocalOf<SnackbarHostState?> { null }
|
||||
|
||||
data class HumanErrorVisuals(val error: HumanError) : SnackbarVisuals {
|
||||
override val message get() = error.body
|
||||
override val actionLabel get() = error.actionLabel
|
||||
override val withDismissAction = true
|
||||
override val duration get() = if (error.retryable) SnackbarDuration.Long else SnackbarDuration.Short
|
||||
}
|
||||
|
||||
/** Uses the app banner's appearance while preserving existing suspend/action contracts. */
|
||||
@Composable
|
||||
fun ThemedMessageHost(hostState: SnackbarHostState, modifier: Modifier = Modifier, scoped: Boolean = false) {
|
||||
val modalActive by UiMessageBus.modalHostActive.collectAsState()
|
||||
if (!scoped && modalActive) return
|
||||
SnackbarHost(hostState = hostState, modifier = modifier) { data ->
|
||||
val error = (data.visuals as? HumanErrorVisuals)?.error
|
||||
val severity = if (error != null) UiMessageSeverity.Error else UiMessageSeverity.Info
|
||||
Surface(
|
||||
modifier = Modifier.padding(12.dp).fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = severityContainer(severity), contentColor = severityOnContainer(severity),
|
||||
) {
|
||||
Column(Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(severityIcon(severity), null, Modifier.size(18.dp))
|
||||
Column(Modifier.weight(1f).padding(10.dp).heightIn(max = 200.dp).verticalScroll(rememberScrollState())) {
|
||||
error?.title?.let { Text(it, style = MaterialTheme.typography.titleSmall) }
|
||||
Text(data.visuals.message, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
IconButton(onClick = data::dismiss) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.common_dismiss))
|
||||
}
|
||||
}
|
||||
data.visuals.actionLabel?.let { label ->
|
||||
TextButton(onClick = data::performAction, modifier = Modifier.align(Alignment.End)) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Modal-window host; the root retains the same bus events until their normal expiry. */
|
||||
@Composable
|
||||
fun MessageOverlayScope(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
val actions = LocalMessageActionHost.current
|
||||
Box(modifier) {
|
||||
content()
|
||||
MessageBannerHost(Modifier.align(Alignment.TopCenter), includeStatusBarPadding = false, primary = false)
|
||||
if (actions != null) ThemedMessageHost(actions, Modifier.align(Alignment.BottomCenter), scoped = true)
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,18 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/** A completed dispatch call is not evidence that its detached child finished. */
|
||||
internal fun isDetachedDelegationDispatch(toolCall: ToolCall): Boolean {
|
||||
if (toolCall.name != "delegate_task" || !toolCall.isComplete || toolCall.success != true) return false
|
||||
val result = toolCall.result ?: return false
|
||||
val payload = runCatching { Json.parseToJsonElement(result) as? JsonObject }.getOrNull() ?: return false
|
||||
return (payload["status"] as? JsonPrimitive)?.content == "dispatched" ||
|
||||
(payload["mode"] as? JsonPrimitive)?.content == "background"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToolProgressCard(
|
||||
@@ -116,7 +128,9 @@ fun ToolProgressCard(
|
||||
}
|
||||
|
||||
val toolIcon = toolIcon(toolCall.name)
|
||||
val detachedDispatch = isDetachedDelegationDispatch(toolCall)
|
||||
val statusText = when {
|
||||
detachedDispatch -> stringResource(R.string.tool_progress_status_dispatched)
|
||||
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
|
||||
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
|
||||
isPreparing -> stringResource(R.string.tool_preparing_a11y)
|
||||
@@ -187,7 +201,7 @@ fun ToolProgressCard(
|
||||
)
|
||||
|
||||
// Duration + completion time ("3.1s · 5:32 PM")
|
||||
val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
|
||||
val metaLabel = if (detachedDispatch) statusText else listOfNotNull(duration, timeLabel).joinToString(" · ")
|
||||
if (metaLabel.isNotEmpty()) {
|
||||
Text(
|
||||
text = metaLabel,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -94,7 +94,7 @@ fun AboutScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
// Pre-resolved Toast messages (Toast is not a composable scope)
|
||||
// Pre-resolved messages for non-composable action callbacks.
|
||||
val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked)
|
||||
val updateCopiedMsg = stringResource(R.string.about_update_copied)
|
||||
val emDash = stringResource(R.string.about_em_dash)
|
||||
@@ -235,17 +235,13 @@ fun AboutScreen(
|
||||
versionTapCount = 0
|
||||
scope.launch {
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
devUnlockedMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.success(devUnlockedMsg)
|
||||
onUnlockDeveloperOptions()
|
||||
}
|
||||
}
|
||||
remaining <= 3 -> {
|
||||
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
||||
Toast.makeText(context, tapsMsg, Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.info(tapsMsg)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -326,11 +322,7 @@ fun AboutScreen(
|
||||
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
|
||||
TextButton(onClick = {
|
||||
clipboard.setText(AnnotatedString(ru.updateCommand))
|
||||
Toast.makeText(
|
||||
context,
|
||||
updateCopiedMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.success(updateCopiedMsg)
|
||||
}) {
|
||||
Text(stringResource(R.string.about_copy_fix))
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -268,7 +268,7 @@ fun AppearanceSettingsScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
snackbarHost = { ThemedMessageHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -41,7 +41,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -73,6 +73,7 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
@@ -84,6 +85,13 @@ import kotlinx.coroutines.launch
|
||||
|
||||
internal enum class BotModeFilter { All, Bots, Groups }
|
||||
|
||||
/** Bundle-compatible key; length-prefix the connection so delimiters cannot alias owners. */
|
||||
internal val BotRosterEntry.lazyItemKey: String
|
||||
get() {
|
||||
val owner = route?.key ?: return "bot:unbound:${profile.name}"
|
||||
return "bot:route:${owner.connectionId.length}:${owner.connectionId}:${owner.profileName}"
|
||||
}
|
||||
|
||||
private sealed interface BotModeRow {
|
||||
val activityAtMs: Long
|
||||
|
||||
@@ -109,7 +117,7 @@ fun BotModeScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val resources = LocalResources.current
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var openingProfile by remember { mutableStateOf<String?>(null) }
|
||||
var openingRoute by remember { mutableStateOf<BotGatewayRouteKey?>(null) }
|
||||
var showCreateBot by remember { mutableStateOf(false) }
|
||||
var creatingBot by remember { mutableStateOf(false) }
|
||||
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
@@ -136,10 +144,11 @@ fun BotModeScreen(
|
||||
onBack = onBack,
|
||||
onRefresh = connectionViewModel::refreshBotMode,
|
||||
onSelectGateway = { selectedGatewayId = it },
|
||||
openingProfile = openingProfile,
|
||||
openingRoute = openingRoute,
|
||||
onOpenBot = { bot ->
|
||||
val route = bot.route ?: return@BotModeContent
|
||||
openingProfile = bot.profile.name
|
||||
if (openingRoute != null) return@BotModeContent
|
||||
openingRoute = route.key
|
||||
scope.launch {
|
||||
val result = connectionViewModel.ensureCanonicalBotChat(route)
|
||||
.map { it.resolvedSessionId }
|
||||
@@ -147,12 +156,12 @@ fun BotModeScreen(
|
||||
onSuccess = { onOpenBotChat(route, it) },
|
||||
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
|
||||
)
|
||||
openingProfile = null
|
||||
openingRoute = null
|
||||
}
|
||||
},
|
||||
onOpenGroup = { onOpenGroup(it.key) },
|
||||
onNewBot = { showCreateBot = true },
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
snackbarHost = { ThemedMessageHost(snackbar) },
|
||||
botAvatar = { bot, size ->
|
||||
BotProfileAvatar(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -206,7 +215,7 @@ internal fun BotModeContent(
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSelectGateway: (String?) -> Unit,
|
||||
openingProfile: String? = null,
|
||||
openingRoute: BotGatewayRouteKey? = null,
|
||||
onOpenBot: (BotRosterEntry) -> Unit,
|
||||
onOpenGroup: (BotGroupRoom) -> Unit,
|
||||
onNewBot: () -> Unit,
|
||||
@@ -404,12 +413,12 @@ internal fun BotModeContent(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
items(activeBots, key = { it.profile.name }) { bot ->
|
||||
items(activeBots, key = { it.lazyItemKey }) { bot ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(78.dp)
|
||||
.clickable(enabled = openingProfile == null) { onOpenBot(bot) },
|
||||
.clickable(enabled = openingRoute == null) { onOpenBot(bot) },
|
||||
) {
|
||||
Box {
|
||||
botAvatar(bot, 56.dp)
|
||||
@@ -455,7 +464,7 @@ internal fun BotModeContent(
|
||||
itemsIndexed(
|
||||
items = rows,
|
||||
key = { _, row -> when (row) {
|
||||
is BotModeRow.Bot -> "bot:${row.value.profile.name}"
|
||||
is BotModeRow.Bot -> row.value.lazyItemKey
|
||||
is BotModeRow.Group -> "group:${row.value.key}"
|
||||
} },
|
||||
) { index, row ->
|
||||
@@ -463,7 +472,7 @@ internal fun BotModeContent(
|
||||
is BotModeRow.Bot -> BotConversationRow(
|
||||
bot = row.value,
|
||||
connectionLabel = row.value.route?.connectionLabel,
|
||||
opening = openingProfile == row.value.profile.name,
|
||||
opening = openingRoute != null && openingRoute == row.value.route?.key,
|
||||
onClick = { onOpenBot(row.value) },
|
||||
avatar = { botAvatar(row.value, 56.dp) },
|
||||
nowMs = nowMs,
|
||||
|
||||
@@ -12,6 +12,14 @@ import androidx.compose.foundation.MutatePriority
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
|
||||
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
|
||||
import com.hermesandroid.relay.data.projectChatActivityReceipts
|
||||
import com.hermesandroid.relay.viewmodel.previewActivities
|
||||
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
|
||||
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -150,7 +158,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
@@ -159,7 +167,7 @@ import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -455,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,
|
||||
@@ -870,9 +893,17 @@ fun ChatScreen(
|
||||
|
||||
|
||||
val rawMessages by chatViewModel.messages.collectAsState()
|
||||
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
|
||||
if (!supervised) rawMessages
|
||||
else rawMessages.map { message ->
|
||||
val activityRecords by chatViewModel.activityRecords.collectAsState()
|
||||
val activityOwner by chatViewModel.conversationBinding.collectAsState()
|
||||
val activitySessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val receiptMessages = remember(rawMessages, activityRecords, activityOwner, activitySessionId, supervised, supervisedVisibility) {
|
||||
if (activityOwner.transport == com.hermesandroid.relay.data.SessionTransport.SSE ||
|
||||
(supervised && !supervisedVisibility.showWorkingStatus)
|
||||
) rawMessages else projectChatActivityReceipts(rawMessages, activityRecords, activityOwner.contextKey, activitySessionId)
|
||||
}
|
||||
val messages = remember(receiptMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
|
||||
if (!supervised) receiptMessages
|
||||
else receiptMessages.map { message ->
|
||||
if (message.role == MessageRole.ASSISTANT) {
|
||||
message.copy(
|
||||
attachments = if (supervisedPolicy.capabilities.generatedImages) {
|
||||
@@ -935,6 +966,10 @@ fun ChatScreen(
|
||||
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val structuredChatFailure by chatViewModel.chatFailure.collectAsState()
|
||||
val preparingGatewaySessionId by chatViewModel.gatewayPreparingSessionId.collectAsState()
|
||||
val gatewaySocketState by chatViewModel.gatewaySocketState.collectAsState()
|
||||
val preparingGatewaySession = isStreaming && currentSessionId != null &&
|
||||
preparingGatewaySessionId == currentSessionId
|
||||
val visibleChatFailure = scopedChatFailure(
|
||||
structuredChatFailure,
|
||||
currentSessionId,
|
||||
@@ -961,6 +996,7 @@ fun ChatScreen(
|
||||
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
|
||||
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
|
||||
val subagentActivities by chatViewModel.subagentActivities.collectAsState()
|
||||
val retainedActivityPreview by chatViewModel.retainedActivityPreview.collectAsState()
|
||||
val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState()
|
||||
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
|
||||
@@ -976,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()
|
||||
@@ -1082,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()
|
||||
@@ -1183,6 +1222,7 @@ fun ChatScreen(
|
||||
chatGatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
|
||||
val isGatewayTransport = remember(
|
||||
streamingEndpointPref, chatServerCapabilities, chatGatewayAvailability,
|
||||
activeConnection,
|
||||
) {
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpointPref) == "gateway"
|
||||
}
|
||||
@@ -1191,15 +1231,22 @@ 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
|
||||
// RPC burst while the session directory was also trying to hydrate.
|
||||
}
|
||||
DisposableEffect(chatViewModel) {
|
||||
onDispose { chatViewModel.setChatVisible(false) }
|
||||
onDispose {
|
||||
chatViewModel.setChatVisible(false)
|
||||
chatViewModel.closeActivityPreview()
|
||||
}
|
||||
}
|
||||
|
||||
// Cold-open recovery: the dashboard probe that flips gatewayAvailability to
|
||||
@@ -1370,14 +1417,10 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
if (request.payload.omittedUriCount > 0) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
UiMessageBus.warning(context.getString(
|
||||
R.string.chat_shared_files_limited,
|
||||
com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS,
|
||||
),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
))
|
||||
}
|
||||
com.hermesandroid.relay.util.SharedContentRequest.consume(request.id)
|
||||
}
|
||||
@@ -1413,9 +1456,17 @@ 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) }
|
||||
var showChatDebug by remember { mutableStateOf(false) }
|
||||
var chatHeaderHeightPx by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(currentSessionId, activeConnection?.id, supervised) { showChatDebug = false }
|
||||
var showProfileSwitcher by remember { mutableStateOf(false) }
|
||||
var showProfileManager by remember { mutableStateOf(false) }
|
||||
var showBackgroundProcesses by remember { mutableStateOf(false) }
|
||||
@@ -1432,7 +1483,7 @@ fun ChatScreen(
|
||||
// A process inventory is scoped to one gateway session. Never leave a
|
||||
// sheet opened onto a different chat after a drawer/profile switch.
|
||||
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
chatViewModel.closeActivityPreview()
|
||||
showBackgroundProcesses = false
|
||||
}
|
||||
|
||||
@@ -1630,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) {
|
||||
@@ -1704,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
|
||||
@@ -1799,7 +1807,7 @@ fun ChatScreen(
|
||||
cameraLauncher.launch(uri)
|
||||
}.onFailure {
|
||||
pendingCameraUri = null
|
||||
Toast.makeText(context, context.getString(R.string.chat_camera_open_failed), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error(context.getString(R.string.chat_camera_open_failed))
|
||||
}
|
||||
}
|
||||
var pendingCameraAfterPermission by remember { mutableStateOf(false) }
|
||||
@@ -1811,11 +1819,7 @@ fun ChatScreen(
|
||||
if (granted && wanted) {
|
||||
launchCamera()
|
||||
} else if (!granted) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.chat_camera_perm_needed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.warning(context.getString(R.string.chat_camera_perm_needed))
|
||||
}
|
||||
}
|
||||
val requestCameraCapture: () -> Unit = {
|
||||
@@ -2441,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,
|
||||
@@ -2613,11 +2618,7 @@ fun ChatScreen(
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(copySessionIdLabel, sessionId))
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
copiedToClipboardMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.success(copiedToClipboardMsg)
|
||||
}
|
||||
},
|
||||
threadsCapabilityActive = threadsCapabilityActive,
|
||||
@@ -2763,6 +2764,7 @@ fun ChatScreen(
|
||||
) {
|
||||
// Top bar — messaging app style with avatar, name, model subtitle
|
||||
TopAppBar(
|
||||
modifier = Modifier.onSizeChanged { chatHeaderHeightPx = it.height },
|
||||
navigationIcon = {
|
||||
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
@@ -2806,6 +2808,7 @@ fun ChatScreen(
|
||||
val showStreamingState = isStreaming &&
|
||||
(!supervised || supervisedVisibility.showWorkingStatus)
|
||||
val statusText = when {
|
||||
preparingGatewaySession -> stringResource(R.string.chat_debug_preparing)
|
||||
headerChatReady -> if (showStreamingState) {
|
||||
stringResource(R.string.chat_streaming)
|
||||
} else {
|
||||
@@ -2882,13 +2885,21 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !supervised) {
|
||||
.chatDebugHeaderGesture(enabled = !supervised,
|
||||
onHold = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showProfileShelf = false
|
||||
showChatDebug = !showChatDebug
|
||||
},
|
||||
onClick = {
|
||||
showChatDebug = false
|
||||
if (profileShelfAvailable) {
|
||||
showProfileShelf = !showProfileShelf
|
||||
} else {
|
||||
showAgentInfo = true
|
||||
}
|
||||
}
|
||||
})
|
||||
.testTag("chat-agent-header")
|
||||
.semantics {
|
||||
contentDescription = if (profileShelfAvailable) {
|
||||
context.getString(
|
||||
@@ -3212,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()
|
||||
@@ -3328,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 {
|
||||
@@ -3342,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()
|
||||
@@ -3669,7 +3685,15 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (processNotification != null) {
|
||||
if (message.activityRecord != null && (!supervised || supervisedVisibility.showWorkingStatus)) {
|
||||
ChatActivityReceipt(
|
||||
record = message.activityRecord,
|
||||
onClick = {
|
||||
showBackgroundProcesses = chatViewModel.openRetainedActivity(message.activityRecord, processNotification?.detail)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
} else if (processNotification != null) {
|
||||
val notificationModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
)
|
||||
@@ -3911,6 +3935,9 @@ fun ChatScreen(
|
||||
// optional diagnostic scaffolding. Keep lanes
|
||||
// visible in Off, Compact, and Detailed modes.
|
||||
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||
val laneCalls = laneGroups.getValue(taskIndex)
|
||||
val retainedIds = activityRecords.flatMap { it.children }.mapTo(HashSet()) { it.id }
|
||||
if (laneCalls.all { it.subagentId != null && it.subagentId in retainedIds }) return@forEach
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
@@ -4046,7 +4073,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Copy feedback snackbar
|
||||
SnackbarHost(
|
||||
ThemedMessageHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -4061,7 +4088,7 @@ fun ChatScreen(
|
||||
subagentActivities = subagentActivities,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
onClick = { showBackgroundProcesses = true },
|
||||
onClick = { chatViewModel.openCurrentActivityPreview(); showBackgroundProcesses = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4318,8 +4345,9 @@ fun ChatScreen(
|
||||
fallbackModelDetail,
|
||||
serverDefaultModelDetail,
|
||||
hasModelChoices,
|
||||
isGatewayTransport,
|
||||
) {
|
||||
if (!hasModelChoices && fallbackModelDetail.isNullOrBlank()) {
|
||||
if (!isGatewayTransport && !hasModelChoices && fallbackModelDetail.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
buildList {
|
||||
@@ -4392,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)
|
||||
@@ -4569,17 +4597,11 @@ fun ChatScreen(
|
||||
isDemoMode = isDemoMode,
|
||||
voiceReady = voiceReady,
|
||||
onDemoNotice = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Voice is unavailable in the offline demo — connect to Hermes to use it",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
UiMessageBus.warning("Voice is unavailable in the offline demo — connect to Hermes to use it")
|
||||
},
|
||||
onStartVoice = requestVoiceMode,
|
||||
onSetupNotice = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
when (standardVoiceAvailability) {
|
||||
UiMessageBus.warning(when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
@@ -4588,9 +4610,7 @@ fun ChatScreen(
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
context.getString(R.string.chat_voice_needs_route)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -4766,7 +4786,9 @@ fun ChatScreen(
|
||||
if (showModelSheet) {
|
||||
ModelPickerSheet(
|
||||
options = modelPickerOptions,
|
||||
loading = modelOptionsLoading,
|
||||
refreshing = modelOptionsRefreshing,
|
||||
error = modelOptionsError,
|
||||
onRefresh = {
|
||||
chatViewModel.refreshModelOptions(refresh = true, catalogOnly = true)
|
||||
},
|
||||
@@ -4814,6 +4836,30 @@ fun ChatScreen(
|
||||
}
|
||||
} // end Column
|
||||
|
||||
ChatDebugOverlay(
|
||||
visible = showChatDebug && !supervised,
|
||||
headerHeight = with(density) { chatHeaderHeightPx.toDp() },
|
||||
onClose = { showChatDebug = false },
|
||||
) {
|
||||
ChatDebugDrawer(
|
||||
profile = AgentDisplay.profileDisplayName(conversationProfile)
|
||||
?: stringResource(R.string.chat_server_default),
|
||||
model = AgentDisplay.displayModelName(sessionModelState.model).orEmpty(),
|
||||
sessionId = currentSessionId,
|
||||
gateway = isGatewayTransport,
|
||||
signedIn = chatGatewayAvailability == GatewayAvailability.Ready,
|
||||
signInRequired = chatGatewayAvailability == GatewayAvailability.SignInRequired,
|
||||
socketState = gatewaySocketState,
|
||||
preparing = preparingGatewaySession,
|
||||
streaming = isStreaming,
|
||||
loadingHistory = isLoadingHistory,
|
||||
directoryUnavailable = sessionListUnavailable,
|
||||
failure = visibleChatFailure?.rawError,
|
||||
onClose = { showChatDebug = false },
|
||||
onConnections = { showChatDebug = false; onNavigateToConnections() },
|
||||
)
|
||||
}
|
||||
|
||||
// Mic permission denied banner — title + body + Open Settings action.
|
||||
// System "Don't ask again" gives no callback, so a toast would leave
|
||||
// the user stranded. Banner + direct-to-app-details deep link is the
|
||||
@@ -4953,8 +4999,8 @@ fun ChatScreen(
|
||||
|
||||
if (showBackgroundProcesses) {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = backgroundProcesses,
|
||||
subagentActivities = subagentActivities,
|
||||
processes = retainedActivityPreview?.processes ?: backgroundProcesses,
|
||||
subagentActivities = retainedActivityPreview?.record?.previewActivities() ?: subagentActivities,
|
||||
subagentChildPreview = subagentChildPreview,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
@@ -4963,8 +5009,10 @@ fun ChatScreen(
|
||||
onStop = chatViewModel::stopBackgroundProcess,
|
||||
onDismissProcess = chatViewModel::dismissBackgroundProcess,
|
||||
onOpenSubagentChild = chatViewModel::openSubagentChildPreview,
|
||||
readOnlyHistory = retainedActivityPreview != null,
|
||||
historyNotice = if (retainedActivityPreview != null) stringResource(R.string.chat_activity_history_notice) else null,
|
||||
onDismiss = {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
chatViewModel.closeActivityPreview()
|
||||
showBackgroundProcesses = false
|
||||
},
|
||||
)
|
||||
@@ -5505,13 +5553,9 @@ private suspend fun ingestAttachmentFromUri(
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: AttachmentTooLargeException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.warning(context.getString(R.string.chat_file_too_large, maxAttachmentMb))
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, context.getString(R.string.chat_failed_read_file), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error(context.getString(R.string.chat_failed_read_file))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -124,7 +124,7 @@ fun CustomPetGuideScreen(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
snackbarHost = { ThemedMessageHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
@@ -92,11 +92,7 @@ fun DeveloperSettingsScreen(
|
||||
) { uri ->
|
||||
if (uri != null && backupJson != null) {
|
||||
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
|
||||
backupJson = null
|
||||
}
|
||||
}
|
||||
@@ -108,11 +104,7 @@ fun DeveloperSettingsScreen(
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
connectionViewModel.importFromUri(uri) { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,15 +176,11 @@ fun DeveloperSettingsScreen(
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.resetOnboarding { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
UiMessageBus.post(if (success) {
|
||||
context.getString(R.string.dev_settings_onboarding_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
@@ -433,11 +421,7 @@ fun DeveloperSettingsScreen(
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_locked_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.info(context.getString(R.string.dev_settings_locked_toast))
|
||||
onBack()
|
||||
}
|
||||
}) {
|
||||
@@ -455,6 +439,7 @@ fun DeveloperSettingsScreen(
|
||||
// logged error, a live update). Gated by isDevBuild so it never
|
||||
// ships in a release APK.
|
||||
if (FeatureFlags.isDevBuild) {
|
||||
MessagePreviewControls()
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_test_harness),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -501,7 +486,7 @@ fun DeveloperSettingsScreen(
|
||||
context.getString(R.string.dev_settings_sample_stacktrace),
|
||||
),
|
||||
)
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_diagnostics_emitted_toast), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.info(context.getString(R.string.dev_settings_diagnostics_emitted_toast))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -518,7 +503,7 @@ fun DeveloperSettingsScreen(
|
||||
is UpdateStatus.Downloaded -> context.getString(R.string.dev_settings_update_state_downloaded)
|
||||
else -> context.getString(R.string.dev_settings_update_state_off)
|
||||
}
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_update_banner_preview_toast, state), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.info(context.getString(R.string.dev_settings_update_banner_preview_toast, state))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -568,11 +553,7 @@ fun DeveloperSettingsScreen(
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
if (json == null) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_export_failed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.error(context.getString(R.string.dev_settings_export_failed))
|
||||
} else {
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
@@ -633,15 +614,11 @@ fun DeveloperSettingsScreen(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
connectionViewModel.resetAppData { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
UiMessageBus.post(if (success) {
|
||||
context.getString(R.string.dev_settings_app_data_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -658,7 +635,7 @@ fun DeveloperSettingsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestHarnessRow(
|
||||
internal fun TestHarnessRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -285,11 +285,7 @@ fun MediaSettingsScreen(
|
||||
R.string.media_clear_cache_freed,
|
||||
formatBytesHuman(context, freed)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
freedMsg,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
UiMessageBus.info(freedMsg)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user