Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f3f75658 | ||
|
|
1d25985aca | ||
|
|
500cc83de6 | ||
|
|
7d3c761c6a | ||
|
|
2e097035fb | ||
|
|
280c20dbca | ||
|
|
86a570e72d | ||
|
|
cbe811fb52 | ||
|
|
ac2508c5ce | ||
|
|
2ad7de4cc9 | ||
|
|
5eb5d198f4 | ||
|
|
7a86b9cee7 | ||
|
|
60f93994f5 | ||
|
|
52e0e38a81 | ||
|
|
471595240b | ||
|
|
061512d330 | ||
|
|
1c59f44ad7 | ||
|
|
269a2b5b36 | ||
|
|
a6879aac6f | ||
|
|
a04930c946 | ||
|
|
b71fea701f | ||
|
|
af54cdddb3 | ||
|
|
1a6f9cc10b | ||
|
|
2865456761 | ||
|
|
a5f671c06c | ||
|
|
75feb55adf | ||
|
|
f4b366389b | ||
|
|
2ec7b7aac9 |
@@ -17,6 +17,7 @@ function classifyCiPaths(paths) {
|
||||
android: forceAll || under(['app/', 'gradle/']) || exact([
|
||||
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
|
||||
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
|
||||
'scripts/check-android-capabilities.py', 'scripts/tests/check_android_capabilities_test.py',
|
||||
'scripts/check-android-collection-apis.py', 'scripts/check-android-native-compat.py',
|
||||
'scripts/check-android-release-notes.py',
|
||||
'scripts/android_release_artifacts.py',
|
||||
|
||||
@@ -128,3 +128,6 @@ assert.match(releaseTrainWorkflow, /name: Hermes-Relay Coordinated Release Appro
|
||||
assert.match(releaseTrainWorkflow, /Coordinated Android approval is stable-only/);
|
||||
|
||||
console.log('CI path classification tests passed.');
|
||||
|
||||
assert.deepEqual(classifyCiPaths(['scripts/check-android-capabilities.py']), { ...none, android: true });
|
||||
assert.deepEqual(classifyCiPaths(['scripts/tests/check_android_capabilities_test.py']), { ...none, android: true });
|
||||
|
||||
@@ -166,6 +166,9 @@ jobs:
|
||||
- name: Build debug APKs
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
|
||||
|
||||
- name: Verify packaged native compatibility
|
||||
run: |
|
||||
python3 scripts/check-android-native-compat.py \
|
||||
@@ -203,6 +206,9 @@ jobs:
|
||||
- name: Build release bundles and APKs
|
||||
run: ./gradlew bundleRelease assembleRelease --console=plain
|
||||
|
||||
- name: Verify Play release capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayRelease
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
@@ -34,6 +34,8 @@ on:
|
||||
- "scripts/check-android-locales.py"
|
||||
- "scripts/android-locale-harness.py"
|
||||
- "scripts/check-android-collection-apis.py"
|
||||
- "scripts/check-android-capabilities.py"
|
||||
- "scripts/tests/check_android_capabilities_test.py"
|
||||
- "scripts/check-android-native-compat.py"
|
||||
- "scripts/check-android-release-notes.py"
|
||||
- "scripts/tests/check_android_native_compat_test.py"
|
||||
@@ -73,6 +75,11 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
- name: Validate Play capability boundary
|
||||
run: |
|
||||
python3 scripts/check-android-capabilities.py
|
||||
python3 -m unittest scripts.tests.check_android_capabilities_test
|
||||
|
||||
- name: Validate translation catalogs
|
||||
run: python3 scripts/check-android-locales.py
|
||||
|
||||
@@ -117,6 +124,9 @@ jobs:
|
||||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug --console=plain
|
||||
|
||||
- name: Verify Play capability manifest
|
||||
run: python3 scripts/check-android-capabilities.py --variant googlePlayDebug
|
||||
|
||||
- name: Verify packaged ONNX Runtime compatibility
|
||||
run: |
|
||||
python3 scripts/check-android-native-compat.py \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -6,10 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** September 10, 2026
|
||||
**Release Date:** September 13, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes Android and Desktop tool availability fast and reliable when Relay is unavailable, starts late-created Android bridge sessions without restarting Hermes, and restores compatibility with both current and legacy `android_setup` arguments. 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
|
||||
|
||||
- **Fast, accurate tool availability.** Android and Desktop tool checks use explicit IPv4 loopback and one bounded health snapshot instead of repeated per-tool connection attempts. Multi-PC capability advertisements remain isolated, and unavailable Relay clients continue to fail closed.
|
||||
- **Late Android bridge recovery.** `android_*` calls retry profile-scoped and active bridge-session credentials after a stale token is rejected, so a phone connected after Hermes startup becomes usable without restarting the host.
|
||||
- **Compatible Android setup arguments.** `android_setup` accepts the canonical `bridge_session_token` and `pairing_code` fields as well as their legacy aliases, with structured errors when no usable credential is supplied.
|
||||
- **Isolated setup tests.** Android tool setup tests use a temporary Hermes home instead of writing bridge settings into the operator environment.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.16.1
|
||||
# Hermes-Relay Android v1.17.0
|
||||
|
||||
**Release Date:** September 12, 2026
|
||||
**Release Date:** September 13, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.16.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,16 +12,28 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch fixes a remaining cold-start path that could leave a Dashboard-only connection waiting for Gateway readiness until the app resumed or its network route changed.
|
||||
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
|
||||
|
||||
- Standalone response cards use one surface, assistant bubbles are subtler, and timestamps share a row with delivery status.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Dashboard-only connections now start the exact profile-scoped session directory before Gateway readiness, so the directory and passive Gateway socket no longer wait on each other during a cold launch.
|
||||
- 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.16.1** (versionCode **56**).
|
||||
- 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.2** remains the matching optional release for Relay tools; this Gateway startup fix does not require it.
|
||||
- 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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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.16.1 - Dashboard-only cold starts recover
|
||||
v1.17.0 - Voice over other apps and clearer conversations
|
||||
|
||||
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.
|
||||
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,25 @@
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
v1.17.0 - Voice over other apps and clearer conversations
|
||||
|
||||
Summary
|
||||
* Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.
|
||||
* 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
|
||||
* Open Gateway chat from a Dashboard-only cold start — The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.
|
||||
* 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
|
||||
* 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
|
||||
* 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 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.
|
||||
* 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -518,6 +518,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
|
||||
|
||||
@@ -1586,6 +1586,7 @@ class GatewayChatClient(
|
||||
claimedBackground?.pendingAsk?.let { ask ->
|
||||
boundTurn.restoreInteraction(ask)
|
||||
}
|
||||
boundTurn.restorePendingClarify(response)
|
||||
boundTurn.armWatchdog()
|
||||
} else if (queued != null) {
|
||||
synchronized(recoveryEventLock) { recoveryEvents = null }
|
||||
@@ -1746,17 +1747,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3854,7 +3877,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
|
||||
) {
|
||||
@@ -3881,6 +3907,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(
|
||||
@@ -3998,6 +4025,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
|
||||
@@ -4204,6 +4234,7 @@ class GatewayChatClient(
|
||||
activated.isSuccess -> {
|
||||
activated.getOrNull()?.let { result ->
|
||||
applySessionResultInfo(result)
|
||||
turn.restorePendingClarify(result)
|
||||
turn.settleFromAuthoritativeSessionState(
|
||||
running = result.booleanField("running"),
|
||||
source = "session.activate",
|
||||
@@ -4506,6 +4537,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
|
||||
|
||||
@@ -32,19 +32,66 @@ class GatewayEventMapper(
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
@get:Synchronized
|
||||
internal val currentInteraction: GatewayAsk?
|
||||
get() = pendingInteraction
|
||||
@Volatile internal var interactionGeneration: Long = 0
|
||||
private set
|
||||
|
||||
internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val duplicate = pendingInteraction?.sameRequestAs(ask) == true
|
||||
pendingInteraction = ask
|
||||
if (!duplicate) callbacks.onInteractionRequest(ask)
|
||||
@Synchronized internal fun restoreInteraction(ask: GatewayAsk) {
|
||||
val previous = pendingInteraction
|
||||
val duplicate = previous?.sameRequestAs(ask) == true
|
||||
if (!duplicate) interactionGeneration++
|
||||
val merged = if (duplicate && ask.questions.isNotEmpty()) {
|
||||
ask.withAnswers(previous.answers + ask.answers, previous)
|
||||
} else if (ask.questions.isNotEmpty()) ask.withAnswers(emptyMap()) else ask
|
||||
if (merged.ownershipToken.retired.get() && !merged.clarifyComplete) {
|
||||
if (duplicate) pendingInteraction = null
|
||||
callbacks.onInteractionExpired(GatewayAskExpiry(merged.kind, merged.requestId))
|
||||
if (pendingInteraction == null) drainDeferredTerminalEvent()
|
||||
return
|
||||
}
|
||||
pendingInteraction = merged
|
||||
if (!duplicate || previous != merged) callbacks.onInteractionRequest(merged)
|
||||
if (merged.clarifyComplete) {
|
||||
pendingInteraction = null
|
||||
drainDeferredTerminalEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized internal fun acknowledgeClarify(
|
||||
requestId: String,
|
||||
questionId: String?,
|
||||
answer: String,
|
||||
expired: Boolean,
|
||||
generation: Long = interactionGeneration,
|
||||
) {
|
||||
if (generation != interactionGeneration) return
|
||||
val pending = pendingInteraction ?: return
|
||||
if (pending.kind != GatewayAsk.Kind.CLARIFY || pending.requestId != requestId) return
|
||||
if (!expired && questionId != null && pending.questions.none { it.qid == questionId }) return
|
||||
if (!expired && questionId != null && pending.questions.any { it.qid == questionId }) {
|
||||
val updated = pending.withAnswers(pending.answers + (questionId to answer))
|
||||
if (updated.questions.any { it.qid !in updated.answers }) {
|
||||
pendingInteraction = updated
|
||||
return
|
||||
}
|
||||
}
|
||||
acknowledgeInteraction(GatewayAskExpiry(GatewayAsk.Kind.CLARIFY, requestId))
|
||||
}
|
||||
|
||||
@Synchronized internal fun acknowledgeClarifyOwner(
|
||||
requestId: String, questionId: String?, answer: String, expired: Boolean, owner: GatewayAskOwnership,
|
||||
) {
|
||||
if (pendingInteraction?.ownershipToken !== owner) return
|
||||
acknowledgeClarify(requestId, questionId, answer, expired)
|
||||
}
|
||||
|
||||
/** Retire only the ask whose explicit respond RPC reached server truth. */
|
||||
internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
|
||||
@Synchronized internal fun acknowledgeInteraction(expiry: GatewayAskExpiry) {
|
||||
val pending = pendingInteraction ?: return
|
||||
if (pending.matches(expiry)) {
|
||||
pending.ownershipToken.retired.set(true)
|
||||
pendingInteraction = null
|
||||
drainDeferredTerminalEvent()
|
||||
}
|
||||
@@ -77,7 +124,7 @@ class GatewayEventMapper(
|
||||
*/
|
||||
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
@Synchronized fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
|
||||
interactionRequest(type, payload)?.let { ask ->
|
||||
@@ -88,6 +135,7 @@ class GatewayEventMapper(
|
||||
interactionExpiry(type, payload)?.let { expiry ->
|
||||
val pending = pendingInteraction
|
||||
if (pending != null && pending.matches(expiry)) {
|
||||
pending.ownershipToken.retired.set(true)
|
||||
pendingInteraction = null
|
||||
}
|
||||
callbacks.onInteractionExpired(expiry)
|
||||
@@ -503,26 +551,7 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
fun interactionRequest(type: String, payload: JsonObject?): GatewayAsk? = when (type) {
|
||||
"clarify.request" -> {
|
||||
val choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
|
||||
?.filter { it.isNotEmpty() }
|
||||
?.distinct()
|
||||
?.take(MAX_CLARIFY_CHOICES)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = choices,
|
||||
multiSelect = payload.boolean("multi_select") == true && choices != null,
|
||||
// Current upstream owns expiry through clarify.expire and
|
||||
// does not advertise its configurable deadline. Never
|
||||
// invent a local deadline; consume future additive
|
||||
// metadata only when it is present and positive.
|
||||
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
|
||||
)
|
||||
}
|
||||
"clarify.request" -> clarifyRequest(payload)
|
||||
|
||||
"approval.request" -> GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
@@ -555,6 +584,41 @@ class GatewayEventMapper(
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun clarifyRequest(payload: JsonObject?): GatewayAsk? {
|
||||
val rawQuestions = payload?.get("questions")
|
||||
if (rawQuestions != null && rawQuestions !is JsonArray) return null
|
||||
val questions = rawQuestions?.map { value ->
|
||||
val row = value as? JsonObject ?: return null
|
||||
val qid = (row["qid"] as? JsonPrimitive)?.takeIf { it.isString }
|
||||
?.contentOrNull?.takeIf(String::isNotBlank) ?: return null
|
||||
val text = row.string("question")?.takeIf(String::isNotBlank) ?: return null
|
||||
val choices = (row["choices"] as? JsonArray)?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.takeIf { option -> option.isString }
|
||||
?.contentOrNull?.takeIf(String::isNotBlank)
|
||||
}.orEmpty().take(MAX_CLARIFY_CHOICES)
|
||||
GatewayClarifyQuestion(qid, text, choices, row.boolean("multi_select") == true && choices.isNotEmpty())
|
||||
}.orEmpty()
|
||||
if (questions.size > MAX_CLARIFY_QUESTIONS || questions.map { it.qid }.distinct().size != questions.size) return null
|
||||
val choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }
|
||||
?.filter(String::isNotEmpty)?.distinct()?.take(MAX_CLARIFY_CHOICES)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
return GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = choices,
|
||||
multiSelect = payload.boolean("multi_select") == true && choices != null,
|
||||
// Upstream owns its configurable deadline; only consume advertised metadata.
|
||||
timeoutSeconds = payload.int("timeout_seconds")?.coerceAtLeast(0) ?: 0,
|
||||
questions = questions,
|
||||
answers = (payload?.get("answers") as? JsonObject)?.mapNotNull { (qid, value) ->
|
||||
(value as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull
|
||||
?.takeIf { questions.any { q -> q.qid == qid } }?.let { qid to it }
|
||||
}?.toMap().orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
fun interactionExpiry(type: String, payload: JsonObject?): GatewayAskExpiry? = when (type) {
|
||||
"clarify.expire" -> GatewayAskExpiry(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
@@ -634,6 +698,7 @@ class GatewayEventMapper(
|
||||
// Upstream clarify tool accepts at most four choices. Sudo/secret retain fixed
|
||||
// `_block()` timeouts; clarify is configurable and expires authoritatively.
|
||||
private const val MAX_CLARIFY_CHOICES = 4
|
||||
private const val MAX_CLARIFY_QUESTIONS = 5
|
||||
private const val SUDO_TIMEOUT_SECONDS = 120
|
||||
private const val SECRET_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -145,7 +146,7 @@ fun HermesCardBubble(
|
||||
// Expiry clock for timed asks. Ticks once a second while the deadline
|
||||
// is ahead; freezes after. Keyed on the deadline so a re-used card id
|
||||
// with a fresh expiry restarts the loop.
|
||||
val expiresAt = card.input?.expiresAtMillis
|
||||
val expiresAt = card.input?.expiresAtMillis ?: card.clarifyBatch?.expiresAtMillis
|
||||
var nowMillis by remember(expiresAt) { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(expiresAt) {
|
||||
if (expiresAt == null) return@LaunchedEffect
|
||||
@@ -252,6 +253,14 @@ fun HermesCardBubble(
|
||||
// action button all collapse the same way.
|
||||
val input = card.input
|
||||
when {
|
||||
card.clarifyBatch != null -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ClarifyBatchContent(
|
||||
batch = card.clarifyBatch,
|
||||
expired = expired || alreadyChosen?.actionValue == HermesCardDispatch.EXPIRED_STAMP,
|
||||
onInputSubmit = onInputSubmit,
|
||||
)
|
||||
}
|
||||
alreadyChosen != null -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
val chosenAction = card.actions.firstOrNull {
|
||||
@@ -408,15 +417,18 @@ private fun ChoseRow(
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CardInputSlot(
|
||||
internal fun CardInputSlot(
|
||||
input: HermesCardInput,
|
||||
onSubmit: (String) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
stackedChoices: Boolean = false,
|
||||
) {
|
||||
// Deliberately remember, not rememberSaveable — a typed secret must
|
||||
// never be written into the saved-instance-state Bundle.
|
||||
var answerText by remember { mutableStateOf("") }
|
||||
// Batch drafts survive lazy-item disposal. Secrets never enter saved state.
|
||||
var answerText by if (stackedChoices && !input.masked) rememberSaveable { mutableStateOf("") }
|
||||
else remember { mutableStateOf("") }
|
||||
var reveal by remember { mutableStateOf(false) }
|
||||
var selectedChoices by remember(input.choices) { mutableStateOf(emptyList<String>()) }
|
||||
var selectedChoices by if (stackedChoices) rememberSaveable(input.choices) { mutableStateOf(emptyList<String>()) }
|
||||
else remember(input.choices) { mutableStateOf(emptyList<String>()) }
|
||||
val isMultiSelect = input.multiSelect && input.choices.isNotEmpty()
|
||||
|
||||
val showFreeText = !input.masked && (
|
||||
@@ -429,7 +441,7 @@ private fun CardInputSlot(
|
||||
|
||||
val submitFreeText = {
|
||||
val customAnswer = answerText.trim()
|
||||
if (customAnswer.isNotEmpty()) {
|
||||
if (enabled && customAnswer.isNotEmpty()) {
|
||||
onSubmit(
|
||||
if (isMultiSelect) {
|
||||
encodeClarifyMultiSelectAnswer(selectedChoices + customAnswer)
|
||||
@@ -443,14 +455,13 @@ private fun CardInputSlot(
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Choice chips
|
||||
if (input.choices.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val choices: @Composable () -> Unit = {
|
||||
input.choices.forEach { choice ->
|
||||
if (isMultiSelect) {
|
||||
val selected = choice in selectedChoices
|
||||
FilterChip(
|
||||
enabled = enabled,
|
||||
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
|
||||
selected = selected,
|
||||
onClick = {
|
||||
selectedChoices = if (selected) {
|
||||
@@ -459,7 +470,11 @@ private fun CardInputSlot(
|
||||
selectedChoices + choice
|
||||
}
|
||||
},
|
||||
label = { Text(choice, style = MaterialTheme.typography.labelMedium) },
|
||||
label = {
|
||||
Text(choice,
|
||||
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
|
||||
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
|
||||
},
|
||||
leadingIcon = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
@@ -477,9 +492,13 @@ private fun CardInputSlot(
|
||||
)
|
||||
} else {
|
||||
AssistChip(
|
||||
enabled = enabled,
|
||||
modifier = if (stackedChoices) Modifier.fillMaxWidth() else Modifier,
|
||||
onClick = { onSubmit(choice) },
|
||||
label = {
|
||||
Text(choice, style = MaterialTheme.typography.labelMedium)
|
||||
Text(choice,
|
||||
style = if (stackedChoices) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
|
||||
modifier = if (stackedChoices) Modifier.padding(vertical = 8.dp) else Modifier)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -489,6 +508,11 @@ private fun CardInputSlot(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stackedChoices) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { choices() }
|
||||
} else {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { choices() }
|
||||
}
|
||||
}
|
||||
|
||||
// Masked secret field
|
||||
@@ -534,12 +558,13 @@ private fun CardInputSlot(
|
||||
else R.string.card_answer_placeholder,
|
||||
),
|
||||
onSubmit = submitFreeText,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!isMultiSelect) {
|
||||
IconButton(
|
||||
onClick = submitFreeText,
|
||||
enabled = answerText.isNotBlank(),
|
||||
enabled = enabled && answerText.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
@@ -559,7 +584,7 @@ private fun CardInputSlot(
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Button(
|
||||
onClick = { onSubmit(encodeClarifyMultiSelectAnswer(answers)) },
|
||||
enabled = answers.isNotEmpty(),
|
||||
enabled = enabled && answers.isNotEmpty(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.card_submit),
|
||||
@@ -615,6 +640,7 @@ private fun InlineAnswerField(
|
||||
placeholder: String,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val shape = appearanceRoundedCornerShape(16.dp)
|
||||
Box(
|
||||
@@ -632,6 +658,7 @@ private fun InlineAnswerField(
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
enabled = enabled,
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
@@ -639,7 +666,7 @@ private fun InlineAnswerField(
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = { onSubmit() }),
|
||||
keyboardActions = KeyboardActions(onSend = { onSubmit() }, onDone = { onSubmit() }),
|
||||
maxLines = 3,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -23,14 +23,9 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
|
||||
/**
|
||||
* Bottom-sheet audit of the exact extra context the agent is injected with on
|
||||
* the next turn — opened by tapping the chat [ContextMeterBar].
|
||||
*
|
||||
* Renders the SAME [ChatViewModel.InjectedContext] the send path builds (via
|
||||
* [ChatViewModel.previewInjectedContext] → `composeInjectedContext`), so it is
|
||||
* a faithful audit, not a re-derivation that could drift. Empty blocks show a
|
||||
* labeled note instead of vanishing, and the gateway's server-side persona is
|
||||
* explicitly called out as not-sent-from-this-device.
|
||||
* Transport-aware context preview, opened from the chat [ContextMeterBar].
|
||||
* Unsupported blocks are labeled rather than represented as delivered.
|
||||
* Server-reported configuration is separate from device-supplied context.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -53,7 +48,7 @@ fun InjectedContextSheet(
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.injected_context_subtitle, context.transport),
|
||||
text = stringResource(R.string.injected_context_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -71,6 +66,7 @@ fun InjectedContextSheet(
|
||||
val mediaNoRelay = stringResource(R.string.injected_context_media_no_relay)
|
||||
val relayNotSet = stringResource(R.string.injected_context_relay_not_set)
|
||||
val turnNotSet = stringResource(R.string.injected_context_turn_not_set)
|
||||
val unsupported = stringResource(R.string.injected_context_gateway_unsupported)
|
||||
|
||||
ContextSection(
|
||||
title = personaTitle,
|
||||
@@ -84,7 +80,7 @@ fun InjectedContextSheet(
|
||||
ContextSection(
|
||||
title = phoneStatusTitle,
|
||||
body = context.appContext,
|
||||
emptyNote = phoneStatusNotSet,
|
||||
emptyNote = if (context.perTurnContextSupported) phoneStatusNotSet else unsupported,
|
||||
)
|
||||
ContextSection(
|
||||
title = mediaTitle,
|
||||
@@ -107,7 +103,7 @@ fun InjectedContextSheet(
|
||||
ContextSection(
|
||||
title = turnTitle,
|
||||
body = context.interfaceContext,
|
||||
emptyNote = turnNotSet,
|
||||
emptyNote = if (context.perTurnContextSupported) turnNotSet else unsupported,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -214,7 +215,7 @@ fun MessageBubble(
|
||||
message.role == MessageRole.USER -> MaterialTheme.colorScheme.primary
|
||||
message.role == MessageRole.SYSTEM -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
isActionBubble -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.45f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||
}
|
||||
|
||||
val textColor = when (message.role) {
|
||||
@@ -436,8 +437,8 @@ fun MessageBubble(
|
||||
// rows) would otherwise paint a bare timestamp-only chip between the
|
||||
// Thought-process block and the tool pill. The first-token working state
|
||||
// is rendered directly in the conversation
|
||||
// lane below, without an opaque bubble. Cards and attachments still own
|
||||
// a normal bubble even when response prose has not arrived yet.
|
||||
// lane below, without an opaque bubble. Standalone cards own their own
|
||||
// surface; wrapping those in another filled bubble duplicates the chrome.
|
||||
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
|
||||
StandaloneStreamingStatus(
|
||||
status = streamingStatus,
|
||||
@@ -454,6 +455,10 @@ fun MessageBubble(
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
val standaloneCards = !isUser && !isSystem &&
|
||||
visibleMessageContent.isBlank() && quoteEnvelope == null &&
|
||||
message.cards.isNotEmpty() && message.attachments.isEmpty() &&
|
||||
inlineImages.isEmpty() && !showImageGeneration
|
||||
if (showBubble) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = maxBubbleWidth),
|
||||
@@ -599,11 +604,11 @@ fun MessageBubble(
|
||||
),
|
||||
) {
|
||||
Surface(
|
||||
shape = bubbleShape,
|
||||
color = backgroundColor,
|
||||
shape = if (standaloneCards) RectangleShape else bubbleShape,
|
||||
color = if (standaloneCards) Color.Transparent else backgroundColor,
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (!isUser && !isSystem && isDarkTheme) {
|
||||
if (!isUser && !isSystem && isDarkTheme && !standaloneCards) {
|
||||
Modifier.leftEdgeGlow(
|
||||
alpha = 0.12f,
|
||||
width = 28.dp,
|
||||
@@ -651,7 +656,8 @@ fun MessageBubble(
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
modifier = if (standaloneCards) Modifier
|
||||
else Modifier.padding(horizontal = 14.dp, vertical = 9.dp),
|
||||
) {
|
||||
quoteEnvelope?.let { envelope ->
|
||||
ChatQuoteReferenceChip(
|
||||
@@ -741,7 +747,7 @@ fun MessageBubble(
|
||||
onInputSubmit = { key, value ->
|
||||
onCardInput(message.id, key, value)
|
||||
},
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
maxWidth = if (standaloneCards) maxBubbleWidth else maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -823,6 +829,7 @@ fun MessageBubble(
|
||||
|
||||
val hasTokenUsage = showUsage && !isUser &&
|
||||
(message.inputTokens != null || message.outputTokens != null)
|
||||
val deliveryStatus = message.deliveryStatus?.takeIf { isUser }
|
||||
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
@@ -831,13 +838,14 @@ fun MessageBubble(
|
||||
// This row is reserved from the first streaming frame. Completion
|
||||
// can reveal both timestamp and token usage without adding a new
|
||||
// footer line or changing the bubble's measured height.
|
||||
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
|
||||
if ((isLastInGroup && (showTimestamps || hasTokenUsage)) || deliveryStatus != null) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = if (standaloneCards) Modifier.padding(horizontal = 4.dp) else Modifier,
|
||||
) {
|
||||
if (showTimestamps) Text(
|
||||
if (isLastInGroup && showTimestamps) Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
@@ -847,33 +855,30 @@ fun MessageBubble(
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
if (hasTokenUsage) {
|
||||
if (isLastInGroup && hasTokenUsage) {
|
||||
TokenDisplay(
|
||||
inputTokens = message.inputTokens,
|
||||
outputTokens = message.outputTokens,
|
||||
)
|
||||
}
|
||||
// Share the footer line, retaining the user bubble's contrasting foreground.
|
||||
deliveryStatus?.let { status ->
|
||||
MessageDeliveryIndicator(
|
||||
status = status,
|
||||
contentColor = textColor,
|
||||
text = MessageDeliveryIndicatorText(
|
||||
sending = stringResource(R.string.msg_bubble_sending),
|
||||
queued = stringResource(R.string.msg_bubble_queued),
|
||||
steered = stringResource(R.string.msg_bubble_steered),
|
||||
delivered = stringResource(R.string.msg_bubble_delivered),
|
||||
failed = stringResource(R.string.msg_bubble_not_sent),
|
||||
tapToRetry = stringResource(R.string.chat_retry),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery status for local user messages, including steering.
|
||||
// Use the bubble's foreground: accent-on-accent hides the label.
|
||||
message.deliveryStatus?.takeIf { isUser }?.let { status ->
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
MessageDeliveryIndicator(
|
||||
status = status,
|
||||
contentColor = textColor,
|
||||
text = MessageDeliveryIndicatorText(
|
||||
sending = stringResource(R.string.msg_bubble_sending),
|
||||
queued = stringResource(R.string.msg_bubble_queued),
|
||||
steered = stringResource(R.string.msg_bubble_steered),
|
||||
delivered = stringResource(R.string.msg_bubble_delivered),
|
||||
failed = stringResource(R.string.msg_bubble_not_sent),
|
||||
tapToRetry = stringResource(R.string.chat_retry),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Non-tail historical fragments have no reserved timestamp row.
|
||||
// Preserve their existing standalone token metadata layout.
|
||||
if (!isLastInGroup && hasTokenUsage) {
|
||||
|
||||
@@ -1673,65 +1673,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) {
|
||||
@@ -1747,37 +1735,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
|
||||
@@ -3258,8 +3215,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()
|
||||
|
||||
@@ -86,6 +86,10 @@ fun PermissionsStatusScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenBridge: () -> Unit = {},
|
||||
) {
|
||||
var showVoiceOverlaySetup by remember { mutableStateOf(false) }
|
||||
if (showVoiceOverlaySetup) com.hermesandroid.relay.voice.VoiceOverlaySetupDialog(
|
||||
onDismiss = { showVoiceOverlaySetup = false },
|
||||
)
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var status by remember { mutableStateOf(AppPermissionStatusProbe.snapshot(context)) }
|
||||
@@ -209,6 +213,21 @@ fun PermissionsStatusScreen(
|
||||
)
|
||||
}
|
||||
|
||||
PermissionSection(
|
||||
title = stringResource(R.string.voice_overlay_setup_title),
|
||||
subtitle = stringResource(R.string.voice_overlay_settings_hint),
|
||||
) {
|
||||
PermissionStatusRow(
|
||||
icon = Icons.Filled.PictureInPicture,
|
||||
title = stringResource(R.string.perms_display_over_apps),
|
||||
subtitle = stringResource(R.string.voice_overlay_setup_body),
|
||||
badge = stringResource(R.string.perms_badge_optional),
|
||||
statusLabel = optionalStatus(context, status.overlayPermitted),
|
||||
granted = status.overlayPermitted,
|
||||
onClick = { showVoiceOverlaySetup = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildFlavor.isSideload) {
|
||||
SideloadPermissionsSection(
|
||||
status = status,
|
||||
|
||||
@@ -582,6 +582,7 @@ fun VoiceSettingsScreen(
|
||||
}
|
||||
|
||||
VoiceSettingsSection.Listening -> {
|
||||
com.hermesandroid.relay.voice.VoiceOverlaySettingsCard()
|
||||
GlobalVoiceControlsCard(
|
||||
voiceSettings = voiceSettings,
|
||||
prefsRepo = prefsRepo,
|
||||
|
||||
@@ -72,6 +72,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyBatch
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
|
||||
import com.hermesandroid.relay.data.clarifyQuestionCardKey
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAgentNotice
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
@@ -3652,7 +3655,7 @@ class ChatViewModel : ViewModel() {
|
||||
private val backgroundPendingInteractions =
|
||||
ConcurrentHashMap<TurnCheckpointKey, BackgroundPendingInteraction>()
|
||||
|
||||
/** Ask cardKeys with a respond RPC in flight — blocks double-taps until it settles. */
|
||||
/** Request-incarnation/card keys with a respond RPC in flight. */
|
||||
private val answeredAskIds = mutableSetOf<String>()
|
||||
|
||||
/**
|
||||
@@ -6774,7 +6777,18 @@ class ChatViewModel : ViewModel() {
|
||||
existing.ask.kind == ask.kind &&
|
||||
existing.ask.requestId == ask.requestId
|
||||
) {
|
||||
sessionId?.let { maybeNotifyInteraction(it, existing.ask) }
|
||||
if (ask.questions.isNotEmpty()) {
|
||||
val updated = existing.copy(ask = ask.copy(answers = existing.ask.answers + ask.answers))
|
||||
_pendingAsk.value = updated
|
||||
updateClarifyBatchCard(handler, updated)
|
||||
if (updated.ask.clarifyComplete) {
|
||||
_pendingAsk.value = null
|
||||
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
|
||||
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
_pendingAsk.value?.let { pending -> sessionId?.let { maybeNotifyInteraction(it, pending.ask) } }
|
||||
return
|
||||
}
|
||||
existing?.let { pending ->
|
||||
@@ -6787,8 +6801,11 @@ class ChatViewModel : ViewModel() {
|
||||
publishBackgroundSessionActivity()
|
||||
}
|
||||
val now = restored?.receivedAt ?: System.currentTimeMillis()
|
||||
val cardKey = restored?.cardKey ?: ask.requestId
|
||||
val proposedCardKey = restored?.cardKey ?: ask.requestId
|
||||
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
|
||||
val cardKey = if (restored == null && handler.messages.value.any { message ->
|
||||
message.cards.any { it.id == proposedCardKey }
|
||||
}) "$proposedCardKey-${java.util.UUID.randomUUID()}" else proposedCardKey
|
||||
val expiresAt = ask.timeoutSeconds.takeIf { it > 0 }?.let { now + it * 1_000L }
|
||||
val card = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> HermesCard(
|
||||
@@ -6887,12 +6904,52 @@ class ChatViewModel : ViewModel() {
|
||||
contextKey = contextKey,
|
||||
sessionId = sessionId,
|
||||
receivedAt = now,
|
||||
ownerId = restored?.ownerId ?: java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
if (ask.questions.isNotEmpty()) updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
|
||||
if (ask.clarifyComplete) {
|
||||
_pendingAsk.value = null
|
||||
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
return
|
||||
}
|
||||
activeKey?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), true) }
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
sessionId?.let { maybeNotifyInteraction(it, ask) }
|
||||
}
|
||||
|
||||
private fun updateClarifyBatchCard(handler: ChatHandler, pending: PendingAsk) {
|
||||
val ask = pending.ask
|
||||
if (ask.questions.isEmpty()) return
|
||||
handler.updateAskCardMessage(
|
||||
pending.messageId,
|
||||
HermesCard(
|
||||
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
|
||||
title = appContext?.getString(R.string.chat_approval_clarify_title) ?: "Hermes needs clarification",
|
||||
accent = HermesCard.Accents.INFO,
|
||||
id = pending.cardKey,
|
||||
clarifyBatch = HermesCardClarifyBatch(
|
||||
questions = ask.questions.map { question ->
|
||||
val key = clarifyQuestionCardKey(pending.cardKey, question.qid)
|
||||
HermesCardClarifyQuestion(
|
||||
key = key,
|
||||
question = question.question,
|
||||
input = HermesCardInput(
|
||||
kind = if (question.choices.isEmpty()) HermesCardInput.Kinds.TEXT else HermesCardInput.Kinds.CHOICE,
|
||||
choices = question.choices,
|
||||
multiSelect = question.multiSelect,
|
||||
allowFreeText = true,
|
||||
),
|
||||
answer = ask.answers[question.qid],
|
||||
submitting = "${pending.ownerId}:$key" in answeredAskIds,
|
||||
)
|
||||
},
|
||||
expiresAtMillis = ask.timeoutSeconds.takeIf { it > 0 }?.let { pending.receivedAt + it * 1_000L },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervised Chat never exposes approval, clarification, sudo, or secret
|
||||
* inputs. Settle the upstream interaction immediately with its safest
|
||||
@@ -6966,21 +7023,35 @@ class ChatViewModel : ViewModel() {
|
||||
fun answerAsk(messageId: String, cardKey: String, value: String) {
|
||||
val handler = chatHandler ?: return
|
||||
val pending = _pendingAsk.value
|
||||
val question = pending?.ask?.questions?.firstOrNull {
|
||||
clarifyQuestionCardKey(pending.cardKey, it.qid) == cardKey
|
||||
}
|
||||
if (pending == null ||
|
||||
pending.cardKey != cardKey ||
|
||||
pending.messageId != messageId ||
|
||||
(if (pending.ask.questions.isNotEmpty()) question == null else pending.cardKey != cardKey) ||
|
||||
pending.contextKey != activeProfileContextKey ||
|
||||
pending.sessionId != handler.currentSessionId.value
|
||||
) {
|
||||
handler.addSystemNotice("This request is no longer active.")
|
||||
return
|
||||
}
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && value.isBlank()) return
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY && pending.ask.timeoutSeconds > 0 &&
|
||||
System.currentTimeMillis() >= pending.receivedAt + pending.ask.timeoutSeconds * 1_000L
|
||||
) {
|
||||
expirePendingAsk(GatewayAskExpiry(pending.ask.kind, pending.ask.requestId))
|
||||
return
|
||||
}
|
||||
if (question != null && question.qid in pending.ask.answers) return
|
||||
val gateway = gatewayClient
|
||||
if (gateway == null) {
|
||||
emitError(Exception("Gateway is not connected"), context = "send_message")
|
||||
return
|
||||
}
|
||||
// In-flight guard: one respond RPC per card at a time.
|
||||
if (!answeredAskIds.add(cardKey)) return
|
||||
// Include the request incarnation so a late completion cannot unlock a reused id.
|
||||
val flightKey = "${pending.ownerId}:$cardKey"
|
||||
if (!answeredAskIds.add(flightKey)) return
|
||||
updateClarifyBatchCard(handler, pending)
|
||||
val ask = pending.ask
|
||||
val stampValue = when (ask.kind) {
|
||||
// Empty sudo password = decline — stamp matches the Deny action
|
||||
@@ -6992,11 +7063,19 @@ class ChatViewModel : ViewModel() {
|
||||
else -> value
|
||||
}
|
||||
viewModelScope.launch {
|
||||
fun ownsResponse(): Boolean = chatHandler === handler && gatewayClient === gateway &&
|
||||
activeProfileContextKey == pending.contextKey &&
|
||||
handler.currentSessionId.value == pending.sessionId &&
|
||||
_pendingAsk.value?.ownerId == pending.ownerId
|
||||
if (!ownsResponse()) {
|
||||
answeredAskIds.remove(flightKey)
|
||||
return@launch
|
||||
}
|
||||
val requestId = ask.requestId
|
||||
val result = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = value)
|
||||
GatewayAsk.Kind.CLARIFY ->
|
||||
requestId?.let { gateway.respondClarify(it, value) }
|
||||
requestId?.let { gateway.respondClarify(it, value.trim(), question?.qid) }
|
||||
?: Result.failure(GatewayRpcException("ask has no request id"))
|
||||
GatewayAsk.Kind.SUDO ->
|
||||
requestId?.let { gateway.respondSudo(it, value) }
|
||||
@@ -7007,6 +7086,8 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { response ->
|
||||
answeredAskIds.remove(flightKey)
|
||||
if (!ownsResponse()) return@fold
|
||||
if (response == GatewayAskResponse.EXPIRED) {
|
||||
expirePendingAsk(
|
||||
GatewayAskExpiry(
|
||||
@@ -7016,10 +7097,24 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
return@fold
|
||||
}
|
||||
if (question != null) {
|
||||
val current = requireNotNull(_pendingAsk.value)
|
||||
val updated = current.copy(ask = current.ask.copy(answers = current.ask.answers + (question.qid to value.trim())))
|
||||
updateClarifyBatchCard(handler, updated)
|
||||
if (updated.ask.questions.all { it.qid in updated.ask.answers }) {
|
||||
updated.sessionId?.let { cancelInteractionNotification(it, updated.ask) }
|
||||
_pendingAsk.value = null
|
||||
activeTurnCheckpointKey()?.let { ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false) }
|
||||
} else {
|
||||
_pendingAsk.value = updated
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
return@fold
|
||||
}
|
||||
// Collapse only after the server confirms — a failed RPC
|
||||
// must leave the card answerable for a retry.
|
||||
handler.recordCardDispatch(pending.messageId, cardKey, stampValue)
|
||||
if (_pendingAsk.value === pending) {
|
||||
if (ownsResponse()) {
|
||||
pending.sessionId?.let { cancelInteractionNotification(it, pending.ask) }
|
||||
_pendingAsk.value = null
|
||||
activeTurnCheckpointKey()?.let {
|
||||
@@ -7029,7 +7124,9 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
answeredAskIds.remove(cardKey)
|
||||
answeredAskIds.remove(flightKey)
|
||||
if (!ownsResponse()) return@fold
|
||||
updateClarifyBatchCard(handler, requireNotNull(_pendingAsk.value))
|
||||
emitError(e, context = "send_message")
|
||||
},
|
||||
)
|
||||
@@ -7054,7 +7151,6 @@ class ChatViewModel : ViewModel() {
|
||||
activeTurnCheckpointKey()?.let {
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
|
||||
}
|
||||
answeredAskIds.remove(pending.cardKey)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
chatHandler?.recordCardDispatch(
|
||||
pending.messageId,
|
||||
@@ -7077,7 +7173,9 @@ class ChatViewModel : ViewModel() {
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(it.keepAliveKey(), false)
|
||||
}
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
|
||||
if (pending.ask.kind == GatewayAsk.Kind.CLARIFY) {
|
||||
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, HermesCardDispatch.EXPIRED_STAMP)
|
||||
} else if (pending.ask.kind == GatewayAsk.Kind.APPROVAL) {
|
||||
chatHandler?.recordCardDispatch(pending.messageId, pending.cardKey, "deny")
|
||||
}
|
||||
}
|
||||
@@ -7685,6 +7783,9 @@ class ChatViewModel : ViewModel() {
|
||||
text = ask.ask.text,
|
||||
choices = ask.ask.choices,
|
||||
multiSelect = ask.ask.multiSelect,
|
||||
questions = ask.ask.questions,
|
||||
answers = ask.ask.answers,
|
||||
ownerId = ask.ownerId,
|
||||
smartDenied = ask.ask.smartDenied,
|
||||
envVar = ask.ask.envVar,
|
||||
timeoutSeconds = ask.ask.timeoutSeconds,
|
||||
@@ -7893,6 +7994,8 @@ class ChatViewModel : ViewModel() {
|
||||
text = saved.text,
|
||||
choices = saved.choices,
|
||||
multiSelect = saved.multiSelect,
|
||||
questions = saved.questions,
|
||||
answers = saved.answers,
|
||||
smartDenied = saved.smartDenied,
|
||||
envVar = saved.envVar,
|
||||
timeoutSeconds = saved.timeoutSeconds,
|
||||
@@ -9667,8 +9770,8 @@ class ChatViewModel : ViewModel() {
|
||||
* 0. **Gateway transport** — the server owns the persona end-to-end: the
|
||||
* session is bound to the selected profile (SOUL applied server-side) and
|
||||
* the personality overlay rides `config.set`/`ephemeral_system_prompt`.
|
||||
* The phone sends NO persona/profile prompt (only the phone-status block)
|
||||
* so it can't double-apply. Cases 1–3 are the SSE-fallback rules.
|
||||
* The phone sends no per-turn system context on Gateway.
|
||||
* Cases 1–3 are the explicit API-only transport rules.
|
||||
* 1. **Selected profile with a non-blank [Profile.systemMessage]** —
|
||||
* profile wins outright. Profile is a richer, newer concept than
|
||||
* personality: it bundles model + persona (from the profile's
|
||||
@@ -9683,15 +9786,12 @@ class ChatViewModel : ViewModel() {
|
||||
* configured default.
|
||||
*
|
||||
* The phone-status [appContextSettings] block is appended to whichever
|
||||
* of the above wins (or sent alone in case 3), so the LLM always sees
|
||||
* phone state regardless of persona source.
|
||||
* of the API-only cases above wins (or sent alone in case 3).
|
||||
*/
|
||||
/**
|
||||
* The exact `system_message` (`ephemeral_system_prompt`) injected for a
|
||||
* turn, split into labeled blocks. Single source of truth shared by
|
||||
* [startStream] (which sends [combinedSystemMessage]) and
|
||||
* [previewInjectedContext] (which renders it in the chat audit sheet) so
|
||||
* the preview can never drift from what is actually sent.
|
||||
* Context prepared for the selected transport, split into labeled blocks.
|
||||
* This is a preview, not a delivery receipt or the complete agent prompt.
|
||||
* Gateway has no general per-turn system-context slot.
|
||||
*/
|
||||
data class InjectedContext(
|
||||
val personaPrompt: String?,
|
||||
@@ -9722,6 +9822,8 @@ class ChatViewModel : ViewModel() {
|
||||
* audit UI labels that block "added server-side".
|
||||
*/
|
||||
val personaOwnedServerSide: Boolean,
|
||||
/** False on Gateway; unsupported blocks are excluded from the payload and preview. */
|
||||
val perTurnContextSupported: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -9757,11 +9859,14 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
_sseToolNames.value
|
||||
}
|
||||
val appContextRaw = buildPromptBlock(
|
||||
settings = appContextSettings,
|
||||
snapshot = capturePhoneSnapshot(),
|
||||
availableTools = availableTools,
|
||||
)
|
||||
val appContextRaw = if (!gateway && appContextSettings.master) {
|
||||
buildPromptBlock(
|
||||
settings = appContextSettings,
|
||||
snapshot = capturePhoneSnapshot(),
|
||||
availableTools = availableTools,
|
||||
)
|
||||
} else null
|
||||
val interfaceContext = interfaceContextPrompt?.takeIf { !gateway && it.isNotBlank() }
|
||||
// Gateway has no per-turn system slot. SSE carries the standard
|
||||
// Dashboard route first, then the optional Relay enhancement.
|
||||
val upstreamMediaAvailable = dashboardMediaClientProvider?.invoke() != null
|
||||
@@ -9773,25 +9878,26 @@ class ChatViewModel : ViewModel() {
|
||||
// block (a stable environment fact, like phone status) and before the
|
||||
// per-turn interface context. The per-block fields below null out blanks
|
||||
// only for display.
|
||||
val combined = listOfNotNull(personaPrompt, appContextRaw, mediaCapability, interfaceContextPrompt)
|
||||
val combined = listOfNotNull(personaPrompt, appContextRaw, mediaCapability, interfaceContext)
|
||||
.joinToString("\n\n")
|
||||
.ifBlank { null }
|
||||
return InjectedContext(
|
||||
personaPrompt = personaPrompt,
|
||||
appContext = appContextRaw?.takeIf { it.isNotBlank() },
|
||||
interfaceContext = interfaceContextPrompt?.takeIf { it.isNotBlank() },
|
||||
interfaceContext = interfaceContext,
|
||||
mediaCapability = mediaCapability,
|
||||
relayServerBlocks = relayServerBlocks,
|
||||
relayMediaAvailable = relayMediaAvailable,
|
||||
combinedSystemMessage = combined,
|
||||
transport = streamingEndpoint,
|
||||
personaOwnedServerSide = gateway,
|
||||
perTurnContextSupported = !gateway,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Live audit snapshot of what the agent will be injected with on the next
|
||||
* turn — rendered by the chat context sheet. Per-turn voice context is null
|
||||
* Preview of context supported by the selected transport, plus separately
|
||||
* reported Relay configuration. Per-turn voice context is null
|
||||
* here (it's set only on a spoken turn); the UI notes that.
|
||||
*/
|
||||
fun previewInjectedContext(): InjectedContext {
|
||||
@@ -11843,6 +11949,7 @@ data class PendingAsk(
|
||||
/** Stored session id within [contextKey]; approvals are session-scoped upstream. */
|
||||
val sessionId: String?,
|
||||
val receivedAt: Long = System.currentTimeMillis(),
|
||||
val ownerId: String = java.util.UUID.randomUUID().toString(),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.app.KeyguardManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/** Permissions do not grant permission to start a session: a resumed UI action is also required. */
|
||||
data class VoiceOverlayAccess(
|
||||
val microphone: Boolean,
|
||||
val notifications: Boolean,
|
||||
val overlay: Boolean,
|
||||
val unlocked: Boolean,
|
||||
) {
|
||||
val ready: Boolean get() = microphone && notifications && overlay && unlocked
|
||||
|
||||
companion object {
|
||||
fun read(context: Context): VoiceOverlayAccess {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
val channelEnabled = Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
manager?.getNotificationChannel(VoiceOverlayForegroundService.CHANNEL_ID)?.importance !=
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
return VoiceOverlayAccess(
|
||||
microphone = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED,
|
||||
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled() && channelEnabled,
|
||||
overlay = Settings.canDrawOverlays(context),
|
||||
unlocked = context.getSystemService(KeyguardManager::class.java)?.isKeyguardLocked == false &&
|
||||
context.getSystemService(PowerManager::class.java)?.isInteractive == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,13 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -27,31 +31,84 @@ import com.hermesandroid.relay.R
|
||||
* execution state Android requires once [MainActivity] is backgrounded.
|
||||
*/
|
||||
class VoiceOverlayForegroundService : Service() {
|
||||
private var activeSessionId: Long? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val accessMonitor = object : Runnable {
|
||||
override fun run() {
|
||||
val id = activeSessionId ?: return
|
||||
if (VoiceOverlayHost.peek()?.canContinue(id) != true) {
|
||||
endSession()
|
||||
} else {
|
||||
// Notification channels can be disabled without a runtime-permission event.
|
||||
handler.postDelayed(this, 1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val screenOffReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_SCREEN_OFF) endSession()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextCompat.registerReceiver(this, screenOffReceiver, IntentFilter(Intent.ACTION_SCREEN_OFF),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForegroundNotification()
|
||||
val id = intent?.getLongExtra(EXTRA_SESSION_ID, -1L) ?: -1L
|
||||
val host = VoiceOverlayHost.peek()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "Notification stop requested")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
if (id == activeSessionId) endSession()
|
||||
else if (activeSessionId == null && host?.sessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// No sticky restart, unowned Intent, or stale callback can start a microphone session.
|
||||
if (intent?.action != ACTION_START || host?.canStart(id) != true) {
|
||||
if (host?.sessionId == id) host.exitVoiceSession(id)
|
||||
if (activeSessionId == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
activeSessionId = id
|
||||
if (!startForegroundNotification() || !host.onServiceReady(id)) {
|
||||
endSession()
|
||||
} else {
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
handler.post(accessMonitor)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
Log.i(TAG, "App task removed; closing voice overlay")
|
||||
VoiceOverlayHost.peek()?.exitVoiceSession()
|
||||
endSession()
|
||||
}
|
||||
|
||||
private fun endSession() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val id = activeSessionId
|
||||
activeSessionId = null
|
||||
handler.removeCallbacks(accessMonitor)
|
||||
unregisterReceiver(screenOffReceiver)
|
||||
if (id != null) VoiceOverlayHost.peek()?.exitVoiceSession(id)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
private fun startForegroundNotification(): Boolean {
|
||||
try {
|
||||
ensureChannel()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
@@ -63,9 +120,9 @@ class VoiceOverlayForegroundService : Service() {
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "Could not foreground voice overlay microphone service", t)
|
||||
VoiceOverlayHost.peek()?.hide()
|
||||
stopSelf()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
@@ -76,8 +133,10 @@ class VoiceOverlayForegroundService : Service() {
|
||||
val openPending = PendingIntent.getActivity(this, 0, openIntent, pendingFlags)
|
||||
val stopPending = PendingIntent.getService(
|
||||
this,
|
||||
1,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP),
|
||||
activeSessionId?.toInt() ?: 0,
|
||||
Intent(this, VoiceOverlayForegroundService::class.java).setAction(ACTION_STOP)
|
||||
.setData(android.net.Uri.parse("hermes-voice-overlay:stop/$activeSessionId"))
|
||||
.putExtra(EXTRA_SESSION_ID, activeSessionId ?: -1L),
|
||||
pendingFlags,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
@@ -93,6 +152,7 @@ class VoiceOverlayForegroundService : Service() {
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.addAction(
|
||||
0,
|
||||
getString(R.string.voice_overlay_notification_stop),
|
||||
@@ -123,14 +183,15 @@ class VoiceOverlayForegroundService : Service() {
|
||||
const val NOTIFICATION_ID = 4715
|
||||
const val ACTION_START = "com.hermesandroid.relay.voice.OVERLAY_MIC_START"
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.voice.OVERLAY_MIC_STOP"
|
||||
const val EXTRA_SESSION_ID = "voice_overlay_session_id"
|
||||
|
||||
fun start(context: Context): Boolean {
|
||||
fun start(context: Context, sessionId: Long): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
return runCatching {
|
||||
ContextCompat.startForegroundService(
|
||||
appContext,
|
||||
Intent(appContext, VoiceOverlayForegroundService::class.java)
|
||||
.setAction(ACTION_START),
|
||||
.setAction(ACTION_START).putExtra(EXTRA_SESSION_ID, sessionId),
|
||||
)
|
||||
true
|
||||
}.getOrElse { error ->
|
||||
|
||||
@@ -22,6 +22,8 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -81,6 +84,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
@@ -153,21 +157,78 @@ class VoiceOverlayHost(context: Context) {
|
||||
private var overlayView: View? = null
|
||||
private var overlayOwner: VoiceOverlayLifecycleOwner? = null
|
||||
private var overlayParams: WindowManager.LayoutParams? = null
|
||||
private var generation = android.os.SystemClock.elapsedRealtimeNanos()
|
||||
internal var sessionId: Long? = null
|
||||
private set
|
||||
private var callerLifecycle: Lifecycle? = null
|
||||
private val callerObserver = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
// Keep microphone protection until the real Activity is foreground again.
|
||||
Lifecycle.Event.ON_RESUME -> if (overlayView != null) handoffToApp()
|
||||
Lifecycle.Event.ON_DESTROY -> exitVoiceSession()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun hasOverlayPermission(): Boolean = Settings.canDrawOverlays(appContext)
|
||||
|
||||
fun show(session: VoiceOverlaySession, lifecycle: Lifecycle): Boolean {
|
||||
if (!com.hermesandroid.relay.data.BuildFlavor.voiceSystemOverlay ||
|
||||
!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) ||
|
||||
!VoiceOverlayAccess.read(appContext).ready || !session.uiState.value.voiceMode
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (sessionId != null) return true
|
||||
val id = ++generation
|
||||
sessionId = id
|
||||
fun guarded(action: () -> Unit): () -> Unit = {
|
||||
if (canContinue(id)) action() else exitVoiceSession(id)
|
||||
}
|
||||
sessionState.value = session.copy(
|
||||
onStartListening = guarded(session.onStartListening),
|
||||
onStopListening = guarded(session.onStopListening),
|
||||
onInterrupt = guarded(session.onInterrupt),
|
||||
onPauseAutoMode = guarded(session.onPauseAutoMode),
|
||||
onReturnToHermes = guarded {
|
||||
session.onReturnToHermes()
|
||||
// Already foreground: no lifecycle transition is needed for a safe handoff.
|
||||
if (sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true
|
||||
) handoffToApp()
|
||||
},
|
||||
onExit = { exitVoiceSession(id) },
|
||||
onDismissOverlay = { exitVoiceSession(id) },
|
||||
onResetPosition = guarded { moveTo(24, 96) },
|
||||
)
|
||||
exitCallback = session.onExit
|
||||
callerLifecycle = lifecycle
|
||||
lifecycle.addObserver(callerObserver)
|
||||
if (!VoiceOverlayForegroundService.start(appContext, id)) {
|
||||
exitVoiceSession(id)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private var exitCallback: (() -> Unit)? = null
|
||||
|
||||
private fun handoffToApp() {
|
||||
val id = sessionId ?: return
|
||||
// A fast return from Android Settings must not bypass revocation teardown.
|
||||
if (canContinue(id)) hide() else exitVoiceSession(id)
|
||||
}
|
||||
|
||||
internal fun canStart(id: Long): Boolean = sessionId == id &&
|
||||
callerLifecycle?.currentState?.isAtLeast(Lifecycle.State.RESUMED) == true && canContinue(id)
|
||||
|
||||
internal fun canContinue(id: Long): Boolean = sessionId == id &&
|
||||
sessionState.value?.uiState?.value?.voiceMode == true && VoiceOverlayAccess.read(appContext).ready
|
||||
|
||||
/** Called only after startForeground succeeds; a queued service start is not readiness. */
|
||||
@SuppressLint("InflateParams")
|
||||
fun show(session: VoiceOverlaySession): Boolean {
|
||||
sessionState.value = session
|
||||
if (!hasOverlayPermission()) {
|
||||
Log.w(TAG, "show: SYSTEM_ALERT_WINDOW not granted")
|
||||
return false
|
||||
}
|
||||
if (!VoiceOverlayForegroundService.start(appContext)) {
|
||||
Log.w(TAG, "show: microphone foreground service could not start")
|
||||
sessionState.value = null
|
||||
return false
|
||||
}
|
||||
internal fun onServiceReady(id: Long): Boolean {
|
||||
if (!canStart(id)) return false
|
||||
if (overlayView != null) return true
|
||||
|
||||
val compose = ComposeView(appContext).apply {
|
||||
@@ -191,9 +252,7 @@ class VoiceOverlayHost(context: Context) {
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
overlayType(),
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
|
||||
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
@@ -205,8 +264,6 @@ class VoiceOverlayHost(context: Context) {
|
||||
.onFailure { Log.w(TAG, "addView(voice overlay) failed", it) }
|
||||
.isSuccess
|
||||
if (!added) {
|
||||
sessionState.value = null
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
overlayOwner?.stop()
|
||||
overlayOwner = null
|
||||
return false
|
||||
@@ -214,10 +271,17 @@ class VoiceOverlayHost(context: Context) {
|
||||
|
||||
overlayView = compose
|
||||
overlayParams = params
|
||||
compose.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
moveTo(params.x, params.y)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
sessionId = null
|
||||
exitCallback = null
|
||||
callerLifecycle?.removeObserver(callerObserver)
|
||||
callerLifecycle = null
|
||||
val view = overlayView
|
||||
overlayView = null
|
||||
overlayParams = null
|
||||
@@ -231,17 +295,29 @@ class VoiceOverlayHost(context: Context) {
|
||||
VoiceOverlayForegroundService.stop(appContext)
|
||||
}
|
||||
|
||||
fun exitVoiceSession() {
|
||||
val onExit = sessionState.value?.onExit
|
||||
fun exitVoiceSession(expectedId: Long? = sessionId) {
|
||||
if (expectedId == null || sessionId != expectedId) return
|
||||
val onExit = exitCallback
|
||||
hide()
|
||||
onExit?.invoke()
|
||||
}
|
||||
|
||||
private fun moveBy(dx: Float, dy: Float) {
|
||||
val params = overlayParams ?: return
|
||||
moveTo(params.x + dx.roundToInt(), params.y + dy.roundToInt())
|
||||
}
|
||||
|
||||
private fun moveTo(x: Int, y: Int) {
|
||||
val view = overlayView ?: return
|
||||
val params = overlayParams ?: return
|
||||
params.x = (params.x + dx.roundToInt()).coerceAtLeast(0)
|
||||
params.y = (params.y + dy.roundToInt()).coerceAtLeast(0)
|
||||
val size = android.graphics.Point()
|
||||
@Suppress("DEPRECATION")
|
||||
wm.defaultDisplay.getSize(size)
|
||||
val nextX = x.coerceIn(0, (size.x - view.width).coerceAtLeast(0))
|
||||
val nextY = y.coerceIn(0, (size.y - view.height).coerceAtLeast(0))
|
||||
if (params.x == nextX && params.y == nextY) return
|
||||
params.x = nextX
|
||||
params.y = nextY
|
||||
runCatching { wm.updateViewLayout(view, params) }
|
||||
.onFailure { Log.w(TAG, "updateViewLayout(voice overlay) failed", it) }
|
||||
}
|
||||
@@ -280,10 +356,12 @@ data class VoiceOverlaySession(
|
||||
val onReturnToHermes: () -> Unit,
|
||||
val onDismissOverlay: () -> Unit,
|
||||
val onExit: () -> Unit,
|
||||
val onResetPosition: () -> Unit = {},
|
||||
val connectionLabel: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun VoiceFloatingOverlayPill(
|
||||
internal fun VoiceFloatingOverlayPill(
|
||||
session: VoiceOverlaySession,
|
||||
onDragBy: (Float, Float) -> Unit,
|
||||
) {
|
||||
@@ -301,20 +379,31 @@ private fun VoiceFloatingOverlayPill(
|
||||
?: stringResource(R.string.voice_overlay_label_default_profile)
|
||||
val stateText = voiceOverlayStateLabel(uiState.state)
|
||||
val overlayWidth = (LocalConfiguration.current.screenWidthDp - 24)
|
||||
.coerceIn(280, 368)
|
||||
.coerceIn(200, 368)
|
||||
.dp
|
||||
|
||||
if (minimized) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = uiState,
|
||||
stateText = stateText,
|
||||
onExpand = { minimized = false },
|
||||
onStartListening = session.onStartListening,
|
||||
onStopListening = session.onStopListening,
|
||||
onInterrupt = session.onInterrupt,
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
onDragBy = onDragBy,
|
||||
)
|
||||
TextButton(onClick = session.onExit) {
|
||||
Text(stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -329,6 +418,7 @@ private fun VoiceFloatingOverlayPill(
|
||||
},
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 8.dp,
|
||||
shadowElevation = 10.dp,
|
||||
) {
|
||||
@@ -382,7 +472,9 @@ private fun VoiceOverlayHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center) {
|
||||
if (LocalConfiguration.current.screenWidthDp >= 360) Box(
|
||||
modifier = Modifier.size(50.dp), contentAlignment = Alignment.Center,
|
||||
) {
|
||||
OverlayCircularWaveformRing(
|
||||
amplitude = uiState.amplitude,
|
||||
state = uiState.state,
|
||||
@@ -396,21 +488,28 @@ private fun VoiceOverlayHeader(
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = listOfNotNull(session.connectionLabel, session.profileName).joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stateText,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = overlayPrimaryText(uiState, stateText),
|
||||
val primaryText = overlayPrimaryText(uiState, stateText)
|
||||
if (primaryText != stateText) Text(
|
||||
text = primaryText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(38.dp)) {
|
||||
IconButton(onClick = onToggleExpanded, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(
|
||||
@@ -426,6 +525,9 @@ private fun VoiceOverlayHeader(
|
||||
onPauseAutoMode = session.onPauseAutoMode,
|
||||
size = 50.dp,
|
||||
)
|
||||
IconButton(onClick = session.onExit, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Filled.Close, stringResource(R.string.voice_overlay_notification_stop))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,7 +541,10 @@ private fun ExpandedVoiceOverlayBody(
|
||||
session: VoiceOverlaySession,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
modifier = Modifier
|
||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp - 120).coerceAtLeast(100).dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 8.dp),
|
||||
) {
|
||||
OverlayLinearWaveform(
|
||||
amplitude = uiState.amplitude,
|
||||
@@ -502,31 +607,36 @@ private fun ExpandedVoiceOverlayBody(
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.62f))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val actions: @Composable (Modifier) -> Unit = { actionModifier ->
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.ExpandMore,
|
||||
label = stringResource(R.string.voice_overlay_minimize),
|
||||
onClick = onMinimize,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.Filled.VisibilityOff,
|
||||
label = stringResource(R.string.voice_overlay_hide),
|
||||
onClick = session.onDismissOverlay,
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Filled.GraphicEq,
|
||||
label = stringResource(R.string.voice_overlay_reset_position),
|
||||
onClick = session.onResetPosition,
|
||||
modifier = actionModifier,
|
||||
)
|
||||
VoiceOverlayAction(
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
label = stringResource(R.string.voice_overlay_open_hermes),
|
||||
onClick = session.onReturnToHermes,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = actionModifier,
|
||||
)
|
||||
}
|
||||
val actionContainer = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
val stackActions = androidx.compose.ui.platform.LocalDensity.current.fontScale > 1.2f &&
|
||||
LocalConfiguration.current.screenWidthDp < 360
|
||||
if (stackActions) {
|
||||
Column(modifier = actionContainer) { actions(Modifier.fillMaxWidth()) }
|
||||
} else {
|
||||
Row(modifier = actionContainer, verticalAlignment = Alignment.CenterVertically) {
|
||||
actions(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,7 +680,7 @@ private fun VoiceOverlayAction(
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(54.dp),
|
||||
modifier = modifier.heightIn(min = 72.dp),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -584,7 +694,7 @@ private fun VoiceOverlayAction(
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -1049,7 +1159,7 @@ private fun voiceEngineLabel(engineMode: String?): String = when (engineMode) {
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
}
|
||||
|
||||
fun openHermesFromOverlay(context: Context) {
|
||||
fun openHermesFromOverlay(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
val launchIntent = appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
|
||||
?: Intent().setPackage(appContext.packageName)
|
||||
@@ -1057,8 +1167,9 @@ fun openHermesFromOverlay(context: Context) {
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
runCatching { appContext.startActivity(launchIntent) }
|
||||
return runCatching { appContext.startActivity(launchIntent) }
|
||||
.onFailure { Log.w("VoiceOverlayHost", "return to Hermes failed", it) }
|
||||
.isSuccess
|
||||
}
|
||||
|
||||
private class VoiceOverlayLifecycleOwner :
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/** Settings and Voice Focus share the explanation; only Voice Focus supplies a start action. */
|
||||
@Composable
|
||||
fun VoiceOverlaySetupDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)? = null,
|
||||
onBeforePermission: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
var access by remember { mutableStateOf(VoiceOverlayAccess.read(context)) }
|
||||
var requestedMicrophone by remember { mutableStateOf(false) }
|
||||
var requestedNotifications by remember { mutableStateOf(false) }
|
||||
var failed by remember { mutableStateOf(false) }
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) access = VoiceOverlayAccess.read(context)
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
fun open(intent: Intent) {
|
||||
onBeforePermission()
|
||||
failed = runCatching { context.startActivity(intent) }.isFailure
|
||||
}
|
||||
VoiceOverlaySetupContent(
|
||||
access = access,
|
||||
failed = failed,
|
||||
onDismiss = onDismiss,
|
||||
onStart = onStart?.let { start -> {
|
||||
access = VoiceOverlayAccess.read(context)
|
||||
if (access.ready && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) start()
|
||||
} },
|
||||
onMicrophone = {
|
||||
if (access.microphone || requestedMicrophone) {
|
||||
open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:${context.packageName}")))
|
||||
} else {
|
||||
onBeforePermission()
|
||||
requestedMicrophone = true
|
||||
permission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
onNotifications = {
|
||||
if (Build.VERSION.SDK_INT >= 33 && !requestedNotifications &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
onBeforePermission()
|
||||
requestedNotifications = true
|
||||
permission.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
|
||||
}
|
||||
},
|
||||
onOverlay = {
|
||||
open(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun VoiceOverlaySetupContent(
|
||||
access: VoiceOverlayAccess,
|
||||
failed: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onStart: (() -> Unit)?,
|
||||
onMicrophone: () -> Unit,
|
||||
onNotifications: () -> Unit,
|
||||
onOverlay: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.voice_overlay_setup_title)) },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_body))
|
||||
AccessButton(R.string.perms_microphone, access.microphone, onMicrophone)
|
||||
AccessButton(R.string.perms_app_notifications, access.notifications, onNotifications)
|
||||
AccessButton(R.string.perms_display_over_apps, access.overlay, onOverlay)
|
||||
if (onStart == null) Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
if (failed) Text(stringResource(R.string.chat_overlay_start_failed), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
if (onStart != null) TextButton(onClick = onStart, enabled = access.ready) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_start))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cw_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccessButton(@androidx.annotation.StringRes label: Int, granted: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(label) + " · " + stringResource(
|
||||
if (granted) R.string.ncs_access_granted else R.string.chat_open_settings,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VoiceOverlaySettingsCard() {
|
||||
var show by remember { mutableStateOf(false) }
|
||||
Card {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.voice_overlay_setup_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(stringResource(R.string.voice_overlay_settings_hint))
|
||||
TextButton(onClick = { show = true }) { Text(stringResource(R.string.onboarding_review_optional_permissions)) }
|
||||
}
|
||||
}
|
||||
if (show) VoiceOverlaySetupDialog(onDismiss = { show = false })
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Pergunta %1$d de %2$d</string>
|
||||
<string name="clarify_batch_complete">Todas as perguntas respondidas</string>
|
||||
<string name="clarify_batch_expired">Esta solicitação terminou</string>
|
||||
<string name="clarify_batch_sending">Enviando resposta…</string>
|
||||
<string name="clarify_batch_answered">Perguntas respondidas (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">interface do agente</string>
|
||||
@@ -757,7 +762,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat em segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notifique quando o Hermes precisar de uma resposta ou terminar em segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartilhar o status do celular com o agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Inclua uma breve mensagem do sistema sobre o app e o celular em cada turno do chat</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluir contexto do app e do telefone em chats apenas por API. Não é enviado nos chats padrão do Gateway.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissões</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Se acessibilidade, captura de tela, sobreposição e notificações foram concedidas</string>
|
||||
<string name="chat_settings_foreground_app">App em primeiro plano</string>
|
||||
@@ -766,7 +771,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Porcentagem atual da bateria. Desativado por padrão.</string>
|
||||
<string name="chat_settings_safety_rails">Proteções de segurança</string>
|
||||
<string name="chat_settings_safety_rails_desc">Quantidade de itens bloqueados, quantidade de verbos destrutivos e temporizador de desativação automática</string>
|
||||
<string name="chat_settings_preview">Prévia</string>
|
||||
<string name="chat_settings_preview">Exemplo de chat apenas por API</string>
|
||||
<string name="chat_settings_no_system_message">(nenhuma mensagem do sistema será enviada)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Analisar anotações de ferramentas</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -3196,10 +3201,10 @@
|
||||
<string name="injected_context_phone_status_title">Status do celular</string>
|
||||
<string name="injected_context_relay_not_set">Nenhum relay configurado</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Contexto enviado com a próxima mensagem</string>
|
||||
<string name="injected_context_subtitle">Prévia deste chat. O contexto do servidor é configurado separadamente.</string>
|
||||
<string name="injected_context_title">Contexto injetado</string>
|
||||
<string name="injected_context_turn_not_set">Não definido</string>
|
||||
<string name="injected_context_turn_title">Modo de detecção de turno</string>
|
||||
<string name="injected_context_turn_title">Contexto do turno</string>
|
||||
<string name="model_picker_cd_clear">Limpar</string>
|
||||
<string name="model_picker_count">%d modelos</string>
|
||||
<string name="power_feature_back_desc">Voltar</string>
|
||||
@@ -4440,4 +4445,12 @@
|
||||
<string name="chat_activity_history_notice">Atividade registrada. As atualizações de progresso não são mantidas; o histórico disponível dos subagentes pode ser aberto somente para leitura.</string>
|
||||
<string name="chat_activity_output_unavailable">A saída não está mais disponível. Este registro preserva o estado registrado do processo.</string>
|
||||
<string name="profile_follow_server_default">Seguir o perfil padrão do servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre outros apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantenha os controles de voz visíveis sobre outro app. O áudio vai para o servidor Hermes configurado. Isso não permite ler a tela nem controlar o telefone. Permita a exibição sobre outros apps nas configurações do Android e volte aqui. É necessária uma notificação do microfone com Parar voz. Fechar a sobreposição ou bloquear a tela encerra a sessão de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar sobreposição de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional nas duas versões. Abra o foco de voz no Chat e escolha Sobreposição. As permissões sozinhas nunca iniciam a escuta.</string>
|
||||
<string name="voice_overlay_reset_position">Redefinir posição</string>
|
||||
<string name="injected_context_gateway_unsupported">Não é enviado no chat do Gateway. Esta conexão não oferece suporte a contexto adicional por turno.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">问题 %1$d / %2$d</string>
|
||||
<string name="clarify_batch_complete">所有问题均已回答</string>
|
||||
<string name="clarify_batch_expired">此请求已结束</string>
|
||||
<string name="clarify_batch_sending">正在发送回答…</string>
|
||||
<string name="clarify_batch_answered">已回答的问题(%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">代理界面</string>
|
||||
@@ -796,7 +801,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">后台聊天提醒</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Hermes 在后台需要输入或完成回复时通知</string>
|
||||
<string name="chat_settings_share_phone_status">与代理分享手机状态</string>
|
||||
<string name="chat_settings_share_phone_status_desc">每轮聊天附带一条关于应用和手机的简短系统消息</string>
|
||||
<string name="chat_settings_share_phone_status_desc">在仅使用 API 的聊天中包含应用和手机上下文。标准 Gateway 聊天不会发送此内容。</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 权限</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">无障碍、屏幕捕获、悬浮窗、通知是否已授权</string>
|
||||
<string name="chat_settings_foreground_app">前台应用</string>
|
||||
@@ -805,7 +810,7 @@
|
||||
<string name="chat_settings_battery_level_desc">当前电池百分比。默认关闭。</string>
|
||||
<string name="chat_settings_safety_rails">安全护栏</string>
|
||||
<string name="chat_settings_safety_rails_desc">黑名单数量、破坏性动词数量和自动禁用计时器</string>
|
||||
<string name="chat_settings_preview">预览</string>
|
||||
<string name="chat_settings_preview">仅使用 API 的聊天示例</string>
|
||||
<string name="chat_settings_no_system_message">(不会发送系统消息)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">解析工具标注</string>
|
||||
<string name="chat_settings_experimental">实验性</string>
|
||||
@@ -3295,10 +3300,10 @@
|
||||
<string name="injected_context_phone_status_title">手机状态</string>
|
||||
<string name="injected_context_relay_not_set">未配置 Relay</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">随下一条消息发送的上下文</string>
|
||||
<string name="injected_context_subtitle">此聊天的预览。服务器端上下文单独配置。</string>
|
||||
<string name="injected_context_title">注入上下文</string>
|
||||
<string name="injected_context_turn_not_set">未设置</string>
|
||||
<string name="injected_context_turn_title">轮换检测模式</string>
|
||||
<string name="injected_context_turn_title">本轮上下文</string>
|
||||
<string name="model_picker_cd_clear">清空</string>
|
||||
<string name="model_picker_count">%d 个模型</string>
|
||||
<string name="power_feature_back_desc">返回</string>
|
||||
@@ -4521,4 +4526,12 @@
|
||||
<string name="chat_activity_history_notice">已记录的活动。进度更新不会保留;可用的子代理历史记录可以以只读方式打开。</string>
|
||||
<string name="chat_activity_output_unavailable">输出已不可用。此条目保留了记录的进程状态。</string>
|
||||
<string name="profile_follow_server_default">跟随服务器默认配置</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">在其他应用上使用语音</string>
|
||||
<string name="voice_overlay_setup_body">在其他应用上保持语音控件可见。音频会发送到你配置的 Hermes 服务器。这不会授权读取屏幕或控制手机。请在 Android 设置中允许显示在其他应用上层,然后返回此处。必须启用带有停止操作的麦克风通知。关闭悬浮窗或锁定屏幕会结束语音会话。</string>
|
||||
<string name="voice_overlay_setup_start">启动语音悬浮窗</string>
|
||||
<string name="voice_overlay_settings_hint">两个版本均可选择使用。在聊天的语音专注模式中选择悬浮窗。仅授予权限不会开始监听。</string>
|
||||
<string name="voice_overlay_reset_position">重置位置</string>
|
||||
<string name="injected_context_gateway_unsupported">Gateway 聊天不会发送此内容。此连接不支持每轮附加上下文。</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Frage %1$d von %2$d</string>
|
||||
<string name="clarify_batch_complete">Alle Fragen beantwortet</string>
|
||||
<string name="clarify_batch_expired">Diese Anfrage ist beendet</string>
|
||||
<string name="clarify_batch_sending">Antwort wird gesendet…</string>
|
||||
<string name="clarify_batch_answered">Beantwortete Fragen (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">Agentenoberfläche</string>
|
||||
@@ -799,7 +804,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Chat-Benachrichtigungen im Hintergrund</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Benachrichtigen, wenn Hermes Eingaben benötigt oder im Hintergrund fertig wird</string>
|
||||
<string name="chat_settings_share_phone_status">Smartphone-Status mit Agent teilen</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Bei jedem Chatdurchlauf eine kurze Systemnachricht über App und Smartphone einfügen</string>
|
||||
<string name="chat_settings_share_phone_status_desc">App- und Smartphone-Kontext in reinen API-Chats mitsenden. Wird in Standard-Gateway-Chats nicht gesendet.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + Berechtigungen</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Ob Bedienungshilfe, Bildschirmaufnahme, Overlay und Benachrichtigungen erlaubt sind</string>
|
||||
<string name="chat_settings_foreground_app">App im Vordergrund</string>
|
||||
@@ -808,7 +813,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Aktueller Akkustand in Prozent. Standardmäßig aus.</string>
|
||||
<string name="chat_settings_safety_rails">Schutzmechanismen</string>
|
||||
<string name="chat_settings_safety_rails_desc">Anzahl gesperrter Einträge und destruktiver Verben sowie Timer zur automatischen Deaktivierung</string>
|
||||
<string name="chat_settings_preview">Vorschau</string>
|
||||
<string name="chat_settings_preview">Beispiel für einen reinen API-Chat</string>
|
||||
<string name="chat_settings_no_system_message">(es wird keine Systemnachricht gesendet)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Werkzeughinweise auswerten</string>
|
||||
<string name="chat_settings_experimental">Experimentell</string>
|
||||
@@ -3364,10 +3369,10 @@
|
||||
<string name="injected_context_phone_status_title">Smartphone-Status</string>
|
||||
<string name="injected_context_relay_not_set">Kein Relay konfiguriert</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Kontext, der mit der nächsten Nachricht gesendet wird</string>
|
||||
<string name="injected_context_subtitle">Vorschau für diesen Chat. Serverseitiger Kontext wird separat konfiguriert.</string>
|
||||
<string name="injected_context_title">Eingefügter Kontext</string>
|
||||
<string name="injected_context_turn_not_set">Nicht festgelegt</string>
|
||||
<string name="injected_context_turn_title">Modus zur Durchlauferkennung</string>
|
||||
<string name="injected_context_turn_title">Nachrichtenkontext</string>
|
||||
<string name="model_picker_cd_clear">Leeren</string>
|
||||
<string name="model_picker_count">%d Modelle</string>
|
||||
<string name="power_feature_back_desc">Zurück</string>
|
||||
@@ -4597,4 +4602,12 @@
|
||||
<string name="chat_activity_history_notice">Gespeicherte Aktivität. Fortschrittsmeldungen werden nicht gespeichert; der verfügbare Verlauf der Unteragenten kann schreibgeschützt geöffnet werden.</string>
|
||||
<string name="chat_activity_output_unavailable">Die Ausgabe ist nicht mehr verfügbar. Dieser Eintrag enthält den gespeicherten Prozessstatus.</string>
|
||||
<string name="profile_follow_server_default">Serverstandard folgen</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Sprache über anderen Apps</string>
|
||||
<string name="voice_overlay_setup_body">Sprachsteuerung bleibt über anderen Apps sichtbar. Audio geht an deinen konfigurierten Hermes-Server. Dies erlaubt weder Bildschirmlesen noch Telefonsteuerung. Erlaube die Anzeige über anderen Apps in den Android-Einstellungen und kehre zurück. Eine Mikrofonbenachrichtigung mit Stopp-Aktion ist erforderlich. Schließen des Overlays oder Sperren des Bildschirms beendet die Sprachsitzung.</string>
|
||||
<string name="voice_overlay_setup_start">Sprach-Overlay starten</string>
|
||||
<string name="voice_overlay_settings_hint">In beiden Versionen optional. Öffne den Sprachfokus im Chat und wähle Overlay. Berechtigungen allein starten kein Zuhören.</string>
|
||||
<string name="voice_overlay_reset_position">Position zurücksetzen</string>
|
||||
<string name="injected_context_gateway_unsupported">Wird im Gateway-Chat nicht gesendet. Diese Verbindung unterstützt keinen zusätzlichen Kontext pro Nachricht.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Pregunta %1$d de %2$d</string>
|
||||
<string name="clarify_batch_complete">Todas las preguntas respondidas</string>
|
||||
<string name="clarify_batch_expired">Esta solicitud ha finalizado</string>
|
||||
<string name="clarify_batch_sending">Enviando respuesta…</string>
|
||||
<string name="clarify_batch_answered">Preguntas respondidas (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">interfaz del agente</string>
|
||||
@@ -724,7 +729,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Alertas de chat en segundo plano</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notificar cuando Hermes necesite información o termine en segundo plano</string>
|
||||
<string name="chat_settings_share_phone_status">Compartir el estado del teléfono con el agente</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluya un breve mensaje del sistema sobre la aplicación y el teléfono en cada turno de chat.</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Incluir contexto de la app y el teléfono en chats solo por API. No se envía en los chats estándar de Gateway.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permisos</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Si se concede accesibilidad, captura de pantalla, superposición o notificaciones</string>
|
||||
<string name="chat_settings_foreground_app">Aplicación en primer plano</string>
|
||||
@@ -733,7 +738,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Porcentaje de batería actual. Desactivado de forma predeterminada.</string>
|
||||
<string name="chat_settings_safety_rails">Barandillas de seguridad</string>
|
||||
<string name="chat_settings_safety_rails_desc">Recuento de listas de bloqueo, recuento de verbos destructivos y temporizador de desactivación automática</string>
|
||||
<string name="chat_settings_preview">Avance</string>
|
||||
<string name="chat_settings_preview">Ejemplo de chat solo por API</string>
|
||||
<string name="chat_settings_no_system_message">(no se enviará ningún mensaje del sistema)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Anotaciones de la herramienta de análisis</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -3028,10 +3033,10 @@
|
||||
<string name="injected_context_phone_status_title">Estado del teléfono</string>
|
||||
<string name="injected_context_relay_not_set">Ningún relay configurado</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Contexto enviado con el siguiente mensaje.</string>
|
||||
<string name="injected_context_subtitle">Vista previa de este chat. El contexto del servidor se configura por separado.</string>
|
||||
<string name="injected_context_title">Contexto inyectado</string>
|
||||
<string name="injected_context_turn_not_set">No establecido</string>
|
||||
<string name="injected_context_turn_title">Modo de detección de giro</string>
|
||||
<string name="injected_context_turn_title">Contexto del turno</string>
|
||||
<string name="model_picker_cd_clear">Borrar</string>
|
||||
<string name="model_picker_count">Modelos %d</string>
|
||||
<string name="power_feature_back_desc">Atrás</string>
|
||||
@@ -4288,4 +4293,12 @@
|
||||
<string name="chat_activity_history_notice">Actividad registrada. No se conservan las actualizaciones de progreso; el historial disponible de los subagentes se puede abrir en modo de solo lectura.</string>
|
||||
<string name="chat_activity_output_unavailable">La salida ya no está disponible. Esta entrada conserva el estado registrado del proceso.</string>
|
||||
<string name="profile_follow_server_default">Seguir el perfil predeterminado del servidor</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voz sobre otras apps</string>
|
||||
<string name="voice_overlay_setup_body">Mantén los controles de voz visibles sobre otra app. El audio se envía a tu servidor Hermes configurado. Esto no permite leer la pantalla ni controlar el teléfono. Permite mostrar sobre otras apps en los ajustes de Android y vuelve aquí. Se requiere una notificación del micrófono con Detener voz. Cerrar la ventana o bloquear la pantalla termina la sesión de voz.</string>
|
||||
<string name="voice_overlay_setup_start">Iniciar ventana de voz</string>
|
||||
<string name="voice_overlay_settings_hint">Opcional en ambas versiones. Abre el enfoque de voz en Chat y elige Superposición. Los permisos por sí solos nunca inician la escucha.</string>
|
||||
<string name="voice_overlay_reset_position">Restablecer posición</string>
|
||||
<string name="injected_context_gateway_unsupported">No se envía en el chat de Gateway. Esta conexión no admite contexto adicional por turno.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">質問 %1$d / %2$d</string>
|
||||
<string name="clarify_batch_complete">すべての質問に回答しました</string>
|
||||
<string name="clarify_batch_expired">このリクエストは終了しました</string>
|
||||
<string name="clarify_batch_sending">回答を送信中…</string>
|
||||
<string name="clarify_batch_answered">回答済みの質問(%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">エージェントインターフェース</string>
|
||||
@@ -796,7 +801,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">バックグラウンドのチャット通知</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">バックグラウンドで Hermes が入力を必要としたとき、または完了したときに通知します</string>
|
||||
<string name="chat_settings_share_phone_status">電話のステータスをエージェントと共有する</string>
|
||||
<string name="chat_settings_share_phone_status_desc">すべてのチャット ターンにアプリと電話に関する短いシステム メッセージを含めます</string>
|
||||
<string name="chat_settings_share_phone_status_desc">API のみのチャットにアプリと端末のコンテキストを含めます。標準の Gateway チャットでは送信されません。</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + 権限</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">アクセシビリティ、画面キャプチャ、オーバーレイ、通知が許可されているかどうか</string>
|
||||
<string name="chat_settings_foreground_app">フォアグラウンドアプリ</string>
|
||||
@@ -805,7 +810,7 @@
|
||||
<string name="chat_settings_battery_level_desc">現在のバッテリーのパーセント。デフォルトではオフです。</string>
|
||||
<string name="chat_settings_safety_rails">安全レール</string>
|
||||
<string name="chat_settings_safety_rails_desc">ブロックリストの数、破壊的な動詞の数、および自動無効化タイマー</string>
|
||||
<string name="chat_settings_preview">プレビュー</string>
|
||||
<string name="chat_settings_preview">API のみのチャットの例</string>
|
||||
<string name="chat_settings_no_system_message">(システムメッセージは送信されません)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">解析ツールの注釈</string>
|
||||
<string name="chat_settings_experimental">実験的</string>
|
||||
@@ -3371,10 +3376,10 @@
|
||||
<string name="injected_context_phone_status_title">電話のステータス</string>
|
||||
<string name="injected_context_relay_not_set">Relayが設定されていません</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">次のメッセージで送信されるコンテキスト</string>
|
||||
<string name="injected_context_subtitle">このチャットのプレビューです。サーバー側のコンテキストは別途設定されます。</string>
|
||||
<string name="injected_context_title">挿入されたコンテキスト</string>
|
||||
<string name="injected_context_turn_not_set">未設定</string>
|
||||
<string name="injected_context_turn_title">回転検出モード</string>
|
||||
<string name="injected_context_turn_title">ターンのコンテキスト</string>
|
||||
<string name="model_picker_cd_clear">クリア</string>
|
||||
<string name="model_picker_count">%d モデル</string>
|
||||
<string name="power_feature_back_desc">戻る</string>
|
||||
@@ -4592,4 +4597,12 @@
|
||||
<string name="chat_activity_history_notice">記録されたアクティビティです。進捗の更新は保存されません。利用可能なサブエージェントの履歴は読み取り専用で開けます。</string>
|
||||
<string name="chat_activity_output_unavailable">出力は利用できなくなりました。この項目には記録されたプロセスの状態が残っています。</string>
|
||||
<string name="profile_follow_server_default">サーバーのデフォルトに従う</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">他のアプリ上で音声を使用</string>
|
||||
<string name="voice_overlay_setup_body">他のアプリ上に音声操作を表示します。音声は設定したHermesサーバーに送信されます。画面の読み取りや端末操作は許可されません。Androidの設定で他のアプリ上への表示を許可し、ここに戻ってください。停止操作付きのマイク通知が必要です。表示を閉じるか画面をロックすると音声セッションは終了します。</string>
|
||||
<string name="voice_overlay_setup_start">音声オーバーレイを開始</string>
|
||||
<string name="voice_overlay_settings_hint">両方のビルドで任意に利用できます。チャットの音声フォーカスでオーバーレイを選択します。権限の付与だけで録音は始まりません。</string>
|
||||
<string name="voice_overlay_reset_position">位置をリセット</string>
|
||||
<string name="injected_context_gateway_unsupported">Gateway チャットでは送信されません。この接続はターンごとの追加コンテキストに対応していません。</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Вопрос %1$d из %2$d</string>
|
||||
<string name="clarify_batch_complete">Все вопросы отвечены</string>
|
||||
<string name="clarify_batch_expired">Этот запрос завершён</string>
|
||||
<string name="clarify_batch_sending">Отправка ответа…</string>
|
||||
<string name="clarify_batch_answered">Отвеченные вопросы (%1$d)</string>
|
||||
<string name="app_name">Hermes-Relay</string>
|
||||
<string name="app_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">интерфейс агента</string>
|
||||
@@ -773,7 +778,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Уведомления о фоновом чате</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Уведомлять, когда Гермес требует ввода или заканчивает работу, когда приложение находится в фоновом режиме</string>
|
||||
<string name="chat_settings_share_phone_status">Поделиться статусом телефона с агентом</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Включите короткое системное сообщение о приложении и телефоне в каждом обороте чата</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Добавлять контекст приложения и телефона в чаты только через API. В стандартных чатах Gateway он не отправляется.</string>
|
||||
<string name="chat_settings_bridge_permissions">Мост + разрешения</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Разрешения на доступность, захват экрана, наложение, уведомления</string>
|
||||
<string name="chat_settings_foreground_app">Приложение на переднем плане</string>
|
||||
@@ -782,7 +787,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Текущий процент заряда батареи. По умолчанию выключено.</string>
|
||||
<string name="chat_settings_safety_rails">Ограждения безопасности</string>
|
||||
<string name="chat_settings_safety_rails_desc">Количество в черном списке, количество деструктивных глаголов и таймер автоматического отключения</string>
|
||||
<string name="chat_settings_preview">Предпросмотр</string>
|
||||
<string name="chat_settings_preview">Пример чата только через API</string>
|
||||
<string name="chat_settings_no_system_message">(системное сообщение не будет отправлено)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Анализ аннотаций инструментов</string>
|
||||
<string name="chat_settings_experimental">Экспериментальные</string>
|
||||
@@ -3234,10 +3239,10 @@
|
||||
<string name="injected_context_phone_status_title">Статус телефона</string>
|
||||
<string name="injected_context_relay_not_set">плагин Relay не настроен</string>
|
||||
<string name="injected_context_relay_title">плагин Relay</string>
|
||||
<string name="injected_context_subtitle">Контекст отправляется с следующим сообщением</string>
|
||||
<string name="injected_context_subtitle">Предпросмотр для этого чата. Контекст на стороне сервера настраивается отдельно.</string>
|
||||
<string name="injected_context_title">Внедренный контекст</string>
|
||||
<string name="injected_context_turn_not_set">Не установлено</string>
|
||||
<string name="injected_context_turn_title">Режим обнаружения Turn</string>
|
||||
<string name="injected_context_turn_title">Контекст хода</string>
|
||||
<string name="model_picker_cd_clear">Очистить</string>
|
||||
<string name="model_picker_count">%d моделей</string>
|
||||
<string name="power_feature_back_desc">Назад</string>
|
||||
@@ -4336,4 +4341,12 @@
|
||||
<string name="chat_activity_history_notice">Сохранённая активность. Обновления хода работы не сохраняются; доступную историю субагентов можно открыть только для чтения.</string>
|
||||
<string name="chat_activity_output_unavailable">Вывод больше недоступен. Эта запись сохраняет зафиксированное состояние процесса.</string>
|
||||
<string name="profile_follow_server_default">Следовать профилю сервера по умолчанию</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Голос поверх других приложений</string>
|
||||
<string name="voice_overlay_setup_body">Голосовые элементы управления видны поверх другого приложения. Аудио отправляется на настроенный сервер Hermes. Это не разрешает чтение экрана или управление телефоном. Разрешите показ поверх приложений в настройках Android и вернитесь сюда. Требуется уведомление микрофона с кнопкой остановки. Закрытие окна или блокировка экрана завершает голосовой сеанс.</string>
|
||||
<string name="voice_overlay_setup_start">Начать голосовой оверлей</string>
|
||||
<string name="voice_overlay_settings_hint">Необязательно в обеих сборках. Откройте голосовой фокус в чате и выберите оверлей. Одни разрешения никогда не включают прослушивание.</string>
|
||||
<string name="voice_overlay_reset_position">Сбросить положение</string>
|
||||
<string name="injected_context_gateway_unsupported">Не отправляется в чате Gateway. Это подключение не поддерживает дополнительный контекст для каждого хода.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="clarify_batch_progress">Question %1$d of %2$d</string>
|
||||
<string name="clarify_batch_complete">All questions answered</string>
|
||||
<string name="clarify_batch_expired">This request has ended</string>
|
||||
<string name="clarify_batch_sending">Sending answer…</string>
|
||||
<string name="clarify_batch_answered">Answered questions (%1$d)</string>
|
||||
<string name="chat_debug_title" translatable="false">Session diagnostics</string>
|
||||
<string name="chat_debug_open" translatable="false">Open session diagnostics</string>
|
||||
<string name="chat_debug_close" translatable="false">Close session diagnostics</string>
|
||||
@@ -882,7 +887,7 @@
|
||||
<string name="chat_settings_notify_when_finishes">Background chat alerts</string>
|
||||
<string name="chat_settings_notify_when_finishes_desc">Notify when Hermes needs input or finishes while the app is in the background</string>
|
||||
<string name="chat_settings_share_phone_status">Share phone status with agent</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Include a short system message about the app and phone on every chat turn</string>
|
||||
<string name="chat_settings_share_phone_status_desc">Include app and phone context in API-only chats. Not sent in standard Gateway chats.</string>
|
||||
<string name="chat_settings_bridge_permissions">Bridge + permissions</string>
|
||||
<string name="chat_settings_bridge_permissions_desc">Whether accessibility, screen capture, overlay, notifications are granted</string>
|
||||
<string name="chat_settings_foreground_app">Foreground app</string>
|
||||
@@ -891,7 +896,7 @@
|
||||
<string name="chat_settings_battery_level_desc">Current battery percent. Off by default.</string>
|
||||
<string name="chat_settings_safety_rails">Safety rails</string>
|
||||
<string name="chat_settings_safety_rails_desc">Blocklist count, destructive-verb count, and auto-disable timer</string>
|
||||
<string name="chat_settings_preview">Preview</string>
|
||||
<string name="chat_settings_preview">Example for API-only chat</string>
|
||||
<string name="chat_settings_no_system_message">(no system message will be sent)</string>
|
||||
<string name="chat_settings_parse_tool_annotations">Parse tool annotations</string>
|
||||
<string name="chat_settings_experimental">Experimental</string>
|
||||
@@ -3764,10 +3769,10 @@
|
||||
<string name="injected_context_phone_status_title">Phone status</string>
|
||||
<string name="injected_context_relay_not_set">No relay configured</string>
|
||||
<string name="injected_context_relay_title">Relay</string>
|
||||
<string name="injected_context_subtitle">Context sent with next message</string>
|
||||
<string name="injected_context_subtitle">Preview for this chat. Server-side context is configured separately.</string>
|
||||
<string name="injected_context_title">Injected context</string>
|
||||
<string name="injected_context_turn_not_set">Not set</string>
|
||||
<string name="injected_context_turn_title">Turn detection mode</string>
|
||||
<string name="injected_context_turn_title">Turn context</string>
|
||||
<string name="model_picker_cd_clear">Clear</string>
|
||||
<string name="model_picker_count">%d models</string>
|
||||
<string name="power_feature_back_desc">Back</string>
|
||||
@@ -4655,4 +4660,12 @@
|
||||
<string name="dev_message_clear" translatable="false">Clear previews</string>
|
||||
<string name="dev_message_clear_desc" translatable="false">Dismiss only these samples; real app messages are preserved.</string>
|
||||
<string name="profile_follow_server_default">Follow server default</string>
|
||||
|
||||
<!-- User-started voice overlay, independent of Device Control. -->
|
||||
<string name="voice_overlay_setup_title">Voice over other apps</string>
|
||||
<string name="voice_overlay_setup_body">Keep voice controls visible while using another app. Audio goes to your configured Hermes server. This does not grant screen reading or phone control. Allow display over other apps in Android Settings, then return here. A microphone notification with Stop voice is required. Closing the overlay or locking the screen ends this voice session.</string>
|
||||
<string name="voice_overlay_setup_start">Start voice overlay</string>
|
||||
<string name="voice_overlay_settings_hint">Optional in both builds. Open Voice Focus in Chat and choose Overlay to start. Permissions alone never start listening.</string>
|
||||
<string name="voice_overlay_reset_position">Reset position</string>
|
||||
<string name="injected_context_gateway_unsupported">Not sent in Gateway chat. This connection does not support extra per-turn context.</string>
|
||||
</resources>
|
||||
|
||||
@@ -17,11 +17,10 @@
|
||||
<!--
|
||||
Tier C (C1-C4) sideload-only permissions.
|
||||
|
||||
These are deliberately NOT declared in the main manifest: Google Play
|
||||
policy forbids CALL_PHONE / SEND_SMS / READ_CONTACTS without a default-
|
||||
dialer / default-SMS-app justification, and ACCESS_FINE_LOCATION is
|
||||
flagged alongside auto-dial on the `googlePlay` track. The `sideload`
|
||||
flavor is the only track that ships them.
|
||||
These are deliberately kept outside Play's voice-only capability surface.
|
||||
SMS permissions require an eligible default-handler/approved use on Play;
|
||||
contacts, foreground location and CALL_PHONE have distinct requirements.
|
||||
Their omission here is a product boundary, not a blanket permission ban.
|
||||
|
||||
Each of the four `android_*` tools (location, search_contacts, call,
|
||||
send_sms) runtime-checks the corresponding permission in
|
||||
|
||||
@@ -56,6 +56,21 @@ class ChatTurnCheckpointStoreTest {
|
||||
assertEquals(checkpoint, store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialClarifyBatch_roundTripsWithExactQuestionOwnership() = runTest {
|
||||
val checkpoint = sampleCheckpoint().copy(pendingAsk = ChatTurnAskCheckpoint(
|
||||
kind = "CLARIFY", requestId = "batch", text = "", timeoutSeconds = 0,
|
||||
messageId = "ask-batch", cardKey = "batch", receivedAt = now,
|
||||
questions = listOf(
|
||||
com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion("route/a", "Which route?", listOf("Canary")),
|
||||
com.hermesandroid.relay.network.upstream.GatewayClarifyQuestion("environment:b", "Which environments?", listOf("Stage", "Production"), true),
|
||||
), answers = mapOf("route/a" to "Canary"),
|
||||
))
|
||||
store.write(checkpoint)
|
||||
assertEquals(checkpoint, store.read())
|
||||
assertEquals(mapOf("route/a" to "Canary"), store.read()?.pendingAsk?.answers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptJson_isDiscarded() = runTest {
|
||||
dataStore.edit { preferences ->
|
||||
|
||||
@@ -74,6 +74,8 @@ class GatewayClientHarness(
|
||||
|
||||
@Volatile
|
||||
var recoveryRunning = false
|
||||
@Volatile
|
||||
var recoveryClarify: JsonObject? = null
|
||||
|
||||
@Volatile
|
||||
var recoveryAssistant = ""
|
||||
@@ -586,6 +588,7 @@ class GatewayClientHarness(
|
||||
private val autoRespondEnabled = autoRespond
|
||||
|
||||
private fun recoveryPayload(sessionId: String, requestedProfile: String? = null): JsonObject = buildJsonObject {
|
||||
recoveryClarify?.let { put("pending_clarify", it) }
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
@@ -3165,6 +3168,75 @@ class GatewayChatClientTest {
|
||||
|
||||
// --- Ask responders ---
|
||||
|
||||
@Test
|
||||
fun `batch clarify reconnect adopts server answered qids without resubmitting`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
val socket = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
harness.recoveryRunning = true
|
||||
harness.recoveryClarify = harness.json.parseToJsonElement("""{
|
||||
"request_id":"batch","questions":[{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}],
|
||||
"answers":{"q0":"accepted before disconnect"}}
|
||||
""") as JsonObject
|
||||
socket.close(1001, "fixture reconnect")
|
||||
val replacement = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.activate")
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (r.interactions.isEmpty() && System.nanoTime() < deadline) Thread.sleep(10)
|
||||
assertEquals(mapOf("q0" to "accepted before disconnect"), r.interactions.last().answers)
|
||||
assertTrue(harness.rpcLog.none { it.first == "clarify.respond" })
|
||||
assertTrue(runBlocking { client.respondClarify("batch", "second", "q1") }.isSuccess)
|
||||
replacement.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"))
|
||||
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch clarify responds with exact question id and JSON array answer`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
val socket = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
socket.send(harness.eventFrame("clarify.request", harness.json.parseToJsonElement("""
|
||||
{"request_id":"batch","questions":[{"qid":"env:b","question":"Which environments?","choices":["Stage","Production"],"multi_select":true}]}
|
||||
""") as JsonObject, "live-1"))
|
||||
awaitCondition { r.interactions.isNotEmpty() }
|
||||
val answer = "[\"Stage\",\"Production\"]"
|
||||
assertTrue(runBlocking { client.respondClarify("batch", answer, "env:b") }.isSuccess)
|
||||
val respond = harness.awaitRpc("clarify.respond")
|
||||
assertEquals("batch", (respond["request_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("env:b", (respond["question_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(answer, (respond["answer"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch answer waits for reconnect replay and refuses an already answered qid`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
val socket = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
val request = harness.json.parseToJsonElement("""{"request_id":"batch","questions":[
|
||||
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}]}""") as JsonObject
|
||||
socket.send(harness.eventFrame("clarify.request", request, "live-1"))
|
||||
awaitCondition { r.interactions.isNotEmpty() }
|
||||
harness.recoveryRunning = true
|
||||
harness.suppressAckMethods += "session.activate"
|
||||
socket.close(1001, "fixture lost acknowledgement")
|
||||
harness.awaitServerSocket()
|
||||
val activation = harness.awaitPendingAck()
|
||||
assertEquals("session.activate", activation.method)
|
||||
assertTrue(runBlocking { client.respondClarify("batch", "retry", "q0") }.isFailure)
|
||||
assertTrue(harness.rpcLog.none { it.first == "clarify.respond" })
|
||||
harness.releaseAck(activation, buildJsonObject {
|
||||
put("session_id", "live-1")
|
||||
put("running", true)
|
||||
put("pending_clarify", JsonObject(request + ("answers" to buildJsonObject { put("q0", "accepted") })))
|
||||
})
|
||||
awaitCondition { r.interactions.last().answers["q0"] == "accepted" }
|
||||
assertTrue(runBlocking { client.respondClarify("batch", "overwrite", "q0") }.isFailure)
|
||||
assertTrue(harness.rpcLog.none { it.first == "clarify.respond" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clarify respond carries request id and answer`() {
|
||||
val r = Recorder()
|
||||
|
||||
@@ -16,6 +16,103 @@ import org.junit.Test
|
||||
*/
|
||||
class GatewayEventMapperTest {
|
||||
|
||||
@Test
|
||||
fun `acknowledgement before reclaim remains in the shared request snapshot`() {
|
||||
val original = mapperWith(Recorder())
|
||||
original.onEvent("clarify.request", obj("""{"request_id":"batch","questions":[
|
||||
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}]}"""))
|
||||
val detachedSnapshot = requireNotNull(original.currentInteraction)
|
||||
original.acknowledgeClarify("batch", "q0", "first", false)
|
||||
val recovered = mapperWith(Recorder())
|
||||
recovered.restoreInteraction(detachedSnapshot)
|
||||
assertEquals(mapOf("q0" to "first"), recovered.currentInteraction?.answers)
|
||||
recovered.onEvent("message.complete", obj("""{"text":"done"}"""))
|
||||
recovered.acknowledgeClarify("batch", "q1", "second", false)
|
||||
assertTrue(recovered.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired detached snapshot cannot become pending again`() {
|
||||
val original = mapperWith(Recorder())
|
||||
original.onEvent("clarify.request", obj("""{"request_id":"batch","questions":[{"qid":"q0","question":"First?"}]}"""))
|
||||
val snapshot = requireNotNull(original.currentInteraction)
|
||||
original.acknowledgeClarify("batch", "q0", "ignored", true)
|
||||
val r = Recorder()
|
||||
val recovered = mapperWith(r)
|
||||
recovered.restoreInteraction(snapshot)
|
||||
assertNull(recovered.currentInteraction)
|
||||
assertTrue(r.interactions.isEmpty())
|
||||
assertEquals("batch", r.interactionExpiries.single().requestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forwarded acknowledgement cannot touch a newer request incarnation`() {
|
||||
val mapper = mapperWith(Recorder())
|
||||
val request = obj("""{"request_id":"batch","questions":[{"qid":"q0","question":"First?"}]}""")
|
||||
mapper.onEvent("clarify.request", request)
|
||||
val oldOwner = requireNotNull(mapper.currentInteraction).ownershipToken
|
||||
mapper.onEvent("clarify.expire", obj("""{"request_id":"batch"}"""))
|
||||
mapper.onEvent("clarify.request", request)
|
||||
mapper.acknowledgeClarifyOwner("batch", "q0", "stale", true, oldOwner)
|
||||
assertTrue(requireNotNull(mapper.currentInteraction).answers.isEmpty())
|
||||
assertFalse(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial acknowledgement holds terminal and merges replay without resurrecting an answer`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
val request = obj("""{"request_id":"batch","questions":[
|
||||
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}]}""")
|
||||
mapper.onEvent("clarify.request", request)
|
||||
mapper.onEvent("message.complete", obj("""{"text":"done"}"""))
|
||||
mapper.acknowledgeClarify("batch", "q0", "first", false)
|
||||
assertFalse(mapper.turnEnded)
|
||||
assertEquals(mapOf("q0" to "first"), mapper.currentInteraction?.answers)
|
||||
mapper.onEvent("clarify.request", request)
|
||||
assertEquals(mapOf("q0" to "first"), mapper.currentInteraction?.answers)
|
||||
mapper.acknowledgeClarify("old-batch", "q1", "stale", true)
|
||||
assertFalse(mapper.turnEnded)
|
||||
mapper.acknowledgeClarify("batch", "q1", "second", false)
|
||||
assertNull(mapper.currentInteraction)
|
||||
assertTrue(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch expiry retires partial progress only for exact request`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("clarify.request", obj("""{"request_id":"batch","questions":[
|
||||
{"qid":"q0","question":"First?"},{"qid":"q1","question":"Second?"}],"answers":{"q0":"done"}}"""))
|
||||
mapper.onEvent("clarify.expire", obj("""{"request_id":"other"}"""))
|
||||
assertEquals("done", mapper.currentInteraction?.answers?.get("q0"))
|
||||
mapper.onEvent("clarify.expire", obj("""{"request_id":"batch"}"""))
|
||||
assertNull(mapper.currentInteraction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `batch clarify preserves exact qids question order choices and replayed answers`() {
|
||||
val ask = GatewayEventMapper.interactionRequest("clarify.request", obj("""
|
||||
{"request_id":"batch","questions":[
|
||||
{"qid":"choice/a","question":"Which deployment?","choices":["Canary","Immediate"],"multi_select":false},
|
||||
{"qid":"env:b","question":"Which environments?","choices":["Stage","Production"],"multi_select":true}
|
||||
],"answers":{"choice/a":"Canary","foreign":"ignored"}}
|
||||
"""))!!
|
||||
assertEquals(listOf("choice/a", "env:b"), ask.questions.map { it.qid })
|
||||
assertEquals("Which deployment?", ask.questions[0].question)
|
||||
assertEquals(listOf("Stage", "Production"), ask.questions[1].choices)
|
||||
assertTrue(ask.questions[1].multiSelect)
|
||||
assertEquals(mapOf("choice/a" to "Canary"), ask.answers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one entry normalized clarify keeps its qid`() {
|
||||
val ask = GatewayEventMapper.interactionRequest("clarify.request", obj("""
|
||||
{"request_id":"one","questions":[{"qid":"q0","question":"Which file?","choices":null}]}
|
||||
"""))!!
|
||||
assertEquals(listOf("q0"), ask.questions.map { it.qid })
|
||||
}
|
||||
|
||||
private class Recorder {
|
||||
val textDeltas = mutableListOf<String>()
|
||||
val interimMessages = mutableListOf<Pair<String, Boolean>>()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.hasScrollAction
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyBatch
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
|
||||
import com.hermesandroid.relay.data.HermesCardInput
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ChatBubblePolishScreenshotTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test @Config(qualifiers = "w360dp-h800dp-xhdpi")
|
||||
fun darkConversation() = capture("dark-conversation", "dark", 1f)
|
||||
|
||||
@Test @Config(qualifiers = "w360dp-h800dp-xhdpi")
|
||||
fun lightConversation() = capture("light-conversation", "light", 1f)
|
||||
|
||||
@Test @Config(qualifiers = "w320dp-h568dp-xhdpi")
|
||||
fun narrowLargeText() = capture("narrow-large-text", "dark", 1.5f)
|
||||
|
||||
@Test @Config(qualifiers = "w720dp-h360dp-xhdpi")
|
||||
fun landscape() = capture("landscape", "dark", 1f)
|
||||
|
||||
@Test @Config(qualifiers = "w360dp-h800dp-xhdpi")
|
||||
fun correctionAndTimeShareOneLine() {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(themePreference = "dark") {
|
||||
MessageBubble(correction(), animationEnabled = false)
|
||||
}
|
||||
}
|
||||
val time = SimpleDateFormat("h:mm a", Locale.US).format(Date(TIMESTAMP))
|
||||
val timeBounds = compose.onNodeWithText(time, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val status = compose.onNodeWithText("Correction sent", useUnmergedTree = true)
|
||||
val statusBounds = status.fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("Time and correction should share a footer row", kotlin.math.abs(timeBounds.center.y - statusBounds.center.y) <= 2f)
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
status.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
|
||||
assertEquals(1, layouts.single().lineCount)
|
||||
}
|
||||
|
||||
private fun capture(name: String, theme: String, scale: Float) {
|
||||
val card = HermesCard(
|
||||
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
|
||||
title = "Hermes needs clarification",
|
||||
id = "clarify",
|
||||
clarifyBatch = HermesCardClarifyBatch(listOf(
|
||||
HermesCardClarifyQuestion("q0", "Which accent color?", HermesCardInput(HermesCardInput.Kinds.TEXT), "Orange"),
|
||||
HermesCardClarifyQuestion("q1", "Which sections?", HermesCardInput(HermesCardInput.Kinds.CHOICE, multiSelect = true), "[\"Calendar\",\"Tasks\"]"),
|
||||
)),
|
||||
)
|
||||
val messages = listOf(
|
||||
ChatMessage(id = "ask", role = MessageRole.ASSISTANT, content = "", timestamp = TIMESTAMP, cards = listOf(card), clientOnly = true),
|
||||
ChatMessage(id = "reply", role = MessageRole.ASSISTANT, content = "Got both responses in one batch:\n\n- Accent: **Orange**\n- Sections: **Calendar and Tasks**", timestamp = TIMESTAMP),
|
||||
correction(),
|
||||
)
|
||||
compose.setContent {
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, scale)) {
|
||||
HermesRelayTheme(themePreference = theme) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(messages.size, key = { messages[it].id }) { index ->
|
||||
MessageBubble(messages[index], showAgentIdentity = false, isFirstInGroup = index == 0 || index == 2,
|
||||
isLastInGroup = index != 0, animationEnabled = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val directory = File("build/ui-evidence/bubble-polish").apply { mkdirs() }
|
||||
compose.onRoot().captureRoboImage(File(directory, "$name-top.png").path)
|
||||
repeat(3) { compose.onNode(hasScrollAction()).performTouchInput { swipeUp() } }
|
||||
compose.onRoot().captureRoboImage(File(directory, "$name-bottom.png").path)
|
||||
}
|
||||
|
||||
private fun correction() = ChatMessage(id = "correction", role = MessageRole.USER, content = "Test", timestamp = TIMESTAMP,
|
||||
deliveryStatus = MessageDeliveryStatus.STEERED)
|
||||
|
||||
companion object { private const val TIMESTAMP = 1_700_000_000_000L }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.hasScrollAction
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyBatch
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
|
||||
import com.hermesandroid.relay.data.HermesCardInput
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import java.io.File
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class ClarifyBatchScreenshotTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test @Config(qualifiers = "w320dp-h568dp-xhdpi")
|
||||
fun compactDark() = capture("compact-dark")
|
||||
|
||||
@Test @Config(qualifiers = "w320dp-h568dp-xhdpi")
|
||||
fun compactLightLargeText() = capture("compact-light-font-1_5", theme = "light", scale = 1.5f)
|
||||
|
||||
@Test @Config(qualifiers = "w720dp-h360dp-xhdpi")
|
||||
fun landscape() = capture("landscape", width = 540)
|
||||
|
||||
@Test @Config(qualifiers = "w330dp-h720dp-xhdpi")
|
||||
fun narrowFoldable() = capture("foldable-pane", scale = 1.5f)
|
||||
|
||||
@Test @Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
fun partialProgress() = capture("partial", answered = 1)
|
||||
|
||||
@Test @Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
fun sending() = capture("sending", answered = 1, submitting = true)
|
||||
|
||||
@Test @Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
fun completed() = capture("completed", answered = 2)
|
||||
|
||||
@Test @Config(qualifiers = "w320dp-h568dp-xhdpi")
|
||||
fun expiredPartial() = capture("expired-partial", answered = 1, expired = true)
|
||||
|
||||
private fun capture(
|
||||
name: String, theme: String = "dark", scale: Float = 1f, width: Int = 300,
|
||||
answered: Int = 0, submitting: Boolean = false, expired: Boolean = false,
|
||||
) {
|
||||
val options = listOf(
|
||||
"Keep both systems running while traffic moves in measured stages (Recommended)",
|
||||
"Migrate everything immediately and accept a short maintenance window",
|
||||
"Pause until every downstream consumer has been verified",
|
||||
"Use a reversible canary rollout with automatic rollback thresholds",
|
||||
)
|
||||
val questions = listOf(
|
||||
HermesCardClarifyQuestion("q0", "Which deployment approach should I use for the migration, given the existing clients and the rollback requirements?",
|
||||
HermesCardInput(HermesCardInput.Kinds.CHOICE, options, allowFreeText = true),
|
||||
answer = options[0].takeIf { answered > 0 }),
|
||||
HermesCardClarifyQuestion("q1", "Which environments should receive this change?",
|
||||
HermesCardInput(HermesCardInput.Kinds.CHOICE, listOf("Staging", "Production"), multiSelect = true, allowFreeText = true),
|
||||
answer = "[\"Staging\",\"Production\"]".takeIf { answered > 1 }, submitting = submitting),
|
||||
)
|
||||
val card = HermesCard(type = HermesCard.BuiltInTypes.ASK_CLARIFY, title = "Hermes needs clarification",
|
||||
id = "batch", clarifyBatch = HermesCardClarifyBatch(questions, expiresAtMillis = if (expired) 1L else null))
|
||||
compose.setContent {
|
||||
val density = LocalDensity.current
|
||||
CompositionLocalProvider(LocalDensity provides Density(density.density, scale)) {
|
||||
HermesRelayTheme(themePreference = theme) {
|
||||
LazyColumn(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(10.dp)) {
|
||||
item {
|
||||
MessageBubble(
|
||||
message = ChatMessage(id = "batch-message", role = MessageRole.ASSISTANT, content = "",
|
||||
timestamp = 0L, cards = listOf(card), clientOnly = true),
|
||||
maxBubbleWidth = width.dp, showTimestamps = false, animationEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val directory = File("build/ui-evidence/clarify-batch").apply { mkdirs() }
|
||||
compose.onRoot().captureRoboImage(File(directory, "$name-top.png").path)
|
||||
repeat(4) { compose.onNode(hasScrollAction()).performTouchInput { swipeUp() } }
|
||||
compose.onRoot().captureRoboImage(File(directory, "$name-bottom.png").path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.ui.components.InjectedContextSheet
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class InjectedContextSheetTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun gatewayPreviewLabelsUnsupportedContextWithoutShowingPhonePreamble() {
|
||||
showPreview("gateway")
|
||||
compose.onAllNodesWithText(
|
||||
"Not sent in Gateway chat. This connection does not support extra per-turn context.",
|
||||
).assertCountEquals(2)
|
||||
compose.onNodeWithText("Hermes-Relay Android app", substring = true).assertDoesNotExist()
|
||||
compose.onNodeWithText("Server-side persona").assertExists()
|
||||
compose.onRoot().captureRoboImage("build/ui-regression/injected-context-gateway.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiOnlyPreviewShowsThePreparedMobileContext() {
|
||||
showPreview("sessions")
|
||||
compose.onNodeWithText("Hermes-Relay Android app", substring = true).assertExists()
|
||||
compose.onNodeWithText("Not sent in Gateway chat", substring = true).assertDoesNotExist()
|
||||
compose.onRoot().captureRoboImage("build/ui-regression/injected-context-api.png")
|
||||
}
|
||||
|
||||
private fun showPreview(endpoint: String) {
|
||||
val preview = ChatViewModel().apply { streamingEndpoint = endpoint }.previewInjectedContext()
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
InjectedContextSheet(context = preview, onDismiss = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.InputMode
|
||||
import androidx.compose.ui.input.InputModeManager
|
||||
import androidx.compose.ui.platform.LocalInputModeManager
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performImeAction
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyBatch
|
||||
import com.hermesandroid.relay.data.HermesCardClarifyQuestion
|
||||
import com.hermesandroid.relay.data.HermesCardInput
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class ClarifyBatchInteractionTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun draftsAndSelectionSurviveSavedStateAndFocusFollowsQuestionOrder() {
|
||||
val restoration = StateRestorationTester(compose)
|
||||
lateinit var inputMode: InputModeManager
|
||||
restoration.setContent {
|
||||
inputMode = LocalInputModeManager.current
|
||||
MaterialTheme {
|
||||
HermesCardBubble(card().copy(clarifyBatch = HermesCardClarifyBatch(listOf(question))),
|
||||
"batch", emptyList(), { _, _ -> }, { _, _ -> })
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Stage").performClick()
|
||||
compose.onNodeWithContentDescription("Other (type your answer)…").performTextInput("Keep rollback")
|
||||
restoration.emulateSavedInstanceStateRestore()
|
||||
compose.onNodeWithText("Stage").assertIsSelected()
|
||||
compose.onNodeWithText("Keep rollback").assertExists()
|
||||
compose.runOnIdle { inputMode.requestInputMode(InputMode.Keyboard) }
|
||||
compose.onNodeWithText("Stage").performSemanticsAction(SemanticsActions.RequestFocus) { it() }
|
||||
compose.onNodeWithText("Stage").assertIsFocused()
|
||||
compose.onNodeWithText("Stage").performKeyInput { pressKey(Key.Tab) }
|
||||
compose.onNodeWithText("Production").assertIsFocused()
|
||||
}
|
||||
|
||||
@Test fun multiSelectImeRetryKeepsChoicesAndDraftAndDisablesDuplicateActions() {
|
||||
var submitting by mutableStateOf(false)
|
||||
val answers = mutableListOf<Pair<String, String>>()
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
HermesCardBubble(
|
||||
card = card().copy(clarifyBatch = HermesCardClarifyBatch(listOf(question.copy(submitting = submitting)))),
|
||||
cardKey = "batch", dispatches = emptyList(), onActionTap = { _, _ -> },
|
||||
onInputSubmit = { key, value -> answers += key to value; submitting = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Submit").assertIsNotEnabled()
|
||||
compose.onNodeWithContentDescription("Other (type your answer)…").apply {
|
||||
performTextInput(" ")
|
||||
performImeAction()
|
||||
}
|
||||
compose.runOnIdle { assertEquals(0, answers.size) }
|
||||
compose.onNodeWithText("Stage").performClick().assertIsSelected()
|
||||
compose.onNodeWithContentDescription("Other (type your answer)…").apply {
|
||||
performTextInput("custom")
|
||||
performImeAction()
|
||||
}
|
||||
compose.onNodeWithText("Stage").assertIsNotEnabled()
|
||||
compose.onNodeWithText("Submit").assertIsNotEnabled()
|
||||
compose.runOnIdle {
|
||||
assertEquals(listOf("qid-key" to "[\"Stage\",\"custom\"]"), answers)
|
||||
submitting = false
|
||||
}
|
||||
compose.onNodeWithText("Stage").assertIsSelected()
|
||||
compose.onNodeWithText("Submit").performClick()
|
||||
compose.runOnIdle { assertEquals(answers[0], answers[1]) }
|
||||
}
|
||||
|
||||
@Test fun confirmedQuestionAdvancesAndExpiredBatchRetainsAnswersWithoutInputs() {
|
||||
var batch by mutableStateOf(HermesCardClarifyBatch(listOf(question,
|
||||
question.copy(key = "second", question = "Anything else?", input = HermesCardInput(HermesCardInput.Kinds.TEXT, allowFreeText = true)))))
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
HermesCardBubble(card().copy(clarifyBatch = batch), "batch", emptyList(), { _, _ -> }, { _, _ -> })
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { batch = batch.copy(questions = batch.questions.map { if (it.key == "qid-key") it.copy(answer = "[\"Stage\"]") else it }) }
|
||||
compose.onNodeWithText("Question 2 of 2").assertExists()
|
||||
compose.onNodeWithText("Anything else?").assertExists()
|
||||
compose.onNodeWithText("Stage").assertDoesNotExist()
|
||||
compose.runOnIdle { batch = batch.copy(expiresAtMillis = 1L) }
|
||||
compose.onNodeWithText("This request has ended").assertExists()
|
||||
compose.onNodeWithText("Stage").assertExists()
|
||||
compose.onNodeWithContentDescription("Type an answer…").assertDoesNotExist()
|
||||
}
|
||||
|
||||
private val question = HermesCardClarifyQuestion("qid-key", "Which environments?",
|
||||
HermesCardInput(HermesCardInput.Kinds.CHOICE, listOf("Stage", "Production"), multiSelect = true, allowFreeText = true))
|
||||
private fun card() = HermesCard(HermesCard.BuiltInTypes.ASK_CLARIFY, title = "Hermes needs clarification")
|
||||
}
|
||||
@@ -1736,6 +1736,24 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals(false, gatewayClient.sessionModelProvider()?.fast)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun injectedContextPreviewMatchesBareGatewayPayload() {
|
||||
viewModel.appContextSettings = com.hermesandroid.relay.util.AppContextSettings(
|
||||
master = true, battery = true, currentApp = true,
|
||||
)
|
||||
val preview = viewModel.previewInjectedContext()
|
||||
assertFalse(preview.perTurnContextSupported)
|
||||
assertNull(preview.combinedSystemMessage)
|
||||
|
||||
viewModel.sendMessage("Keep this message unchanged")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
val submitted = gatewayHarness.rpcLog.last { it.first == "prompt.submit" }.second
|
||||
assertEquals(JsonPrimitive("Keep this message unchanged"), submitted["text"])
|
||||
assertFalse(submitted.containsKey("system_message"))
|
||||
assertFalse(submitted.containsKey("surface"))
|
||||
assertEquals(0, apiCompletionsRequestCount.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dashboardOnlyConnectionCanSendWithoutApiClient() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
@@ -2729,6 +2747,121 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
awaitCondition { viewModel.pendingAsk.value == null }
|
||||
}
|
||||
|
||||
private fun presentBatchClarify(requestId: String = "batch") {
|
||||
serverWs.send(gatewayHarness.eventFrame("clarify.request",
|
||||
gatewayHarness.json.parseToJsonElement("""{"request_id":"$requestId","questions":[
|
||||
{"qid":"route/a","question":"Which deployment?","choices":["Canary","Immediate"]},
|
||||
{"qid":"environment:b","question":"Which environments?","choices":["Stage","Production"],"multi_select":true}
|
||||
]}""") as kotlinx.serialization.json.JsonObject, "live-resumed"))
|
||||
awaitCondition { viewModel.pendingAsk.value?.ask?.requestId == requestId }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batchClarifyInFlightAnswerSurvivesSessionRoundTripWithoutDuplicateRpc() {
|
||||
val contextKey = AgentDisplay.profileContextKey("connection-a", null)
|
||||
val store = MemoryCheckpointStore()
|
||||
viewModel.setChatTurnCheckpointStore(store)
|
||||
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
|
||||
viewModel.sendMessage("Ask before continuing")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
presentBatchClarify()
|
||||
val pending = requireNotNull(viewModel.pendingAsk.value)
|
||||
val key = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a")
|
||||
gatewayHarness.suppressAckMethods += "clarify.respond"
|
||||
viewModel.answerAsk(pending.messageId, key, "Canary")
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
val ack = gatewayHarness.awaitPendingAck()
|
||||
viewModel.switchSession("other-session")
|
||||
awaitCondition { handler.currentSessionId.value == "other-session" && store.checkpoint?.pendingAsk != null }
|
||||
gatewayHarness.recoveryRunning = true
|
||||
viewModel.switchSession(STORED_SESSION_ID)
|
||||
awaitCondition { viewModel.pendingAsk.value != null && handler.currentSessionId.value == STORED_SESSION_ID }
|
||||
val restored = requireNotNull(viewModel.pendingAsk.value)
|
||||
assertEquals(pending.ownerId, restored.ownerId)
|
||||
viewModel.answerAsk(restored.messageId, key, "Canary")
|
||||
gatewayHarness.releaseAck(ack)
|
||||
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Canary" }
|
||||
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
|
||||
gatewayHarness.suppressAckMethods -= "clarify.respond"
|
||||
viewModel.answerAsk(restored.messageId,
|
||||
com.hermesandroid.relay.data.clarifyQuestionCardKey(restored.cardKey, "environment:b"), "[\"Stage\"]")
|
||||
awaitCondition { viewModel.pendingAsk.value == null }
|
||||
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject { put("text", "All answers received") }, "live-resumed"))
|
||||
awaitCondition { !handler.isStreaming.value && !gatewayClient.hasActiveTurn() }
|
||||
assertEquals(2, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batchClarifyKeepsPartialProgressRejectsDuplicateAndNeverSendsChatAnswers() {
|
||||
viewModel.sendMessage("Ask a batch")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
presentBatchClarify()
|
||||
val pending = requireNotNull(viewModel.pendingAsk.value)
|
||||
val key = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a")
|
||||
gatewayHarness.suppressAckMethods += "clarify.respond"
|
||||
viewModel.answerAsk(pending.messageId, key, "Canary")
|
||||
viewModel.answerAsk(pending.messageId, key, "Canary")
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
val ack = gatewayHarness.awaitPendingAck()
|
||||
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
|
||||
gatewayHarness.releaseAck(ack)
|
||||
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Canary" }
|
||||
presentBatchClarify()
|
||||
viewModel.answerAsk(pending.messageId, key, "Immediate")
|
||||
assertEquals("Canary", viewModel.pendingAsk.value?.ask?.answers?.get("route/a"))
|
||||
gatewayHarness.suppressAckMethods -= "clarify.respond"
|
||||
val secondKey = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "environment:b")
|
||||
viewModel.answerAsk(pending.messageId, secondKey, "[\"Stage\",\"Production\"]")
|
||||
awaitCondition { viewModel.pendingAsk.value == null }
|
||||
val responses = gatewayHarness.rpcLog.filter { it.first == "clarify.respond" }.map { it.second }
|
||||
assertEquals(listOf(JsonPrimitive("route/a"), JsonPrimitive("environment:b")), responses.map { it["question_id"] })
|
||||
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "prompt.submit" })
|
||||
val card = handler.messages.value.single { it.id == pending.messageId }.cards.single()
|
||||
assertEquals(listOf("Canary", "[\"Stage\",\"Production\"]"), card.clarifyBatch?.questions?.map { it.answer })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun batchClarifyFailedRpcCanRetryAndWhitespaceCannotSubmit() {
|
||||
viewModel.sendMessage("Ask a batch")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
presentBatchClarify()
|
||||
val pending = requireNotNull(viewModel.pendingAsk.value)
|
||||
val key = com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a")
|
||||
viewModel.answerAsk(pending.messageId, key, " ")
|
||||
assertTrue(gatewayHarness.rpcLog.none { it.first == "clarify.respond" })
|
||||
gatewayHarness.rpcErrors["clarify.respond"] = 5030 to "Try again"
|
||||
viewModel.answerAsk(pending.messageId, key, "Canary")
|
||||
awaitCondition { gatewayHarness.rpcLog.any { it.first == "clarify.respond" } &&
|
||||
handler.messages.value.single { it.id == pending.messageId }.cards.single().clarifyBatch?.questions?.first()?.submitting == false }
|
||||
assertTrue(requireNotNull(viewModel.pendingAsk.value).ask.answers.isEmpty())
|
||||
gatewayHarness.rpcErrors.remove("clarify.respond")
|
||||
viewModel.answerAsk(pending.messageId, key, " custom route ")
|
||||
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "custom route" }
|
||||
assertEquals(2, gatewayHarness.rpcLog.count { it.first == "clarify.respond" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleExpiredBatchResponseCannotRetireReusedRequestId() {
|
||||
viewModel.sendMessage("Ask a batch")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
presentBatchClarify()
|
||||
val pending = requireNotNull(viewModel.pendingAsk.value)
|
||||
gatewayHarness.suppressAckMethods += "clarify.respond"
|
||||
viewModel.answerAsk(pending.messageId, com.hermesandroid.relay.data.clarifyQuestionCardKey(pending.cardKey, "route/a"), "Canary")
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
val ack = gatewayHarness.awaitPendingAck()
|
||||
serverWs.send(gatewayHarness.eventFrame("clarify.expire", buildJsonObject { put("request_id", "batch") }, "live-resumed"))
|
||||
awaitCondition { viewModel.pendingAsk.value == null }
|
||||
presentBatchClarify()
|
||||
val newOwner = requireNotNull(viewModel.pendingAsk.value).ownerId
|
||||
gatewayHarness.releaseAck(ack, buildJsonObject { put("status", "expired") })
|
||||
gatewayHarness.suppressAckMethods -= "clarify.respond"
|
||||
val current = requireNotNull(viewModel.pendingAsk.value)
|
||||
viewModel.answerAsk(current.messageId, com.hermesandroid.relay.data.clarifyQuestionCardKey(current.cardKey, "route/a"), "Immediate")
|
||||
awaitCondition { viewModel.pendingAsk.value?.ask?.answers?.get("route/a") == "Immediate" }
|
||||
assertEquals(newOwner, viewModel.pendingAsk.value?.ownerId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authoritativeClarifyExpiryCollapsesCardAndRejectsLateAction() {
|
||||
viewModel.sendMessage("Ask a question")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.util.AppContextSettings
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class InjectedContextTest {
|
||||
@Test
|
||||
fun gatewayPreviewExcludesPhoneContextEvenWhenSharingIsEnabled() {
|
||||
val viewModel = ChatViewModel().apply {
|
||||
streamingEndpoint = "gateway"
|
||||
appContextSettings = AppContextSettings(master = true, battery = true, currentApp = true)
|
||||
setSelectedProfileProvider { Profile(name = "writer", model = "test-model", systemMessage = "Profile persona") }
|
||||
}
|
||||
|
||||
val preview = viewModel.previewInjectedContext()
|
||||
|
||||
assertFalse(preview.perTurnContextSupported)
|
||||
assertTrue(preview.personaOwnedServerSide)
|
||||
assertNull(preview.personaPrompt)
|
||||
assertNull(preview.appContext)
|
||||
assertNull(preview.interfaceContext)
|
||||
assertNull(preview.mediaCapability)
|
||||
assertNull(preview.combinedSystemMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiOnlyTransportsRetainOptedInPhoneContextAndMasterOffRemovesIt() {
|
||||
for (endpoint in listOf("sessions", "runs", "completions")) {
|
||||
val viewModel = ChatViewModel().apply { streamingEndpoint = endpoint }
|
||||
val enabled = viewModel.previewInjectedContext()
|
||||
assertTrue(enabled.perTurnContextSupported)
|
||||
assertFalse(enabled.personaOwnedServerSide)
|
||||
assertTrue(enabled.appContext!!.contains("Hermes-Relay Android app"))
|
||||
assertEquals(enabled.appContext, enabled.combinedSystemMessage)
|
||||
|
||||
viewModel.appContextSettings = AppContextSettings(master = false, battery = true, currentApp = true)
|
||||
val disabled = viewModel.previewInjectedContext()
|
||||
assertNull(disabled.appContext)
|
||||
assertNull(disabled.combinedSystemMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changingTransportRebuildsPreviewWithoutChangingSharingPreference() {
|
||||
val viewModel = ChatViewModel().apply { streamingEndpoint = "sessions" }
|
||||
val apiPreview = viewModel.previewInjectedContext()
|
||||
viewModel.streamingEndpoint = "gateway"
|
||||
assertNull(viewModel.previewInjectedContext().appContext)
|
||||
assertTrue(viewModel.appContextSettings.master)
|
||||
viewModel.streamingEndpoint = "sessions"
|
||||
assertEquals(apiPreview.appContext, viewModel.previewInjectedContext().appContext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.spyk
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class, sdk = [35])
|
||||
class VoiceOverlayForegroundServiceTest {
|
||||
private val host = mockk<VoiceOverlayHost>(relaxed = true)
|
||||
private lateinit var service: VoiceOverlayForegroundService
|
||||
@Before fun setup() {
|
||||
mockkObject(VoiceOverlayHost.Companion)
|
||||
every { VoiceOverlayHost.peek() } returns host
|
||||
every { host.sessionId } returns 7L
|
||||
every { host.canStart(7L) } returns true
|
||||
every { host.canContinue(7L) } returns true
|
||||
every { host.onServiceReady(7L) } returns true
|
||||
service = Robolectric.buildService(VoiceOverlayForegroundService::class.java).create().get()
|
||||
}
|
||||
@After fun cleanup() { service.onDestroy(); unmockkAll() }
|
||||
private fun command(action: String, id: Long = 7L) = Intent().setAction(action)
|
||||
.putExtra(VoiceOverlayForegroundService.EXTRA_SESSION_ID, id)
|
||||
|
||||
@Test fun notificationIsPostedBeforeWindowReadiness() {
|
||||
every { host.onServiceReady(7L) } answers {
|
||||
assertNotNull(shadowOf(service).lastForegroundNotification)
|
||||
true
|
||||
}
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
verify(exactly = 1) { host.onServiceReady(7L) }
|
||||
}
|
||||
@Test fun unownedOrRestartIntentNeverPromotesService() {
|
||||
service.onStartCommand(null, 0, 1)
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START, 8), 0, 2)
|
||||
assertNull(shadowOf(service).lastForegroundNotification)
|
||||
verify(exactly = 0) { host.onServiceReady(any()) }
|
||||
}
|
||||
@Test fun oldNotificationCannotStopCurrentSession() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_STOP, 6), 0, 2)
|
||||
verify(exactly = 0) { host.exitVoiceSession(any()) }
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_STOP), 0, 3)
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
@Test fun windowFailureEndsVoiceInsteadOfLeavingUnprotectedCapture() {
|
||||
every { host.onServiceReady(7L) } returns false
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
@Test fun taskRemovalEndsVoice() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
service.onTaskRemoved(null)
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
|
||||
@Test fun permissionLossEndsVoiceAtTheNextAccessCheck() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
every { host.canContinue(7L) } returns false
|
||||
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
|
||||
@Test fun screenOffEndsVoiceWithoutWaitingForPolling() {
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
service.sendBroadcast(Intent(Intent.ACTION_SCREEN_OFF))
|
||||
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
|
||||
@Test fun failedForegroundPromotionNeverAttachesWindow() {
|
||||
service = spyk(service)
|
||||
every { service.startForeground(any(), any(), any()) } throws SecurityException("denied")
|
||||
service.onStartCommand(command(VoiceOverlayForegroundService.ACTION_START), 0, 1)
|
||||
verify(exactly = 0) { host.onServiceReady(any()) }
|
||||
verify(exactly = 1) { host.exitVoiceSession(7L) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class, sdk = [35])
|
||||
class VoiceOverlayLifecycleTest {
|
||||
private lateinit var host: VoiceOverlayHost
|
||||
private val owner = object : LifecycleOwner {
|
||||
override val lifecycle = LifecycleRegistry(this)
|
||||
}
|
||||
private var exits = 0
|
||||
private var access = VoiceOverlayAccess(true, true, true, true)
|
||||
private val state = MutableStateFlow(VoiceUiState(voiceMode = true))
|
||||
|
||||
@Before fun setup() {
|
||||
mockkObject(VoiceOverlayAccess.Companion)
|
||||
every { VoiceOverlayAccess.read(any()) } answers { access }
|
||||
host = VoiceOverlayHost(RuntimeEnvironment.getApplication())
|
||||
owner.lifecycle.currentState = Lifecycle.State.RESUMED
|
||||
}
|
||||
@After fun cleanup() { host.hide(); unmockkAll() }
|
||||
|
||||
private fun session() = VoiceOverlaySession(state, provider = null, model = null, voice = null,
|
||||
profileName = "Test", configScope = null, outputEnabled = true, fallbackEnabled = false,
|
||||
onStartListening = {}, onStopListening = {}, onInterrupt = {}, onPauseAutoMode = {},
|
||||
onReturnToHermes = {}, onDismissOverlay = {}, onExit = { exits++ })
|
||||
|
||||
@Test fun backgroundCallerCannotCreateSession() {
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
assertFalse(host.show(session(), owner.lifecycle))
|
||||
assertNull(host.sessionId)
|
||||
}
|
||||
|
||||
@Test fun missingAccessCannotCreateSession() {
|
||||
listOf(access.copy(microphone = false), access.copy(notifications = false),
|
||||
access.copy(overlay = false), access.copy(unlocked = false)).forEach {
|
||||
access = it
|
||||
assertFalse(host.show(session(), owner.lifecycle))
|
||||
assertNull(host.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun stopBeforeServiceReadyRejectsLateStartAndExitsOnce() {
|
||||
assertTrue(host.show(session(), owner.lifecycle))
|
||||
val id = host.sessionId!!
|
||||
host.exitVoiceSession(id)
|
||||
host.exitVoiceSession(id)
|
||||
assertFalse(host.onServiceReady(id))
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
|
||||
@Test fun oldStopAndReadyCannotAffectNewSession() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
val old = host.sessionId!!
|
||||
host.exitVoiceSession(old)
|
||||
host.show(session(), owner.lifecycle)
|
||||
val current = host.sessionId!!
|
||||
host.exitVoiceSession(old)
|
||||
assertFalse(host.onServiceReady(old))
|
||||
assertEquals(current, host.sessionId)
|
||||
assertTrue(host.canStart(current))
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
|
||||
@Test fun lossOfForegroundBeforeServicePromotionFailsClosed() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
assertFalse(host.canStart(host.sessionId!!))
|
||||
}
|
||||
|
||||
@Test fun revocationAndVoiceExitInvalidateActiveEligibility() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
val id = host.sessionId!!
|
||||
access = access.copy(overlay = false)
|
||||
assertFalse(host.canContinue(id))
|
||||
access = access.copy(overlay = true)
|
||||
state.value = VoiceUiState(voiceMode = false)
|
||||
assertFalse(host.canContinue(id))
|
||||
}
|
||||
|
||||
@Test fun destroyedCallerEndsPendingSession() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
owner.lifecycle.currentState = Lifecycle.State.DESTROYED
|
||||
assertNull(host.sessionId)
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
|
||||
@Test fun returningToResumedAppReleasesOverlayWithoutEndingVoice() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
val id = host.sessionId!!
|
||||
assertTrue(host.onServiceReady(id))
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
assertTrue(host.canContinue(id))
|
||||
owner.lifecycle.currentState = Lifecycle.State.RESUMED
|
||||
assertNull(host.sessionId)
|
||||
assertEquals(0, exits)
|
||||
assertTrue(state.value.voiceMode)
|
||||
}
|
||||
|
||||
@Test fun revocationBeforeResumeEndsVoiceInsteadOfHandingItBack() {
|
||||
host.show(session(), owner.lifecycle)
|
||||
assertTrue(host.onServiceReady(host.sessionId!!))
|
||||
owner.lifecycle.currentState = Lifecycle.State.STARTED
|
||||
access = access.copy(overlay = false)
|
||||
owner.lifecycle.currentState = Lifecycle.State.RESUMED
|
||||
assertNull(host.sessionId)
|
||||
assertEquals(1, exits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class, qualifiers = "w360dp-h720dp-xhdpi", sdk = [35])
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class VoiceOverlayPresentationTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun missingPermissionKeepsStartDisabled() = setupDialog(false, 1f)
|
||||
@Test fun grantedPermissionsStillRequireStart() = setupDialog(true, 1f)
|
||||
@Test fun largeTextPermissionSetup() = setupDialog(true, 1.5f)
|
||||
|
||||
private fun setupDialog(granted: Boolean, scale: Float) {
|
||||
org.robolectric.RuntimeEnvironment.setFontScale(scale)
|
||||
compose.waitForIdle()
|
||||
var starts = 0
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
CompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, scale)) {
|
||||
VoiceOverlaySetupContent(VoiceOverlayAccess(true, granted, granted, true), false,
|
||||
{}, { starts++ }, {}, {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
val start = compose.onNodeWithText("Start voice overlay")
|
||||
start.assertIsDisplayed()
|
||||
assertEquals(0, starts)
|
||||
if (granted) start.assertIsEnabled() else start.assertIsNotEnabled()
|
||||
capture("setup-$granted-$scale")
|
||||
compose.onNodeWithText("Display over other apps", substring = true).performScrollTo().assertIsDisplayed()
|
||||
if (scale > 1f) capture("setup-large-text-scrolled")
|
||||
if (granted) {
|
||||
start.performClick()
|
||||
compose.runOnIdle { assertEquals(1, starts) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun stopRemainsReachableWhenMinimized() = overlay(1f)
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "w320dp-h480dp-xhdpi")
|
||||
fun narrowOverlayWithLargeText() = overlay(1.5f)
|
||||
|
||||
private fun overlay(scale: Float) {
|
||||
org.robolectric.RuntimeEnvironment.setFontScale(scale)
|
||||
compose.waitForIdle()
|
||||
var exits = 0
|
||||
val session = VoiceOverlaySession(MutableStateFlow(VoiceUiState(voiceMode = true)),
|
||||
provider = null, model = null, voice = null, profileName = "Research", configScope = null,
|
||||
outputEnabled = true, fallbackEnabled = false, onStartListening = {}, onStopListening = {},
|
||||
onInterrupt = {}, onPauseAutoMode = {}, onReturnToHermes = {}, onDismissOverlay = {},
|
||||
onExit = { exits++ }, connectionLabel = "Home server")
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
VoiceFloatingOverlayPill(session, { _, _ -> })
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithContentDescription("Stop voice").assertIsDisplayed().assertHeightIsAtLeast(48.dp)
|
||||
capture("overlay-compact-$scale")
|
||||
compose.onNodeWithContentDescription("Expand voice controls")
|
||||
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
|
||||
compose.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.mainClock.advanceTimeBy(500)
|
||||
capture("overlay-expanded-$scale")
|
||||
compose.onNodeWithText("Minimize").performScrollTo()
|
||||
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
|
||||
compose.mainClock.advanceTimeBy(500)
|
||||
compose.onNodeWithText("Stop voice").assertIsDisplayed()
|
||||
capture("overlay-minimized-$scale")
|
||||
compose.onNodeWithText("Stop voice")
|
||||
.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() }
|
||||
compose.runOnIdle { assertEquals(1, exits) }
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
val file = File("build/ui-evidence/play-voice-$name.png")
|
||||
file.parentFile?.mkdirs()
|
||||
compose.onRoot().captureRoboImage(file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 81 KiB |
@@ -22,13 +22,24 @@ export const desktopUiScreenshotSourceFiles = Object.freeze([
|
||||
|
||||
const binaryExtensions = new Set(['.png'])
|
||||
|
||||
function normalizedSource(relativePath, bytes) {
|
||||
if (binaryExtensions.has(extname(relativePath))) return bytes
|
||||
const text = bytes.toString('utf8').replace(/\r\n?/g, '\n')
|
||||
if (relativePath !== 'desktop/tray/package-lock.json') return Buffer.from(text, 'utf8')
|
||||
|
||||
// The screenshot fixture has fixed display versions. A release-only bump of
|
||||
// the root package does not change it; dependency versions still affect rendering.
|
||||
const lockfile = JSON.parse(text)
|
||||
delete lockfile.version
|
||||
if (lockfile.packages?.['']) delete lockfile.packages[''].version
|
||||
return Buffer.from(JSON.stringify(lockfile), 'utf8')
|
||||
}
|
||||
|
||||
export async function computeDesktopUiSourceFingerprint(repositoryRoot = defaultRepositoryRoot) {
|
||||
const hash = createHash('sha256')
|
||||
for (const relativePath of desktopUiScreenshotSourceFiles) {
|
||||
const bytes = await readFile(resolve(repositoryRoot, relativePath))
|
||||
const normalized = binaryExtensions.has(extname(relativePath))
|
||||
? bytes
|
||||
: Buffer.from(bytes.toString('utf8').replace(/\r\n?/g, '\n'), 'utf8')
|
||||
const normalized = normalizedSource(relativePath, bytes)
|
||||
hash.update(relativePath)
|
||||
hash.update('\0')
|
||||
hash.update(normalized)
|
||||
@@ -36,7 +47,7 @@ export async function computeDesktopUiSourceFingerprint(repositoryRoot = default
|
||||
}
|
||||
return {
|
||||
algorithm: 'sha256',
|
||||
normalization: 'text-lf-v1',
|
||||
normalization: 'text-lf-lockfile-root-version-v2',
|
||||
digest: hash.digest('hex'),
|
||||
files: [...desktopUiScreenshotSourceFiles]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { computeDesktopUiSourceFingerprint, desktopUiScreenshotSourceFiles } from './desktop-ui-source-fingerprint.mjs'
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'hermes-desktop-fingerprint-'))
|
||||
t.after(async () => {
|
||||
assert.equal(dirname(resolve(root)), resolve(tmpdir()))
|
||||
assert.ok(basename(root).startsWith('hermes-desktop-fingerprint-'))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
for (const file of desktopUiScreenshotSourceFiles) {
|
||||
const destination = join(root, file)
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
await writeFile(destination, 'fixture\n')
|
||||
}
|
||||
const lockfile = {
|
||||
name: '@hermes-relay/tray-ui', version: '0.4.0-beta.6', lockfileVersion: 3,
|
||||
packages: {
|
||||
'': { name: '@hermes-relay/tray-ui', version: '0.4.0-beta.6', dependencies: { react: '^19.0.0' } },
|
||||
'node_modules/react': { version: '19.0.0', integrity: 'example-integrity' },
|
||||
},
|
||||
}
|
||||
const saveLock = () => writeFile(join(root, 'desktop/tray/package-lock.json'), JSON.stringify(lockfile, null, 2))
|
||||
await saveLock()
|
||||
return { root, lockfile, saveLock, fingerprint: () => computeDesktopUiSourceFingerprint(root) }
|
||||
}
|
||||
|
||||
test('root release versions do not invalidate fixed-version screenshots', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = await f.fingerprint()
|
||||
f.lockfile.version = '0.4.0-beta.7'
|
||||
f.lockfile.packages[''].version = '0.4.0-beta.7'
|
||||
await f.saveLock()
|
||||
assert.deepEqual(await f.fingerprint(), before)
|
||||
})
|
||||
|
||||
test('dependency versions and integrity remain fingerprinted', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = (await f.fingerprint()).digest
|
||||
f.lockfile.packages['node_modules/react'].version = '19.1.0'
|
||||
await f.saveLock()
|
||||
const changedVersion = (await f.fingerprint()).digest
|
||||
assert.notEqual(changedVersion, before)
|
||||
f.lockfile.packages['node_modules/react'].integrity = 'changed-integrity'
|
||||
await f.saveLock()
|
||||
assert.notEqual((await f.fingerprint()).digest, changedVersion)
|
||||
})
|
||||
|
||||
test('production UI changes still invalidate screenshots', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = (await f.fingerprint()).digest
|
||||
await writeFile(join(f.root, 'desktop/tray/ui/App.tsx'), 'changed UI\n')
|
||||
assert.notEqual((await f.fingerprint()).digest, before)
|
||||
})
|
||||
|
||||
test('text line endings do not cause platform-only drift', async t => {
|
||||
const f = await fixture(t)
|
||||
const before = await f.fingerprint()
|
||||
for (const file of desktopUiScreenshotSourceFiles.filter(file => !file.endsWith('.png'))) {
|
||||
const path = join(f.root, file)
|
||||
await writeFile(path, (await readFile(path, 'utf8')).replace(/\n/g, '\r\n'))
|
||||
}
|
||||
assert.deepEqual(await f.fingerprint(), before)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
# Play voice overlay verification
|
||||
|
||||
The Google Play build adds user-started voice controls over other apps. This
|
||||
does not enable Device Control, AccessibilityService, MediaProjection or phone
|
||||
utility permissions. Standard voice keeps the upstream Dashboard/Gateway path.
|
||||
|
||||
## Session contract
|
||||
|
||||
- Permission setup explains the purpose before launching Android Settings.
|
||||
Permission grants do not start the overlay; Start requires a resumed, unlocked
|
||||
app with microphone, overlay and notification access.
|
||||
- The microphone foreground service must successfully promote before the window
|
||||
attaches. Session identifiers reject stale starts, readiness and Stop actions.
|
||||
- The existing voice runtime owns capture and microphone-release barriers. The
|
||||
service creates no recorder. Stop/close, screen lock, task removal, permission
|
||||
loss and failed startup terminate the voice session. Notification-channel
|
||||
revocation is checked at most one second later while the service is running.
|
||||
- Returning to Hermes preserves foreground protection until the app resumes.
|
||||
No overlay session is restored after process death or started by boot, Relay
|
||||
commands or wake detection. Independent opt-in wake settings remain unchanged.
|
||||
|
||||
## Host verification
|
||||
|
||||
Run focused tests through the Windows Android lane:
|
||||
|
||||
```powershell
|
||||
.\scripts\android-lane.ps1 gradle :app:testGooglePlayDebugUnitTest `
|
||||
--tests '*VoiceOverlay*Test' --tests '*VoiceModeOverlayInteractionTest' `
|
||||
--tests '*VoiceViewModelBargeInTest' --tests '*BargeInListenerShutdownRaceTest' `
|
||||
--console=plain
|
||||
```
|
||||
|
||||
The new session/service tests exercise foreground eligibility, missing/revoked
|
||||
access, stopped-before-ready sessions, old notification actions, failed foreground
|
||||
promotion/window attachment, screen-off and task removal. Existing voice tests
|
||||
cover the microphone handoff and shutdown race. The same new tests are included
|
||||
in the both-flavor on-demand focused preset.
|
||||
|
||||
`scripts/check-android-capabilities.py` checks source overlays and the merged
|
||||
Play debug/release manifests. Its mutation tests reject transitive sensitive
|
||||
permissions, renamed accessibility services and exported overlay services.
|
||||
CI debug/release builds and Play preflight run this check.
|
||||
|
||||
## Rendered controls
|
||||
|
||||
These are production Compose components rendered by Robolectric/Roborazzi on
|
||||
API 35, with synthetic voice state. They are not physical microphone evidence.
|
||||
Normal layout is 360 × 720 dp; narrow layout is 320 × 480 dp with 150% font size.
|
||||
The permission dialog is also checked at 150%, including scrolling to the last
|
||||
permission row while Start/Cancel remain reachable.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Release validation
|
||||
|
||||
Physical-device tests and Play Console changes are separate release work. Before
|
||||
production, validate real repeated microphone turns after backgrounding on Android
|
||||
14–16, denial/revocation, task/process termination, screen lock, audio interruption,
|
||||
network loss and OEM window behavior. Use the Standard Phone API 36 emulator lane
|
||||
for the smallest relevant instrumentation run; device claims still require a real
|
||||
device. Do not install an APK or submit a test-track build without authorization.
|
||||
|
||||
Update the microphone FGS declaration and demo for the actual Google Play package.
|
||||
`scripts/android-fgs-demo.py` defaults to that package and requires permissions to
|
||||
be granted manually before recording. Review Data Safety against real recipients,
|
||||
retention and any applicable exceptions. Publish the corresponding canonical and
|
||||
legacy privacy pages before stable preflight; the strengthened live checker
|
||||
deliberately rejects the old blanket no-screen-access policy. See the
|
||||
[submission requirements](../play-store-listing.md#voice-overlay-review-before-production).
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -4411,3 +4411,36 @@ approval bypass.
|
||||
- `.github/workflows/approve-release-train.yml`
|
||||
- `scripts/android_release_artifacts.py`
|
||||
- `scripts/android-prepush.py`
|
||||
|
||||
|
||||
## ADR 74 — Play voice overlay is independent of phone control
|
||||
|
||||
**Status:** Accepted (2026-09-12).
|
||||
|
||||
Voice Focus and a user-started voice-only system overlay are shared presentation
|
||||
surfaces. `SYSTEM_ALERT_WINDOW` is special access, not permission to read or drive
|
||||
other apps. Play retains the no-op voice bridge handler, unsupported Device Control
|
||||
status, absent accessibility/projection services and closed bridge-command gate.
|
||||
|
||||
The overlay requires a resumed, unlocked Activity action and microphone,
|
||||
notification and overlay access. Permission grants never start listening. Its
|
||||
session identifier fences queued starts and old notification actions. The window
|
||||
is attached only after microphone foreground promotion succeeds. The existing
|
||||
voice runtime remains the sole microphone owner; no second recorder is created.
|
||||
|
||||
Stop/close, screen lock, task removal, permission loss and service/window failure
|
||||
end the voice session. Returning to the Activity releases overlay protection only
|
||||
after it resumes. The session is not persisted or restarted by background callers.
|
||||
Existing independent opt-in wake/Assistant settings are not changed by overlay use.
|
||||
|
||||
Play's autonomous Accessibility Device Control boundary remains sideload-only.
|
||||
User-mediated MediaProjection and phone compose/picker actions are separate future
|
||||
features, not implicitly enabled by this decision. Standard voice remains upstream
|
||||
Dashboard/Gateway-owned and never requires Relay. Google Play declaration, live
|
||||
privacy/listing publication and physical-device certification are release gates,
|
||||
not consequences of merging this change.
|
||||
|
||||
Source and merged-manifest validation enforce this boundary. Foreground-service
|
||||
lifecycle tests and rendered permission/Stop controls supplement, but do not
|
||||
replace, device tests or a reviewed Play test-track submission. See
|
||||
[Play declarations](play-store-listing.md#voice-overlay-review-before-production).
|
||||
|
||||
@@ -68,6 +68,9 @@ the upstream contract identifiers it depends on.
|
||||
|---|---|
|
||||
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
|
||||
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
|
||||
| `clarify_legacy` | Top-level single question and unkeyed `clarify.respond` |
|
||||
| `clarify_normalized_single` | One normalized `questions[]` entry still requires its exact `qid` |
|
||||
| `clarify_batch` | Independent qid responses, partial acknowledgement, and answered-question replay on reconnect |
|
||||
| `session_initialization_failure` | Exact-session initialization error arrives before a lazy create acknowledgement; Android must fail the pending send without waiting for the readiness deadline |
|
||||
| `subagent_child_preview` | Child activity continues after the parent terminal, followed by child completion and a separate completion wake; preview ownership remains on the same profile/session |
|
||||
| `ownership_rejection` | A submit acknowledged before the defense-in-depth ownership check emits the canonical terminal refusal; no user/model row is persisted and clients must not enter history recovery |
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
|
||||
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
|
||||
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
|
||||
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
|
||||
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
|
||||
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "0b368eebdd369c06304aa7fff7954bdad2c0d5da8974e36a57d9c8d44d3e9cee",
|
||||
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
],
|
||||
"sourceFingerprint": {
|
||||
"algorithm": "sha256",
|
||||
"normalization": "text-lf-v1",
|
||||
"digest": "38253cb9fb1124a629625ea0bc5be09a4f61af91e3fca0103cbb12a5279aae54",
|
||||
"normalization": "text-lf-lockfile-root-version-v2",
|
||||
"digest": "ed01f2a0d65cd152c271bc37f9c6b0e555ca0e1306ec09c7638cdcbe283ed5eb",
|
||||
"files": [
|
||||
"desktop/tray/ui/App.tsx",
|
||||
"desktop/tray/ui/main.tsx",
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
v1.17.0 - Voice over other apps and clearer conversations
|
||||
|
||||
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.
|
||||
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.
|
||||
```
|
||||
## Category
|
||||
|
||||
@@ -197,6 +197,8 @@ entry points in its description and demonstration:
|
||||
and Stop action, then opens the voice overlay, backgrounds Hermes, and ends
|
||||
the session from the overlay or notification.
|
||||
|
||||
The Play build declares `SYSTEM_ALERT_WINDOW` only for explicitly user-started Voice Overlay. It never enables Device Control. Overlay permission and notification refusal retain in-app voice.
|
||||
|
||||
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
|
||||
|
||||
### Data safety
|
||||
@@ -213,3 +215,23 @@ questions against this flow before the next Play submission.
|
||||
- `POST_NOTIFICATIONS` — chat input, turn-complete, and keep-alive notifications, requested on API 33+.
|
||||
- `CAMERA` — QR pairing / attachments, requested at use.
|
||||
- Notification listener (companion) — user-enabled in system settings.
|
||||
|
||||
### Voice Overlay review before production
|
||||
|
||||
Update the microphone FGS declaration and demonstrate the actual Google Play
|
||||
package on an explicitly approved test track. Sideload recordings do not certify
|
||||
Play. Show permission refusal, notification Stop, screen-lock termination and
|
||||
repeated turns after switching apps. Do not use the Production-draft stable
|
||||
preflight as an experiment. Upload success is not policy approval.
|
||||
|
||||
Reconcile Data Safety for audio, messages, attachments, notification content and
|
||||
Assistant text/screenshots, including recipients and retention. No hosted backend
|
||||
or analytics does not itself establish "no data collected." Record any applicable
|
||||
collection/sharing exceptions against Google's definitions. Confirm the listing,
|
||||
canonical privacy page and legacy page describe the reviewed behavior. Console
|
||||
updates and test-track submission require release authorization.
|
||||
|
||||
Official sources checked September 12, 2026: [special access](https://developer.android.com/training/permissions/requesting-special),
|
||||
[FGS declarations](https://support.google.com/googleplay/android-developer/answer/13392821),
|
||||
[Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469),
|
||||
and [Accessibility automation](https://support.google.com/googleplay/android-developer/answer/10964491).
|
||||
|
||||
@@ -17,7 +17,7 @@ Hermes-Relay has two Android tracks:
|
||||
|
||||
| Track | Bridge scope | Sensitive Android APIs |
|
||||
|-------|--------------|------------------------|
|
||||
| Google Play | **Bridge Core**: chat, voice, terminal/TUI relay, notification companion, media handoff, relay sessions, status | No AccessibilityService, overlay permission, MediaProjection, wake-lock device-control service, or contacts/location/SMS/call permissions. Optional Android Assistant screen context is described below. |
|
||||
| Google Play | **Bridge Core**: chat, voice, terminal/TUI relay, notification companion, media handoff, relay sessions, status | Optional voice-only overlay. No AccessibilityService, MediaProjection, wake-lock device-control service, or contacts/location/SMS/call permissions. Optional Android Assistant screen context is described below. |
|
||||
| Sideload | **Device Control**: the full agent-driven phone-control bridge | AccessibilityService, foreground service, overlay chip, optional screenshots, and phone-utility permissions when enabled |
|
||||
|
||||
The Google Play build cannot use Accessibility or MediaProjection to inspect or
|
||||
@@ -118,3 +118,7 @@ Stats for Nerds tracks performance metrics such as time to first token, completi
|
||||
## Open Source
|
||||
|
||||
Hermes-Relay is MIT licensed. All source code is publicly available and auditable at [GitHub](https://github.com/Codename-11/hermes-relay).
|
||||
|
||||
## Voice Overlay
|
||||
|
||||
Voice Overlay is optional in both builds. Start it explicitly from Voice Focus while Hermes-Relay is visible and unlocked. It requires microphone access, display-over-other-apps access and an enabled microphone notification with Stop voice. Audio goes to the configured Hermes server; the overlay does not read or control other apps. Stop voice, closing the overlay, screen lock, task removal or loss of required access ends the overlay voice session. Returning to the app keeps foreground protection until the app is resumed. Granting permissions never starts a session.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-09-13 — Android 1.17.0 and Plugin 1.11.3 release preparation
|
||||
|
||||
Prepared Android 1.17.0 (versionCode 57) with Google Play Voice Overlay, progressive Clarify batches, chat card presentation, transport-accurate context previews and profile display names. Prepared Plugin 1.11.3 for current and legacy Dashboard WebSocket guard ownership. CLI+UI remains 0.4.0-beta.7.
|
||||
|
||||
Reconciled the privacy policy with local chat recovery state, explicit API-only routing and public update checks. Android publication retains exact-tree signed Play preflight and policy/declaration gates. Physical Android 14-16/OEM voice-overlay testing is waived for this release and is not claimed as verification.
|
||||
|
||||
## 2026-09-02 — Android 1.15.1 and CLI+UI beta.7 release preparation
|
||||
|
||||
Prepared Android 1.15.1 (versionCode 54) and CLI+UI 0.4.0-beta.7 from the integrated release tree. Android notes cover chat memory bounds, media previews, Gateway readiness, follow-up controls, and voice recovery. CLI+UI notes cover the Windows unified updater. Plugin metadata remains at 1.11.1; its only unreleased change is a documentation-comment path.
|
||||
|
||||
@@ -58,6 +58,33 @@ standard upstream media/file delivery, and concise-response expectations. Once
|
||||
that contract is available in the supported Hermes baseline, adopt it and add
|
||||
Gateway conformance coverage proving the exact prompt bytes and session source.
|
||||
|
||||
Issues [#556](https://github.com/Codename-11/hermes-relay/issues/556) and
|
||||
[#557](https://github.com/Codename-11/hermes-relay/issues/557) share this contract
|
||||
gap. The Android audit fix labels unsupported context; automatic Android
|
||||
identification and Gateway phone-status delivery still require upstream work.
|
||||
Rechecked against upstream `5dea46d13deec9549bdc2ea703ae9201d733c28d`:
|
||||
|
||||
- [`methods_prompt.py`](https://github.com/NousResearch/hermes-agent/blob/5dea46d13deec9549bdc2ea703ae9201d733c28d/tui_gateway/methods_prompt.py)
|
||||
accepts `surface` only for `hud` and `voice-live`; it has no general
|
||||
per-turn system-context parameter. Neither surface means Android.
|
||||
- [`session_notifications.py`](https://github.com/NousResearch/hermes-agent/blob/5dea46d13deec9549bdc2ea703ae9201d733c28d/tui_gateway/session_notifications.py)
|
||||
adds those built-in surface notes to model input without changing the
|
||||
persisted user row. This is the existing upstream seam to extend, with
|
||||
explicit client capability negotiation, bounded opted-in context, and
|
||||
turn-owned snapshots through queueing, retries, and client switches.
|
||||
- [`server.py`](https://github.com/NousResearch/hermes-agent/blob/5dea46d13deec9549bdc2ea703ae9201d733c28d/tui_gateway/server.py)
|
||||
accepts a caller-supplied session `source`, but
|
||||
[`prompt_builder.py`](https://github.com/NousResearch/hermes-agent/blob/5dea46d13deec9549bdc2ea703ae9201d733c28d/agent/prompt_builder.py)
|
||||
has no Android platform hint. A session-origin label is not verified
|
||||
current-turn sender identity. Do not invent a platform or use a client hint
|
||||
as authorization for phone tools.
|
||||
- The API server's `POST /api/sessions` source field belongs to the explicit
|
||||
API-only surface. Android standard chat creates/resumes sessions through
|
||||
Gateway RPC; that REST field does not add per-turn context to `prompt.submit`.
|
||||
|
||||
Keep unknown/older hosts supported without sending private parameters,
|
||||
rewriting the user transcript, overriding persona, or requiring Relay.
|
||||
|
||||
---
|
||||
|
||||
## Scope sensitive-media prompt guidance to capable clients
|
||||
|
||||
@@ -597,6 +597,8 @@ Bottom navigation bar with 4 tabs:
|
||||
```
|
||||
|
||||
### Chat Tab
|
||||
|
||||
- **Context preview** — the Injected context sheet describes the bound chat transport, not a delivery receipt. Standard Gateway chat has no general per-turn system-context slot: phone status, the Android client preamble, media hints, and interface-context blocks are omitted. Phone status and turn context show an explicit unsupported note. API-only chats retain the opted-in context and sharing controls; the Settings example is labeled API-only. Gateway persona and optional Relay-reported configuration remain separately server-owned. No context is prepended to user text or written into the personality slot to bypass this boundary.
|
||||
- **Top bar and Profile Shelf (three-layer agent model).** Layout from left to right:
|
||||
1. **Connection chip** — tap to open `ConnectionSwitcherSheet` (all paired servers + health indicator). Auto-hidden when you only have one Connection. See `docs/decisions.md` §19.
|
||||
2. **Agent avatar/name region** — tap to expand or collapse the Profile Shelf immediately below the app bar. With only one visible effective identity, the shelf stays hidden and the same tap opens Agent Passport.
|
||||
@@ -638,7 +640,7 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Agent Passport — profile inspection/configuration** — upstream Hermes profiles are selected from the Profile Shelf or the Passport's shared full switcher. Passport retains identity customization, model/personality/reasoning/safety configuration, inspection, and session analytics; it is not a second profile-picker implementation. See `docs/decisions.md` §21 and ADR 48.
|
||||
- **Agent sheet — Personality section** — personalities fetched from `GET /api/config` (`config.agent.personalities`). Shows server default (from `config.display.personality`) + all configured. Active personality name shown on assistant chat bubbles.
|
||||
- **Agent sheet — Approval controls** — gateway contract v3 exposes the profile-persisted `approvals.mode` policy (`manual` / `smart` / `off`) separately from YOLO. The launch/default profile gets the three-way control; multiplexed non-launch profiles reconcile `session.info.approval_mode` read-only until upstream config RPCs honor profile scope. The existing YOLO switch remains an explicit per-session override and never silently writes profile configuration. Older gateways keep chat and YOLO available while the profile control explains that an upstream update is required.
|
||||
- **Interactive clarify cards** — ordinary upstream choices retain one-tap submission; `multi_select:true` choices toggle independently and require explicit submission as one JSON-array answer. Open text remains available for an Other answer. Android never invents a clarify deadline when upstream omits timeout metadata: the correlated `clarify.expire` event or an expired response retires the card authoritatively.
|
||||
- **Interactive clarify cards** — ordinary upstream choices retain one-tap submission; `multi_select:true` choices toggle independently and require explicit submission as one JSON-array answer. Open text remains available for an Other answer. Upstream `questions[]` batches use one progressive card, retain exact qids, and answer each question through `clarify.respond` with `question_id`. Confirmed answers survive checkpoint recovery and merge with upstream replay without resubmission. Legacy top-level questions keep their original wire shape. Android never invents a clarify deadline when upstream omits timeout metadata: the correlated `clarify.expire` event or an expired response retires the card authoritatively.
|
||||
- **Streaming dots** — animated pulsing 3-dot indicator replaces static "streaming..." text
|
||||
- Displays: streaming delta text; quiet thinking/reasoning disclosures that open while live and collapse when settled; consecutive routine tool activity summarized as one live ticker or settled disclosure; standalone lifecycle surfaces for approvals, failures, generated media, file edits, and delegated work; per-message token counts + cost
|
||||
|
||||
@@ -1016,7 +1018,7 @@ The v0.4 wave includes three reliability patterns applied to existing code and o
|
||||
|
||||
**WakeLockManager — wake-scope wrapping for gesture dispatch.** New `object WakeLockManager` at `app/src/main/kotlin/com/hermesandroid/relay/power/WakeLockManager.kt` exposes `suspend fun <T> wakeForAction(block: suspend () -> T): T`. Uses `PowerManager.PARTIAL_WAKE_LOCK`, ref-counted so nested calls don't release each other prematurely, with a hard 10-second timeout as a battery safety rail. `ActionExecutor` wraps every gesture-dispatching function (`tap`, `tapText`, `typeText`, `swipe`, `scroll`, `longPress`, `drag`) in `wakeForAction { ... }`. Read-only accessibility calls (`readScreen`, `findNodes`, `describeNode`, `screenHash`, `diffScreen`, `currentApp`, `clipboardRead/Write`, `mediaControl`) are not wrapped — they don't need the screen on. Closes the "gesture fires into the void when the screen is off" failure mode that silently broke `android_tap` / `android_swipe` whenever Bailey's phone hit idle between commands. Requires `android.permission.WAKE_LOCK` in the main manifest.
|
||||
|
||||
**Multi-window ScreenReader (P1).** `ScreenReader.readCurrentScreen` now iterates `service.windows.mapNotNull { it.root }` instead of the single `rootInActiveWindow`. Returns a merged tree where each `AccessibilityNodeInfo` is walked per-window and recycled in the per-iteration `try/finally`. Catches system overlays, popup menus, notification shade, and split-screen secondary windows — the previous single-root path silently ignored them. **Node-ID scheme update:** stable IDs are now prefixed `w<windowIndex>:<sequentialIndex>` (e.g. `w0:42`, `w1:7`) so IDs are disambiguated across windows. A single-window fallback kicks in when `service.windows` is empty, which happens on the googlePlay flavor without `flagRetrieveInteractiveWindows` (the conservative a11y config that survives Play Store policy review). Node IDs are end-to-end resolvable after A4 wired parsing into `/tap` and `/scroll` — `android_find_nodes` and `android_describe_node` emit them, and `android_tap` / `android_scroll` accept them as input, so an agent can search → describe → act without re-reading the tree.
|
||||
**Multi-window ScreenReader (P1).** `ScreenReader.readCurrentScreen` now iterates `service.windows.mapNotNull { it.root }` instead of the single `rootInActiveWindow`. Returns a merged tree where each `AccessibilityNodeInfo` is walked per-window and recycled in the per-iteration `try/finally`. Catches system overlays, popup menus, notification shade, and split-screen secondary windows — the previous single-root path silently ignored them. **Node-ID scheme update:** stable IDs are now prefixed `w<windowIndex>:<sequentialIndex>` (e.g. `w0:42`, `w1:7`) so IDs are disambiguated across windows. A single-window fallback kicks in when `service.windows` is empty, when Android cannot provide interactive windows; the Google Play flavor does not bind this AccessibilityService. Node IDs are end-to-end resolvable after A4 wired parsing into `/tap` and `/scroll` — `android_find_nodes` and `android_describe_node` emit them, and `android_tap` / `android_scroll` accept them as input, so an agent can search → describe → act without re-reading the tree.
|
||||
|
||||
**A9 three-tier `tapText` cascade.** `ActionExecutor.tapText` replaces the single-shot `findNodeBoundsByText → performAction(ACTION_CLICK)` path with a 3-tier fallback:
|
||||
1. Find node by text across all windows. If `node.isClickable` → `performAction(ACTION_CLICK)`.
|
||||
@@ -1369,3 +1371,13 @@ Preserved verbatim from the original scoping session. This is a historical snaps
|
||||
|
||||
All six deliverables shipped in v0.1.0. Four of the five "non-goals for tonight" have since shipped in v0.2.0 / v0.3.0; biometrics is the one remaining open item.
|
||||
- **ClawPort** — Web dashboard (parallel effort, different interface surface)
|
||||
|
||||
|
||||
### Voice Overlay capability boundary
|
||||
|
||||
Both Android flavors offer an optional voice-only overlay from Voice Focus.
|
||||
Microphone, notification and display-over-other-apps permission checks are followed
|
||||
by a fresh Start action in the resumed app. The microphone FGS acknowledges
|
||||
readiness before the overlay attaches. Stop, screen lock, task removal and lost
|
||||
access terminate the session; an Activity handoff retains protection until resume.
|
||||
Device Control, Accessibility and MediaProjection remain sideload-only. See ADR 74.
|
||||
|
||||
@@ -63,6 +63,46 @@ Verified upstream source snapshot:
|
||||
| `hermes relay doctor` | Hermes-Relay plugin CLI | No for diagnostics | Operator/agent diagnostics | Reports vanilla upstream Hermes route reachability (including `/v1/toolsets`), dashboard Nous/topology state, sanitized gateway event-loop heartbeat state, plugin layout, Relay loopback state, and legacy bootstrap presence. |
|
||||
| `hermes_relay_bootstrap` routes | Legacy compatibility monkeypatch | No, but non-upstream | Fallback only | Installed via `.pth` by legacy installer. Injects only compatibility-only gaps: session search, memory, legacy skill detail/toggle, config, available-models, slash middleware. Sessions CRUD and skill/toolset lists are native upstream and retired from the bootstrap. Retained session-database work is offloaded (`AsyncSessionDB` when available, `asyncio.to_thread` fallback), and memory mutations reset newer upstream's request-local consolidation-failure budget. |
|
||||
|
||||
## Dashboard Relay WebSocket guard compatibility
|
||||
|
||||
The optional Relay ingress reuses the host's already-loaded
|
||||
`_ws_request_is_allowed` and `_ws_auth_ok` pair. Current Hermes owns both in
|
||||
`hermes_cli.web_server_chat`; older hosts expose them on `hermes_cli.web_server`
|
||||
(or its legacy module alias). The current owner takes precedence. If it is
|
||||
loaded but either guard is missing or non-callable, admission fails closed;
|
||||
guards are never combined across modules or retried through a weaker fallback.
|
||||
No Dashboard modules are imported just to discover guards.
|
||||
|
||||
Plugin enablement still uses the facade's `_get_dashboard_plugins` and the
|
||||
host's enabled/disabled sets. A disabled plugin or rejected Host/Origin/IP is
|
||||
checked before ticket consumption. Guard exceptions deny the upgrade, tickets
|
||||
remain single-use, and Dashboard admission never replaces Relay's own
|
||||
pairing/session authentication.
|
||||
|
||||
Run the hermetic transport regressions with:
|
||||
|
||||
```bash
|
||||
python -m pytest plugin/dashboard/test_plugin_api.py plugin/tests/test_dashboard_ingress.py -q
|
||||
```
|
||||
|
||||
An opt-in conformance lane exercises real current-upstream imports and ticket
|
||||
consumption through FastAPI and an ephemeral loopback Relay, using a temporary
|
||||
Hermes home rather than an installed service. Use a Python environment with the
|
||||
Dashboard dependencies, provide the upstream checkout on `PYTHONPATH`, and run
|
||||
this lane in its own process:
|
||||
|
||||
```bash
|
||||
test_home=$(mktemp -d)
|
||||
env -i PATH="$PATH" HOME="$test_home" HERMES_HOME="$test_home" \
|
||||
HERMES_RELAY_TEST_UPSTREAM=1 PYTHONPATH="$PWD:/path/to/hermes-agent" \
|
||||
python -m pytest plugin/dashboard/test_upstream_ws_guards.py -q
|
||||
```
|
||||
|
||||
The explicit opt-in fails on missing upstream dependencies instead of silently
|
||||
skipping. It covers query/subprotocol tickets, replay/expiry, policy and plugin
|
||||
rejection before forwarding, loopback-only token auth, and inner Relay auth.
|
||||
It does not certify a live reverse proxy or restart any installed service.
|
||||
|
||||
## Client capability gate (build flavor)
|
||||
|
||||
Route ownership above is a *server-side* contract. The Android client adds a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.16.1"
|
||||
appVersionCode = "56"
|
||||
appVersionName = "1.17.0"
|
||||
appVersionCode = "57"
|
||||
agp = "9.4.0"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Hermes-Relay",
|
||||
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.3",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.3",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.3",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
@@ -364,8 +364,17 @@ def _dashboard_ws_guards() -> tuple[Any, Any] | None:
|
||||
The current upstream plugin contract mounts routers but does not inject an
|
||||
auth dependency for WebSockets. Using the host's already-loaded helpers
|
||||
keeps ticket consumption and Host/Origin/IP policy identical to `/api/ws`.
|
||||
If upstream moves either helper, this ingress fails closed.
|
||||
Current hosts own both guards in web_server_chat; older hosts expose them
|
||||
on web_server. Prefer the current owner and never combine guards from
|
||||
different modules. A loaded current owner with an incomplete contract must
|
||||
fail closed rather than fall back to potentially stale facade helpers.
|
||||
"""
|
||||
chat = sys.modules.get("hermes_cli.web_server_chat")
|
||||
if chat is not None:
|
||||
allowed = getattr(chat, "_ws_request_is_allowed", None)
|
||||
authed = getattr(chat, "_ws_auth_ok", None)
|
||||
return (allowed, authed) if callable(allowed) and callable(authed) else None
|
||||
|
||||
candidates = [sys.modules.get("hermes_cli.web_server")]
|
||||
candidates.extend(
|
||||
module for name, module in tuple(sys.modules.items())
|
||||
|
||||
@@ -293,6 +293,68 @@ class TransportIngressTests(PluginApiTestCase):
|
||||
self.assertIn("size limit", response.json()["detail"])
|
||||
|
||||
|
||||
class DashboardWebSocketGuardDiscoveryTests(unittest.TestCase):
|
||||
def test_loaded_chat_guards_admit_without_legacy_facade_exports(self) -> None:
|
||||
allowed = Mock(return_value=True)
|
||||
authed = Mock(return_value=True)
|
||||
modules = {
|
||||
"hermes_cli.web_server": SimpleNamespace(),
|
||||
"hermes_cli.web_server_chat": SimpleNamespace(
|
||||
_ws_request_is_allowed=allowed, _ws_auth_ok=authed,
|
||||
),
|
||||
}
|
||||
with patch.object(plugin_api.sys, "modules", modules):
|
||||
self.assertEqual(plugin_api._dashboard_ws_guards(), (allowed, authed))
|
||||
|
||||
def test_legacy_hosts_retain_complete_guard_pair(self) -> None:
|
||||
for name in ("hermes_cli.web_server", "legacy.web_server"):
|
||||
with self.subTest(name=name):
|
||||
guards = (Mock(), Mock())
|
||||
module = SimpleNamespace(
|
||||
_ws_request_is_allowed=guards[0], _ws_auth_ok=guards[1],
|
||||
)
|
||||
with patch.object(plugin_api.sys, "modules", {name: module}):
|
||||
self.assertEqual(plugin_api._dashboard_ws_guards(), guards)
|
||||
|
||||
def test_current_owner_takes_precedence_over_stale_facade(self) -> None:
|
||||
guards = (Mock(return_value=False), Mock(return_value=False))
|
||||
modules = {
|
||||
"hermes_cli.web_server_chat": SimpleNamespace(
|
||||
_ws_request_is_allowed=guards[0], _ws_auth_ok=guards[1],
|
||||
),
|
||||
"hermes_cli.web_server": SimpleNamespace(
|
||||
_ws_request_is_allowed=Mock(return_value=True),
|
||||
_ws_auth_ok=Mock(return_value=True),
|
||||
),
|
||||
}
|
||||
with patch.object(plugin_api.sys, "modules", modules):
|
||||
self.assertEqual(plugin_api._dashboard_ws_guards(), guards)
|
||||
|
||||
def test_missing_or_partial_contracts_fail_closed_without_mixing(self) -> None:
|
||||
complete = SimpleNamespace(_ws_request_is_allowed=Mock(), _ws_auth_ok=Mock())
|
||||
for modules in (
|
||||
{},
|
||||
{"hermes_cli.web_server_chat": SimpleNamespace()},
|
||||
{
|
||||
"hermes_cli.web_server_chat": SimpleNamespace(_ws_auth_ok=Mock()),
|
||||
"hermes_cli.web_server": complete,
|
||||
},
|
||||
{
|
||||
"hermes_cli.web_server_chat": SimpleNamespace(
|
||||
_ws_auth_ok=True, _ws_request_is_allowed=Mock(),
|
||||
),
|
||||
"hermes_cli.web_server": complete,
|
||||
},
|
||||
{
|
||||
"hermes_cli.web_server": SimpleNamespace(_ws_auth_ok=Mock()),
|
||||
"legacy.web_server": SimpleNamespace(_ws_request_is_allowed=Mock()),
|
||||
},
|
||||
):
|
||||
with self.subTest(modules=tuple(modules)):
|
||||
with patch.object(plugin_api.sys, "modules", modules):
|
||||
self.assertIsNone(plugin_api._dashboard_ws_guards())
|
||||
|
||||
|
||||
class TransportWebSocketAdmissionTests(unittest.IsolatedAsyncioTestCase):
|
||||
class _Socket:
|
||||
def __init__(self) -> None:
|
||||
@@ -330,6 +392,30 @@ class TransportWebSocketAdmissionTests(unittest.IsolatedAsyncioTestCase):
|
||||
request_allowed.assert_called_once_with(socket)
|
||||
auth_ok.assert_called_once_with(socket)
|
||||
|
||||
async def test_request_denial_does_not_consume_ticket(self) -> None:
|
||||
socket = self._Socket()
|
||||
auth = Mock(return_value=True)
|
||||
with patch.object(plugin_api, "_dashboard_plugin_is_enabled", return_value=True), patch.object(
|
||||
plugin_api, "_dashboard_ws_guards", return_value=(lambda _ws: False, auth)
|
||||
):
|
||||
self.assertIsNone(await plugin_api._admit_transport_websocket(socket)) # type: ignore[arg-type]
|
||||
self.assertEqual(socket.closed[0], 1008) # type: ignore[index]
|
||||
auth.assert_not_called()
|
||||
|
||||
async def test_guard_exception_fails_closed_without_auth_fallback(self) -> None:
|
||||
for failing_guard in (0, 1):
|
||||
with self.subTest(failing_guard=failing_guard):
|
||||
socket = self._Socket()
|
||||
guards = [Mock(return_value=True), Mock(return_value=True)]
|
||||
guards[failing_guard].side_effect = RuntimeError("guard unavailable")
|
||||
with patch.object(plugin_api, "_dashboard_plugin_is_enabled", return_value=True), patch.object(
|
||||
plugin_api, "_dashboard_ws_guards", return_value=tuple(guards)
|
||||
):
|
||||
self.assertIsNone(await plugin_api._admit_transport_websocket(socket)) # type: ignore[arg-type]
|
||||
self.assertEqual(socket.closed[0], 1011) # type: ignore[index]
|
||||
if failing_guard == 0:
|
||||
guards[1].assert_not_called()
|
||||
|
||||
async def test_failed_dashboard_ticket_is_policy_close(self) -> None:
|
||||
socket = self._Socket()
|
||||
with patch.object(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Opt-in conformance against real Hermes Dashboard imports, never a live service.
|
||||
|
||||
Set HERMES_RELAY_TEST_UPSTREAM=1 and put a current hermes-agent checkout on
|
||||
PYTHONPATH. Missing upstream dependencies then fail rather than silently skip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from plugin.dashboard import plugin_api
|
||||
from plugin.dashboard import test_plugin_api as transport_tests
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("HERMES_RELAY_TEST_UPSTREAM") == "1", "opt-in upstream conformance")
|
||||
class UpstreamWebSocketGuardTests(unittest.TestCase):
|
||||
path = "/api/plugins/hermes-relay/transport/ws"
|
||||
|
||||
def setUp(self) -> None:
|
||||
home = Path(self.enterContext(tempfile.TemporaryDirectory()))
|
||||
self.enterContext(patch.dict(os.environ, {
|
||||
"HERMES_HOME": str(home),
|
||||
"HERMES_RELAY_DASHBOARD_PROXY_SECRET": "integration-test-secret",
|
||||
}))
|
||||
self.enterContext(patch.object(Path, "home", return_value=home))
|
||||
self.config_path = home / "config.yaml"
|
||||
self.config_path.write_text("plugins:\n enabled: [hermes-relay]\n", encoding="utf-8")
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import ws_tickets
|
||||
|
||||
self.host = web_server
|
||||
self.tickets = ws_tickets
|
||||
self.tickets._reset_for_tests()
|
||||
self.addCleanup(self.tickets._reset_for_tests)
|
||||
app = FastAPI()
|
||||
app.state.auth_required = True
|
||||
app.state.bound_host = "127.0.0.1"
|
||||
app.state.trusted_public_hosts = frozenset()
|
||||
self.enterContext(patch.object(self.host, "app", app))
|
||||
# Exercise the facade discovery function and real enabled/disabled config
|
||||
# readers, but do not discover or execute the operator's installed plugins.
|
||||
self.enterContext(patch.object(self.host, "_dashboard_plugins_cache", [{
|
||||
"name": "hermes-relay", "source": "user", "_dir": str(home),
|
||||
}]))
|
||||
self.assertTrue(plugin_api._dashboard_plugin_is_enabled())
|
||||
app.include_router(plugin_api.router, prefix="/api/plugins/hermes-relay")
|
||||
self.client = self.enterContext(TestClient(
|
||||
app, base_url="http://127.0.0.1", headers={"host": "127.0.0.1"},
|
||||
))
|
||||
self.relay = transport_tests._EphemeralRelay()
|
||||
self.relay.start()
|
||||
self.addCleanup(self.relay.stop)
|
||||
self.enterContext(patch.object(plugin_api, "RELAY_PORT", self.relay.port))
|
||||
|
||||
def _ticket(self) -> str:
|
||||
return self.tickets.mint_ticket(user_id="test-user", provider="test-provider")
|
||||
|
||||
def _assert_denied(self, path: str, **kwargs: Any) -> None:
|
||||
# No connection to the loopback Relay is permitted on a failed guard.
|
||||
with patch.object(plugin_api.aiohttp, "ClientSession", side_effect=AssertionError("Relay reached")):
|
||||
with self.assertRaises(WebSocketDisconnect) as denied:
|
||||
with self.client.websocket_connect(path, **kwargs):
|
||||
pass
|
||||
self.assertEqual(denied.exception.code, 1008)
|
||||
|
||||
def test_real_ticket_pairing_reconnect_subprotocol_and_replay(self) -> None:
|
||||
self.assertTrue(self.relay.server.pairing.register_code("ABC123"))
|
||||
ticket = self._ticket()
|
||||
with self.client.websocket_connect(f"{self.path}?ticket={ticket}") as socket:
|
||||
socket.send_json(transport_tests.TransportWebSocketIntegrationTests._auth_payload(pairing_code="ABC123"))
|
||||
paired = socket.receive_json()
|
||||
self.assertEqual(paired["type"], "auth.ok")
|
||||
session_token = paired["payload"]["session_token"]
|
||||
self._assert_denied(f"{self.path}?ticket={ticket}")
|
||||
|
||||
protocols = ["hermes-gateway-v1", f"hermes-gateway-ticket.{self._ticket()}"]
|
||||
with self.client.websocket_connect(self.path, subprotocols=protocols) as socket:
|
||||
self.assertEqual(socket.accepted_subprotocol, "hermes-gateway-v1")
|
||||
socket.send_json(transport_tests.TransportWebSocketIntegrationTests._auth_payload(session_token=session_token))
|
||||
self.assertEqual(socket.receive_json()["type"], "auth.ok")
|
||||
self._assert_denied(self.path, subprotocols=protocols)
|
||||
|
||||
# Dashboard admission must not replace the inner Relay session boundary.
|
||||
with self.client.websocket_connect(f"{self.path}?ticket={self._ticket()}") as socket:
|
||||
socket.send_json(transport_tests.TransportWebSocketIntegrationTests._auth_payload(session_token="invalid"))
|
||||
self.assertEqual(socket.receive_json()["type"], "auth.fail")
|
||||
|
||||
def test_real_ungated_token_keeps_loopback_peer_policy(self) -> None:
|
||||
self.host.app.state.auth_required = False
|
||||
path = f"{self.path}?token={self.host._SESSION_TOKEN}"
|
||||
with self.client.websocket_connect(path) as socket:
|
||||
socket.send_json(transport_tests.TransportWebSocketIntegrationTests._auth_payload(session_token="invalid"))
|
||||
self.assertEqual(socket.receive_json()["type"], "auth.fail")
|
||||
with TestClient(
|
||||
self.host.app, headers={"host": "127.0.0.1"}, client=("203.0.113.8", 12345),
|
||||
) as remote_client, patch.object(
|
||||
plugin_api.aiohttp, "ClientSession", side_effect=AssertionError("Relay reached"),
|
||||
):
|
||||
with self.assertRaises(WebSocketDisconnect) as denied:
|
||||
with remote_client.websocket_connect(path):
|
||||
pass
|
||||
self.assertEqual(denied.exception.code, 1008)
|
||||
|
||||
def test_real_policy_and_plugin_gate_reject_before_ticket_consumption(self) -> None:
|
||||
for headers in ({"host": "untrusted.example"}, {"origin": "https://untrusted.example"}):
|
||||
with self.subTest(headers=headers):
|
||||
ticket = self._ticket()
|
||||
self._assert_denied(f"{self.path}?ticket={ticket}", headers=headers)
|
||||
self.assertEqual(self.tickets.consume_ticket(ticket)["user_id"], "test-user")
|
||||
|
||||
self._assert_denied(self.path)
|
||||
self._assert_denied(f"{self.path}?ticket=invalid")
|
||||
with patch.object(self.tickets.time, "time", return_value=0):
|
||||
expired = self._ticket()
|
||||
self._assert_denied(f"{self.path}?ticket={expired}")
|
||||
|
||||
for config in (
|
||||
"plugins:\n enabled: []\n",
|
||||
"plugins:\n enabled: [hermes-relay]\n disabled: [hermes-relay]\n",
|
||||
):
|
||||
self.config_path.write_text(config, encoding="utf-8")
|
||||
self.assertFalse(plugin_api._dashboard_plugin_is_enabled())
|
||||
ticket = self._ticket()
|
||||
self._assert_denied(f"{self.path}?ticket={ticket}")
|
||||
self.assertEqual(self.tickets.consume_ticket(ticket)["user_id"], "test-user")
|
||||
@@ -2,7 +2,7 @@ name: hermes-relay
|
||||
# Temporary v1 shim for Hermes installers that reject manifests the runtime supports; see docs/project/TODO.md.
|
||||
manifest_version: 1
|
||||
api_version: 1
|
||||
version: 1.11.2
|
||||
version: 1.11.3
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
license: MIT
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.11.2"
|
||||
__version__ = "1.11.3"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "1.11.2"
|
||||
version = "1.11.3"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -19,7 +19,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DEFAULT_PACKAGE = "com.axiomlabs.hermesrelay.sideload"
|
||||
DEFAULT_PACKAGE = "com.axiomlabs.hermesrelay"
|
||||
REMOTE_UI = "/sdcard/hermes-fgs-demo.xml"
|
||||
REMOTE_VIDEO = "/sdcard/hermes-fgs-demo.mp4"
|
||||
|
||||
@@ -420,6 +420,8 @@ def main() -> int:
|
||||
demo.tap_selector(description="Start voice conversation", settle=1)
|
||||
demo.tap_selector(description="Expand voice controls", settle=0.7)
|
||||
demo.tap_selector(text="Overlay", settle=1.5)
|
||||
# Grant required access manually before recording; never automate consent.
|
||||
demo.tap_selector(text="Start voice overlay", settle=1.5)
|
||||
demo.home()
|
||||
demo.pause(3)
|
||||
nodes, _ = demo.wait_for_notification("Hermes voice overlay active")
|
||||
|
||||
@@ -21,6 +21,12 @@ import sys
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
FOCUSED_TESTS = (
|
||||
"com.hermesandroid.relay.viewmodel.InjectedContextTest",
|
||||
"com.hermesandroid.relay.screenshots.InjectedContextSheetTest",
|
||||
"com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest.injectedContextPreviewMatchesBareGatewayPayload",
|
||||
"com.hermesandroid.relay.voice.VoiceOverlayLifecycleTest",
|
||||
"com.hermesandroid.relay.voice.VoiceOverlayForegroundServiceTest",
|
||||
"com.hermesandroid.relay.voice.VoiceOverlayPresentationTest",
|
||||
"com.hermesandroid.relay.network.ArchitectureBoundaryTest",
|
||||
"com.hermesandroid.relay.network.relay.RelayUrlDeriverTest",
|
||||
"com.hermesandroid.relay.viewmodel.ConnectionSwitchTest",
|
||||
@@ -45,6 +51,7 @@ RELEASE_PREP_TESTS = (
|
||||
"com.hermesandroid.relay.screenshots.WhatsNewToastScreenshotTest",
|
||||
)
|
||||
REPOSITORY_CHECKS = (
|
||||
"check-android-capabilities.py",
|
||||
"check-android-locales.py",
|
||||
"check-user-docs-locales.py",
|
||||
"check-android-collection-apis.py",
|
||||
|
||||