Compare commits

...
Author SHA1 Message Date
Bailey Dixon 284f19d62d Merge origin/dev into fix/android-gateway-onboarding 2026-09-23 09:29:13 -04:00
Bailey Dixon 1302da4846 Merge pull request #623 from Codename-11/fix/android-onnxruntime-compatibility
fix(android): restore Sherpa ONNX compatibility
2026-09-23 09:28:39 -04:00
Bailey Dixon ce5f9c7c98 fix(android): restore Sherpa ONNX compatibility 2026-09-23 09:16:33 -04:00
Bailey Dixon ad4e9d7b81 Merge origin/dev into fix/android-gateway-onboarding 2026-09-23 08:50:52 -04:00
Bailey Dixon 774ab90ad5 fix(android): clarify Gateway onboarding 2026-09-23 08:50:40 -04:00
dependabot[bot] a1cc34e649 chore(deps): bump com.microsoft.onnxruntime:onnxruntime-android (#613)
Bumps [com.microsoft.onnxruntime:onnxruntime-android](https://github.com/microsoft/onnxruntime) from 1.27.0 to 1.30.0.
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseNotesWorkflow.md)
- [Commits](https://github.com/microsoft/onnxruntime/compare/v1.27.0...v1.30.0)

---
updated-dependencies:
- dependency-name: com.microsoft.onnxruntime:onnxruntime-android
  dependency-version: 1.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 11:57:05 +00:00
dependabot[bot] e7f882f8b5 chore(deps): bump coil from 3.6.2 to 3.6.3 (#611)
Bumps `coil` from 3.6.2 to 3.6.3.

Updates `io.coil-kt.coil3:coil-compose` from 3.6.2 to 3.6.3
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.6.2...3.6.3)

Updates `io.coil-kt.coil3:coil-gif` from 3.6.2 to 3.6.3
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.6.2...3.6.3)

Updates `io.coil-kt.coil3:coil-network-okhttp` from 3.6.2 to 3.6.3
- [Release notes](https://github.com/coil-kt/coil/releases)
- [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/coil-kt/coil/compare/3.6.2...3.6.3)

---
updated-dependencies:
- dependency-name: io.coil-kt.coil3:coil-compose
  dependency-version: 3.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.coil-kt.coil3:coil-gif
  dependency-version: 3.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.coil-kt.coil3:coil-network-okhttp
  dependency-version: 3.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 11:56:31 +00:00
Bailey Dixon 971a9a7e39 Merge pull request #605 from Codename-11/fix/android-gateway-foreground-start
fix(android): settle Gateway foreground starts before stopping
2026-09-18 20:46:18 -04:00
Bailey Dixon 635aaa44e7 fix(android): settle Gateway foreground starts before stopping 2026-09-18 17:09:41 -04:00
Bailey Dixon e088469dbf Merge pull request #602 from Codename-11/fix/545-standard-voice-completions
fix(android): speak unsolicited Gateway completions in Standard Voice
2026-09-18 16:23:21 -04:00
Bailey Dixon 2d202bdeac test(android): await Gateway recovery and catalog callbacks 2026-09-14 20:31:02 -04:00
Bailey Dixon 798441c1e1 fix(android): speak unsolicited Gateway completions in Standard Voice 2026-09-14 20:15:35 -04:00
Bailey Dixon b424678f44 Merge pull request #601 from Codename-11/release/android-1.17.0-plugin-1.11.3
docs: confirm Android 1.17.0 Play publication
2026-09-14 16:31:22 -04:00
Bailey Dixon d5210bcd5e docs: confirm Android 1.17.0 Play publication 2026-09-14 16:27:42 -04:00
Bailey Dixon 6458a854a8 chore: refresh release publication record 2026-09-14 16:27:40 -04:00
Bailey Dixon 65fe37a2ba Merge pull request #600 from Codename-11/release/android-1.17.0-plugin-1.11.3
docs: record Android 1.17.0 and Plugin 1.11.3 verification
2026-09-14 16:12:52 -04:00
Bailey Dixon 9ac10cf645 docs: record Android and Plugin release verification 2026-09-14 16:11:10 -04:00
Bailey Dixon bd904e26e4 chore: refresh release documentation against dev 2026-09-14 16:11:07 -04:00
dependabot[bot] ba5a2c82a5 chore(deps): bump kotlin from 2.4.10 to 2.4.20 (#597)
Bumps `kotlin` from 2.4.10 to 2.4.20.

Updates `org.jetbrains.kotlin.plugin.compose` from 2.4.10 to 2.4.20
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.4.10...v2.4.20)

Updates `org.jetbrains.kotlin.plugin.serialization` from 2.4.10 to 2.4.20
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v2.4.10...v2.4.20)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.plugin.compose
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin.plugin.serialization
  dependency-version: 2.4.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 12:07:03 +00:00
dependabot[bot] 6119f3ba79 chore(deps): bump androidx.navigation:navigation-compose (#596)
Bumps androidx.navigation:navigation-compose from 2.10.0 to 2.10.1.

---
updated-dependencies:
- dependency-name: androidx.navigation:navigation-compose
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 12:02:29 +00:00
dependabot[bot] 2bcdca09e9 chore(deps): bump org.robolectric:robolectric from 4.16.1 to 4.17 (#599)
Bumps [org.robolectric:robolectric](https://github.com/robolectric/robolectric) from 4.16.1 to 4.17.
- [Release notes](https://github.com/robolectric/robolectric/releases)
- [Commits](https://github.com/robolectric/robolectric/compare/robolectric-4.16.1...robolectric-4.17)

---
updated-dependencies:
- dependency-name: org.robolectric:robolectric
  dependency-version: '4.17'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 12:00:57 +00:00
dependabot[bot] e2e9cc72b7 chore(deps): bump media3 from 1.11.0 to 1.11.1 (#598)
Bumps `media3` from 1.11.0 to 1.11.1.

Updates `androidx.media3:media3-exoplayer` from 1.11.0 to 1.11.1
- [Release notes](https://github.com/androidx/media/releases)
- [Changelog](https://github.com/androidx/media/blob/release/RELEASENOTES.md)
- [Commits](https://github.com/androidx/media/compare/1.11.0...1.11.1)

Updates `androidx.media3:media3-ui-compose` from 1.11.0 to 1.11.1
- [Release notes](https://github.com/androidx/media/releases)
- [Changelog](https://github.com/androidx/media/blob/release/RELEASENOTES.md)
- [Commits](https://github.com/androidx/media/compare/1.11.0...1.11.1)

---
updated-dependencies:
- dependency-name: androidx.media3:media3-exoplayer
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.media3:media3-ui-compose
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 12:00:05 +00:00
dependabot[bot] ca555fd617 chore(deps): bump the testing group with 2 updates (#595)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.73.0 to 1.74.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.73.0...1.74.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.73.0 to 1.74.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.73.0...1.74.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.74.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.74.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 11:56:01 +00:00
dependabot[bot] 3b5e61e149 chore(deps): bump androidx.compose:compose-bom in the compose group (#594)
Bumps the compose group with 1 update: androidx.compose:compose-bom.


Updates `androidx.compose:compose-bom` from 2026.08.00 to 2026.09.00

---
updated-dependencies:
- dependency-name: androidx.compose:compose-bom
  dependency-version: 2026.09.00
  dependency-type: direct:production
  dependency-group: compose
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 11:55:18 +00:00
Bailey Dixon 1d25985aca Merge pull request #592 from Codename-11/release/android-1.17.0-play-notes
release(android): reconcile Chinese Play notes for 1.17.0
2026-09-13 21:38:29 -04:00
Bailey Dixon 500cc83de6 release(android): reconcile Chinese Play notes for 1.17.0 2026-09-13 21:26:31 -04:00
Bailey Dixon 7d3c761c6a Merge pull request #590 from Codename-11/release/android-1.17.0-plugin-1.11.3
release: prepare Android 1.17.0 and Plugin 1.11.3
2026-09-13 21:25:20 -04:00
Bailey Dixon 2e097035fb release(android): android-v1.17.0 and server-v1.11.3 2026-09-13 21:14:30 -04:00
Bailey Dixon 280c20dbca Merge pull request #585 from Codename-11/fix/android-batch-clarify
fix(android): support batch Clarify and simplify chat cards
2026-09-13 20:33:18 -04:00
86 changed files with 2566 additions and 687 deletions
+3
View File
@@ -188,6 +188,9 @@ jobs:
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
--tests com.hermesandroid.relay.data.AppLanguageTest \
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
--tests com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest \
--tests com.hermesandroid.relay.viewmodel.VoiceInboundCompletionTest \
--tests com.hermesandroid.relay.voice.VoiceViewModelBargeInTest \
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
+14 -1
View File
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- Android Gateway onboarding verifies Dashboard access without overstating Chat or voice readiness, explains common authentication setup failures, and requires exact-address consent before using HTTP. Custom Dashboard ports are accepted and shown throughout setup and route editing. (#604)
- Android safely settles Gateway foreground-service starts before stopping local retention, preventing the startup/shutdown race reported in #603. Turning off always-on connectivity preserves active turns.
- Android Standard Voice speaks live background completions in its active conversation after the original reply finishes. Stop and conversation changes discard pending speech. (#545)
## [Android 1.17.0] - 2026-09-13
### Added
- Optional voice controls over other apps in Google Play, with contextual permission setup, a persistent Stop voice notification, and session shutdown on screen lock or permission loss. Phone control remains sideload-only.
@@ -15,9 +23,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- 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)
- 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 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
+3 -6
View File
@@ -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
+20 -8
View File
@@ -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.
+2 -2
View File
@@ -458,8 +458,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.74.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.74.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -0,0 +1,111 @@
package com.hermesandroid.relay.network.upstream
import android.app.ActivityManager
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.SystemClock
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.test.platform.app.InstrumentationRegistry
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.data.setGatewayKeepAlive
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** Real ActivityManager/notification lifecycle; no Gateway or personal data. */
class GatewayKeepAliveServiceInstrumentedTest {
@get:Rule val activity = createAndroidComposeRule<ComponentActivity>()
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context get() = instrumentation.targetContext
@Before fun setup() {
if (Build.VERSION.SDK_INT >= 33) {
instrumentation.uiAutomation.executeShellCommand(
"pm grant ${context.packageName} android.permission.POST_NOTIFICATIONS",
).close()
}
runBlocking { context.setGatewayKeepAlive(false) }
}
@After fun cleanup() {
instrumentation.runOnMainSync {
GatewayKeepAliveService.stop(context)
ActiveTurnKeepAliveRegistry.releaseAll()
}
await("service shutdown") { serviceState() == null }
runBlocking { context.setGatewayKeepAlive(false) }
}
@Test fun immediateStopsAndOverlappingStartsSurviveThePlatformWatchdog() {
// All changes happen before Android can dispatch onCreate/onStartCommand.
instrumentation.runOnMainSync {
repeat(25) {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
GatewayKeepAliveService.stop(context)
}
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
}
await("foreground promotion") { serviceState()?.foreground == true }
instrumentation.runOnMainSync { GatewayKeepAliveService.stop(context) }
await("settled shutdown") { serviceState() == null }
// Observation window, not a startup workaround: an asynchronous system
// foreground-start crash fails the instrumentation process during it.
CountDownLatch(1).await(12, TimeUnit.SECONDS)
assertTrue(serviceState() == null)
}
@Test fun notificationDisablesAlwaysOnWhileANewerTurnStaysProtected() {
runBlocking { context.setGatewayKeepAlive(true) }
instrumentation.runOnMainSync {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
}
val manager = context.getSystemService(NotificationManager::class.java)
await("persistent notification") {
manager.activeNotifications.any { it.id == GatewayKeepAliveService.NOTIFICATION_ID }
}
val action = manager.activeNotifications.single {
it.id == GatewayKeepAliveService.NOTIFICATION_ID
}.notification.actions.single().actionIntent
instrumentation.runOnMainSync {
ActiveTurnKeepAliveRegistry.acquire("fixture::profile-a::session")
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
}
action.send()
await("persisted notification action") {
runBlocking { context.relayDataStore.data.first()[KEY_GATEWAY_KEEP_ALIVE] == false }
}
instrumentation.runOnMainSync {
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
}
assertTrue(serviceState()?.foreground == true)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
await("active-turn notification without always-on action") {
manager.activeNotifications.singleOrNull {
it.id == GatewayKeepAliveService.NOTIFICATION_ID
}?.notification?.let { it.actions.isNullOrEmpty() } == true
}
}
@Suppress("DEPRECATION")
private fun serviceState(): ActivityManager.RunningServiceInfo? =
context.getSystemService(ActivityManager::class.java).getRunningServices(100)
.singleOrNull { it.service.className == GatewayKeepAliveService::class.java.name }
private fun await(description: String, condition: () -> Boolean) {
val deadline = SystemClock.uptimeMillis() + 10_000
while (!condition() && SystemClock.uptimeMillis() < deadline) {
instrumentation.waitForIdleSync()
SystemClock.sleep(20)
}
assertTrue(description, condition())
}
}
@@ -3,230 +3,97 @@ package com.hermesandroid.relay.ui.onboarding
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextReplacement
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import org.junit.Rule
import org.junit.Test
/**
* Instrumented tests for the Standard-first onboarding pager.
*/
/** Standard setup stays separate from Direct API and optional Relay grants. */
class OnboardingFlowTest {
@get:Rule val compose = createComposeRule()
@get:Rule
val composeTestRule = createComposeRule()
private fun setOnboardingContent() {
val app = ApplicationProvider.getApplicationContext<Application>()
val connectionViewModel = ConnectionViewModel(app)
composeTestRule.setContent {
HermesRelayTheme {
OnboardingScreen(
connectionViewModel = connectionViewModel,
onComplete = {},
)
}
}
private fun start() {
val model = ConnectionViewModel(ApplicationProvider.getApplicationContext<Application>())
compose.setContent { HermesRelayTheme { OnboardingScreen(model, onComplete = {}) } }
}
@Test
fun firstPage_showsHermesForAndroidTitle() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Hermes-Relay for Android")
.assertIsDisplayed()
private fun connectPage() {
compose.onNodeWithText("Get started").performClick()
repeat(3) { compose.onNodeWithText("Next").performClick(); compose.waitForIdle() }
}
@Test
fun firstPage_showsStandardFirstDescription() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Chat with Hermes and manage your dashboard from your phone.")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Standard")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Advanced")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Setup Guide")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Hermes Docs")
.assertIsDisplayed()
@Test fun welcomeOffersStartAndDemo() {
start()
compose.onNodeWithText("Hermes,\nin your pocket").assertIsDisplayed()
compose.onNodeWithText("Get started").assertIsDisplayed().assertIsEnabled()
compose.onNodeWithText("Try the demo").assertIsDisplayed().assertIsEnabled()
compose.onNodeWithText("Back").assertDoesNotExist()
}
@Test
fun nextButton_navigatesForward_toChatPage() {
setOnboardingContent()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Chat")
.assertIsDisplayed()
@Test fun introNavigationAndSkipRemainAvailable() {
start()
compose.onNodeWithText("Get started").performClick()
compose.onNodeWithText("Chat").assertIsDisplayed()
compose.onNodeWithText("Back").performClick()
compose.onNodeWithText("Get started").assertIsDisplayed()
compose.onNodeWithText("Get started").performClick()
compose.onNodeWithText("Skip").performClick()
compose.onNodeWithText("Skip setup?").assertIsDisplayed()
compose.onNodeWithText("Go back").performClick()
compose.onNodeWithText("Chat").assertIsDisplayed()
}
@Test
fun canNavigateForward_throughStandardAndPowerPages() {
setOnboardingContent()
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Manage").assertIsDisplayed()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Power tools").assertIsDisplayed()
composeTestRule.onNodeWithText("Connect").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Add gateway").assertIsDisplayed()
@Test fun standardMethodsDoNotAskForApiCredentials() {
start(); connectPage()
compose.onNodeWithText("Hermes nearby").assertIsDisplayed()
compose.onNodeWithText("Remote gateway").assertIsDisplayed().performClick()
compose.onNodeWithText("Hermes address").assertIsDisplayed()
compose.onNodeWithText("API key").assertDoesNotExist()
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsDisplayed()
}
@Test
fun backButton_hiddenOnFirstPage() {
setOnboardingContent()
composeTestRule
.onNodeWithText("Back")
.assertDoesNotExist()
@Test fun publicHttpConsentResetsWhenAddressChanges() {
start(); connectPage()
compose.onNodeWithText("Remote gateway").performClick()
compose.onNodeWithText("Hermes address").performTextReplacement("http://11.0.0.1:9119")
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsNotEnabled()
compose.onNodeWithText("I accept the risk and allow HTTP for this address").performScrollTo().performClick()
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsEnabled()
compose.onNodeWithText("Hermes address").performScrollTo().performTextReplacement("http://11.0.0.1:9120")
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsNotEnabled()
}
@Test
fun backButton_navigatesBackward() {
setOnboardingContent()
composeTestRule.onNodeWithText("Next").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Chat").assertIsDisplayed()
composeTestRule.onNodeWithText("Back").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Hermes-Relay for Android").assertIsDisplayed()
@Test fun advancedKeepsApiAndRelaySeparate() {
start(); connectPage()
compose.onNodeWithText("Advanced").performScrollTo().performClick()
compose.onNodeWithText("API-only connection").assertIsDisplayed()
compose.onNodeWithText("Pair Relay by code").performScrollTo().assertIsDisplayed()
}
@Test
fun addGatewayPage_leadsWithStandardGatewayMethods() {
setOnboardingContent()
navigateToPage(4)
composeTestRule
.onNodeWithText("Hermes nearby")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Remote gateway")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Scan Hermes setup QR")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Recommended")
.assertDoesNotExist()
@Test fun setupSkipIsScrollReachable() {
start(); connectPage()
compose.onNodeWithText("Skip for now — set up later in Settings").performScrollTo().assertIsDisplayed().performClick()
compose.onNodeWithText("Skip setup?").assertIsDisplayed()
}
@Test
fun manualSetup_showsHermesAddressWithoutApiCredentials() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Remote gateway").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Hermes address")
.assertIsDisplayed()
@Test fun hostedGatewayKeepsItsSeparateAddressEntry() {
start(); connectPage()
compose.onNodeWithText("Nous-hosted Hermes").performClick()
compose.onNodeWithText("Connect to Nous-hosted Hermes").assertIsDisplayed()
compose.onNodeWithText("Find Hermes").performScrollTo().assertIsNotEnabled()
}
@Test
fun manualSetup_findButton_isShown() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Remote gateway").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Find Hermes")
.assertIsDisplayed()
}
@Test
fun cloudSetup_requestsTheHostedDashboardAddress() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Nous-hosted Hermes").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Connect to Nous-hosted Hermes")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Use the complete HTTPS address shown for your hosted agent.")
.assertIsDisplayed()
}
@Test
fun addGatewayPage_keepsPairingOptional() {
setOnboardingContent()
navigateToPage(4)
composeTestRule.onNodeWithText("Advanced").performClick()
composeTestRule.waitForIdle()
composeTestRule
.onNodeWithText("Pair Relay by code")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Power-user path for Terminal, Bridge, Relay sessions, and grants")
.assertIsDisplayed()
}
@Test
fun powerPage_linksToPermissionReview() {
setOnboardingContent()
navigateToPage(3)
composeTestRule
.onNodeWithText("Review permissions")
.assertIsDisplayed()
.assertIsEnabled()
}
@Test
fun skipButton_visibleOnIntroPages_andWizardSkipOnAddGatewayPage() {
setOnboardingContent()
repeat(4) {
composeTestRule.onNodeWithText("Skip").assertIsDisplayed()
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
composeTestRule.waitForIdle()
}
composeTestRule
.onNodeWithText("Skip for now — set up later in Settings")
.assertIsDisplayed()
}
private fun navigateToPage(pageIndex: Int) {
repeat(pageIndex) {
composeTestRule.onNodeWithText(if (it == 3) "Connect" else "Next").performClick()
composeTestRule.waitForIdle()
}
@Test fun optionalPowerPermissionsRemainReachable() {
start()
compose.onNodeWithText("Get started").performClick()
repeat(2) { compose.onNodeWithText("Next").performClick(); compose.waitForIdle() }
compose.onNodeWithText("Review permissions").performScrollTo().assertIsDisplayed().assertIsEnabled()
}
}
@@ -62,9 +62,17 @@ class GatewayExternalFixtureInstrumentedTest {
private var gatewayScope: CoroutineScope? = null
private var gatewayClient: GatewayChatClient? = null
private var viewModel: ChatViewModel? = null
private var voiceViewModel: VoiceViewModel? = null
private var voicePlayer: com.hermesandroid.relay.audio.VoicePlayer? = null
private var voiceSfx: com.hermesandroid.relay.audio.VoiceSfxPlayer? = null
@After
fun tearDown() {
compose.runOnUiThread {
voiceViewModel?.exitVoiceMode()
voicePlayer?.release()
voiceSfx?.release()
}
viewModel?.updateGatewayClient(null)
gatewayClient?.shutdown()
gatewayScope?.cancel()
@@ -250,6 +258,80 @@ class GatewayExternalFixtureInstrumentedTest {
assertEquals("gateway", vm.streamingEndpoint)
}
@Test
fun unsolicitedVoiceCompletions_surviveActivityPauseWithoutHistorySpeech() {
val base = InstrumentationRegistry.getArguments().getString(ARG_FIXTURE_BASE_URL)
?.trim()?.trimEnd('/')
assumeTrue("Pass the unsolicited_voice_completions fixture URL", !base.isNullOrBlank())
requireNotNull(base)
val http = OkHttpClient.Builder().callTimeout(10, TimeUnit.SECONDS).build()
assertEquals("unsolicited_voice_completions", readFixtureJson(http, "$base/__fixture__/state")["scenario"]?.jsonString())
val dashboard = DashboardApiClient(base, http)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { gatewayScope = it }
val gateway = GatewayChatClient(
initialDashboardClient = dashboard, okHttpClient = http,
callbackDispatcher = { Handler(Looper.getMainLooper()).post(it) }, scope = scope,
).also { gatewayClient = it }
val handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
val spoken = java.util.concurrent.CopyOnWriteArrayList<String>()
lateinit var vm: ChatViewModel
compose.runOnUiThread {
val app = compose.activity.application
vm = ChatViewModel().also {
it.initialize(null, handler)
it.streamingEndpoint = "gateway"
it.setProfileMessageLoaderWithMode { profile, id, mode ->
dashboard.getSessionMessages(id, profile, mode)
}
it.updateGatewayClient(gateway)
viewModel = it
}
val audio = object : com.hermesandroid.relay.network.shared.VoiceAudioClient {
override val route = com.hermesandroid.relay.data.VoiceAudioRoute.Standard
override suspend fun transcribe(audioFile: java.io.File) = Result.success("")
override suspend fun synthesize(text: String): Result<java.io.File> {
spoken.add(text)
// A short silent WAV exercises the production play/drain path without a provider.
val pcm = ByteArray(3200)
val header = java.nio.ByteBuffer.allocate(44).order(java.nio.ByteOrder.LITTLE_ENDIAN)
.put("RIFF".toByteArray()).putInt(36 + pcm.size).put("WAVEfmt ".toByteArray())
.putInt(16).putShort(1).putShort(1).putInt(16000).putInt(32000)
.putShort(2).putShort(16).put("data".toByteArray()).putInt(pcm.size).array()
val file = java.io.File.createTempFile("fixture-voice", ".wav", app.cacheDir)
file.writeBytes(header + pcm)
return Result.success(file)
}
}
val player = com.hermesandroid.relay.audio.VoicePlayer(app).also { voicePlayer = it }
val sfx = com.hermesandroid.relay.audio.VoiceSfxPlayer(app).also { voiceSfx = it }
voiceViewModel = VoiceViewModel(app).also {
it.initialize(
voiceClient = com.hermesandroid.relay.network.relay.RelayVoiceClient(app, http, { null }, { null }),
voiceAudioClient = audio, chatViewModel = vm,
recorder = com.hermesandroid.relay.audio.VoiceRecorder(app, scope),
player = player, sfxPlayer = sfx,
)
it.enterVoiceMode()
}
}
compose.setContent {
val messages by vm.messages.collectAsStateWithLifecycle()
Text(messages.joinToString("\n") { it.content }, Modifier.testTag("voice-fixture-history"))
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
compose.runOnUiThread { vm.sendMessage("Start background work.") }
compose.waitUntil(10_000) { handler.messages.value.any { it.content == "Work started." } }
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.STARTED)
compose.waitUntil(15_000) { spoken.size == 3 }
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
compose.runOnUiThread { voiceViewModel?.onAppResumed() }
compose.waitForIdle()
assertEquals(listOf("Process finished.", "Watch matched.", "Delegated work finished."), spoken.toList())
assertEquals(1, readFixtureJson(http, "$base/__fixture__/evidence")["entries"].let { it as JsonArray }.rpcCount("prompt.submit"))
assertTrue(handler.messages.value.any { it.content == "Delegated work finished." })
assertEquals("gateway", vm.streamingEndpoint)
}
private fun JsonArray.rpcCount(method: String): Int = count { element ->
val entry = element as? JsonObject ?: return@count false
entry["kind"]?.jsonString() == "rpc" && entry["method"]?.jsonString() == method
@@ -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 批量问题,重连后保留已确认的进度。聊天卡片更简洁,配置文件显示名称更易辨认,上下文预览准确说明当前连接支持发送哪些信息。设备控制仍仅限侧载版本。
+19
View File
@@ -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",
+13 -5
View File
@@ -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.
@@ -44,7 +44,9 @@ val Connection.automaticChatTransport: SessionTransport
get() {
val dashboardPersisted = !dashboardUrl.isNullOrBlank() ||
!authenticatedDashboardOrigin.isNullOrBlank()
return if (dashboardPersisted) SessionTransport.GATEWAY else SessionTransport.SSE
// An empty first-setup placeholder is standard Gateway intent, not an
// API-only conversation. Only an actual API endpoint selects legacy SSE.
return if (dashboardPersisted || apiServerUrl.isBlank()) SessionTransport.GATEWAY else SessionTransport.SSE
}
fun Connection.chatTransportForPreference(preference: String): SessionTransport =
@@ -67,6 +67,8 @@ data class Connection(
* "derive from [apiServerUrl] using the conventional same-host :9119".
*/
val dashboardUrl: String? = null,
/** User-accepted cleartext origins. This does not assert or monitor VPN protection. */
val dashboardHttpConsentOrigins: Set<String> = emptySet(),
/**
* Credential-free origin that most recently completed Dashboard
* authentication for this connection. Public origins require HTTPS;
@@ -116,7 +118,7 @@ data class Connection(
*/
val resolvedDashboardUrl: String
get() = authenticatedDashboardOrigin
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
?.let { normalizeCredentialFreeAuthenticatedDashboardOrigin(it, dashboardHttpConsentOrigins) }
?: configuredDashboardUrl
/** Stable display/host identity that does not depend on the API surface. */
@@ -628,7 +630,10 @@ internal fun normalizeCredentialFreeHttpsOrigin(raw: String): String? {
* HTTPS; cleartext is accepted only for literal loopback, RFC1918/link-local,
* or Tailscale CGNAT addresses.
*/
internal fun normalizeCredentialFreeAuthenticatedDashboardOrigin(raw: String): String? {
internal fun normalizeCredentialFreeAuthenticatedDashboardOrigin(
raw: String,
httpConsentOrigins: Set<String> = emptySet(),
): String? {
normalizeCredentialFreeHttpsOrigin(raw)?.let { return it }
val parsed = runCatching { URI(raw.trim()) }.getOrNull() ?: return null
if (!parsed.scheme.equals("http", ignoreCase = true)) return null
@@ -651,6 +656,6 @@ internal fun normalizeCredentialFreeAuthenticatedDashboardOrigin(raw: String): S
else -> false
}
}
if (!trustedHost) return null
if (!trustedHost && !dashboardHttpConsentMatches(raw, httpConsentOrigins)) return null
return parsed.normalize().toASCIIString().trimEnd('/').takeIf { it.isNotBlank() }
}
@@ -581,7 +581,7 @@ internal fun Connection.withDashboardDefaults(): Connection {
it.role.equals(LEGACY_AUTHENTICATED_DASHBOARD_ROUTE_ROLE, ignoreCase = true)
}
val migratedAuthenticatedOrigin = authenticatedDashboardOrigin
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
?.let { normalizeCredentialFreeAuthenticatedDashboardOrigin(it, dashboardHttpConsentOrigins) }
?: legacyAuthenticatedRoute?.dashboard?.url
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
val routesWithoutLegacyAuthentication = routeCandidates.filterNot {
@@ -0,0 +1,31 @@
package com.hermesandroid.relay.data
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/** An explicit HTTP exception is scoped to a connection and exact origin, never a VPN claim. */
fun dashboardHttpOrigin(address: String): String? {
val url = address.trim().toHttpUrlOrNull() ?: return null
if (url.scheme != "http" || url.username.isNotEmpty() || url.password.isNotEmpty() ||
url.query != null || url.fragment != null
) return null
return url.newBuilder().encodedPath("/").build().toString().trimEnd('/')
}
fun dashboardHttpConsentRequired(address: String): Boolean =
dashboardHttpOrigin(address) != null && Connection.inferRouteRole(address) == "public"
fun dashboardHttpConsentMatches(address: String, approvedOrigins: Set<String>): Boolean =
dashboardHttpOrigin(address)?.let { it in approvedOrigins } == true
/** Editing an origin retires its old exception; another address requires its own confirmation. */
fun updatedDashboardHttpConsents(
previous: Set<String>,
oldAddress: String?,
newAddress: String,
confirmedOrigin: String?,
): Set<String> {
val oldOrigin = oldAddress?.let(::dashboardHttpOrigin)
val newOrigin = dashboardHttpOrigin(newAddress)
val retained = if (oldOrigin != newOrigin) previous - setOfNotNull(oldOrigin) else previous
return if (newOrigin != null && confirmedOrigin == newOrigin) retained + newOrigin else retained
}
@@ -2157,9 +2157,10 @@ fun sameDashboardBase(candidate: String, trusted: String): Boolean {
fun trustedDashboardBearerAuthOrNull(
candidate: String,
trusted: String,
httpConsentOrigins: Set<String> = emptySet(),
tokenStoreProvider: () -> NativeDashboardTokenStore,
): DashboardBearerAuth? =
if (isNativeDashboardTransportEligible(candidate) &&
if (isNativeDashboardTransportEligible(candidate, httpConsentOrigins) &&
sameDashboardBase(candidate, trusted)
) {
DashboardBearerAuth(candidate, tokenStoreProvider())
@@ -0,0 +1,38 @@
package com.hermesandroid.relay.network.upstream
import java.io.IOException
data class DashboardSetupVerification(
val status: DashboardStatus,
val session: DashboardAuthSession,
val ticketAvailable: Boolean,
) {
val authenticated: Boolean get() = session.authenticated && ticketAvailable
}
/** Public status is discovery, not proof that a phone can use protected Dashboard routes. */
suspend fun DashboardApiClient.verifySetup(): DashboardSetupVerification {
val status = getStatus().getOrThrow()
var session = currentSession().getOrThrow()
val ticketAvailable = if (session.authenticated) {
val ticket = requestWsTicket()
val failure = ticket.exceptionOrNull()
if (failure?.isDashboardSignInRequiredFailure() == true) {
session = session.copy(authenticated = false)
false
} else {
ticket.getOrThrow()
true
}
} else false
if (!status.authRequired && !session.authenticated) {
throw DashboardLocalAuthenticationRequiredException()
}
return DashboardSetupVerification(status, session, ticketAvailable)
}
class DashboardLocalAuthenticationRequiredException : IOException(
"Hermes is reachable, but protected requests are not authorized. If the Dashboard is forwarded " +
"from loopback, check its bind address, authentication provider, and dashboard.public_url " +
"on the host. A 401 alone does not identify the cause.",
)
@@ -9,18 +9,23 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.annotation.MainThread
import androidx.core.app.NotificationCompat
import androidx.datastore.preferences.core.edit
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.setGatewayKeepAlive
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.data.relayDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Foreground service that holds the app process up so work the user already
@@ -48,16 +53,16 @@ import kotlinx.coroutines.launch
*
* The service's only job is to hold the process in the foreground. The socket
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
* idle-close timer while retention is required. On task removal (user swipes the app
* away) the ViewModel + socket die with the process, so the service stops
* itself rather than leave a notification that lies about being connected.
* idle-close timer while retention is required. Task removal releases local
* foreground protection; it does not terminate server-owned work or assume
* that removing a task kills the application process.
*
* # Android 15 watchdog
* # Foreground-start obligation (Android 8+)
*
* On target SDK 35 any intent to a service that declares a foregroundServiceType
* must call `startForeground` within 5s — so [onStartCommand] always does that
* first, before branching on the action. Shutdown goes through [stop]
* (`stopService`) to bypass [onStartCommand] entirely.
* An accepted startForegroundService must promote promptly, even if demand
* disappears before delivery. Never stopService a pending start: Android 12
* also treats teardown before promotion as a foreground-start failure.
* Main-thread demand is coalesced until onStartCommand acknowledges the start.
*/
class GatewayKeepAliveService : Service() {
companion object {
@@ -67,47 +72,76 @@ class GatewayKeepAliveService : Service() {
const val NOTIFICATION_ID = 4713
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
private const val EXTRA_PERSISTENT = "persistent"
private const val EXTRA_ACTIVE_TURNS = "active_turns"
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
@Volatile private var runningInstance: GatewayKeepAliveService? = null
private const val EXTRA_START_TOKEN = "start_token"
private var runningInstance: GatewayKeepAliveService? = null
private var pendingStart: String? = null
private var desiredPersistent = false
private var desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
private var wasForeground = false
private var taskRemoved = false
@Volatile private var persistentToken: String? = null
// Preference writes must survive service teardown, but never process death.
private val preferenceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@MainThread
fun update(
context: Context,
persistent: Boolean,
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
appForeground: Boolean = true,
) {
if (!persistent && !activeTurns.required) {
stop(context)
return
checkMainThread()
if (appForeground && !wasForeground) taskRemoved = false
wasForeground = appForeground
if (persistent != desiredPersistent) {
persistentToken = if (persistent) UUID.randomUUID().toString() else null
}
runningInstance?.let { service ->
service.applyState(persistent, activeTurns)
service.startForegroundNotification()
desiredPersistent = persistent
desiredTurns = activeTurns
// Keep the accepted start alive until Android delivers its command.
if (pendingStart != null) return
runningInstance?.let {
it.reconcile()
return
}
if (taskRemoved || !appForeground || (!persistent && !activeTurns.required)) return
val token = UUID.randomUUID().toString()
pendingStart = token
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
.setAction(ACTION_REFRESH)
.putExtra(EXTRA_PERSISTENT, persistent)
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
context.applicationContext.startService(intent)
.putExtra(EXTRA_START_TOKEN, token)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
context.applicationContext.startService(intent)
}
} catch (e: Exception) {
pendingStart = null
Log.w(TAG, "Foreground service launch rejected; retaining server-owned work", e)
}
}
@MainThread
fun stop(context: Context) {
// stopService() bypasses onStartCommand, so a "please shut down"
// never trips the Android 15 foreground-start watchdog.
context.applicationContext.stopService(
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
)
update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(), wasForeground)
}
private fun checkMainThread() {
check(Looper.myLooper() == Looper.getMainLooper())
}
internal fun resetForTest() {
runningInstance = null
pendingStart = null
desiredPersistent = false
desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
persistentToken = null
wasForeground = false
taskRemoved = false
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var persistent = false
private var activeTurns = 0
private var waitingSessions = 0
@@ -116,45 +150,63 @@ class GatewayKeepAliveService : Service() {
override fun onCreate() {
super.onCreate()
runningInstance = this
// No datastore, socket, coroutine or other owner work ahead of promotion.
// A cold stale notification action has no accepted foreground start.
if (pendingStart != null) {
applyState(desiredPersistent, desiredTurns)
startForegroundNotification()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val token = intent?.getStringExtra(EXTRA_START_TOKEN)
if (intent?.action == ACTION_REFRESH) {
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
.coerceIn(0, activeTurns)
}
startForegroundNotification()
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
persistent = false
if (activeTurns == 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
startForegroundNotification()
applyState(desiredPersistent, desiredTurns)
// Also promote reused service instances before acknowledging the start.
// A delivered start still owes promotion if process-local demand
// was lost or its token is stale. Never replay its old demand.
if (startForegroundNotification()) {
if (token == pendingStart) pendingStart = null
if (pendingStart == null) {
runningInstance = this
reconcile()
}
}
return START_NOT_STICKY
} else if (intent?.action == ACTION_STOP &&
intent.data?.lastPathSegment == persistentToken && persistentToken != null
) {
val actionToken = persistentToken
val context = applicationContext
preferenceScope.launch {
try {
context.relayDataStore.edit { preferences ->
// Recheck inside the serialized edit; an old action must
// not undo a subsequent disable/re-enable cycle.
if (persistentToken == actionToken) preferences[KEY_GATEWAY_KEEP_ALIVE] = false
}
} catch (e: Exception) {
Log.w(TAG, "Could not disable persistent connection", e)
}
}
// The preference collector reconciles current active-turn demand
// after persistence. Never stop from the notification's old snapshot.
}
if (runningInstance !== this && pendingStart == null) stopSelfResult(startId)
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
// The socket lives in the ViewModel, which dies when the task is
// removed — keeping the notification would be a lie. Stop cleanly.
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
ActiveTurnKeepAliveRegistry.releaseAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
taskRemoved = true
// Leases belong to chat owners. Keep them intact so a surviving
// process can protect unfinished turns again when the user returns.
// A queued new start still owes Android promotion before retirement.
if (pendingStart == null) retire()
}
override fun onDestroy() {
if (runningInstance === this) runningInstance = null
scope.cancel()
super.onDestroy()
}
@@ -164,7 +216,23 @@ class GatewayKeepAliveService : Service() {
// this foreground service (and its Gateway socket) alive. Re-post the
// existing notification so its localized title/body follow the new
// application resources without restarting either owner.
startForegroundNotification()
if (runningInstance === this) reconcile()
}
private fun reconcile() {
if (taskRemoved || (!desiredPersistent && !desiredTurns.required)) {
retire()
} else {
applyState(desiredPersistent, desiredTurns)
startForegroundNotification()
}
}
private fun retire() {
if (runningInstance === this) runningInstance = null
// Let Android remove the foreground notification with service teardown.
// Do not demote an instance while another start may be queued for it.
stopSelf()
}
private fun applyState(
@@ -181,10 +249,10 @@ class GatewayKeepAliveService : Service() {
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
// check is finicky about correlating the runtime type arg with the manifest.
@SuppressLint("ForegroundServiceType")
private fun startForegroundNotification() {
ensureChannel()
val notification = buildNotification()
private fun startForegroundNotification(): Boolean {
try {
ensureChannel()
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
@@ -194,9 +262,12 @@ class GatewayKeepAliveService : Service() {
} else {
startForeground(NOTIFICATION_ID, notification)
}
} catch (t: Throwable) {
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
stopSelf()
return true
} catch (e: Exception) {
Log.w(TAG, "Foreground notification failed; retaining server-owned work", e)
pendingStart = null
retire()
return false
}
}
@@ -208,6 +279,7 @@ class GatewayKeepAliveService : Service() {
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
.setData(Uri.parse("hermes-relay://keep-alive/$persistentToken"))
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
val (title, body) = when {
@@ -111,6 +111,8 @@ class NativeDashboardAuthClient(
private val tokenStore: NativeDashboardTokenStore,
private val client: OkHttpClient = OkHttpClient.Builder()
.dns(RetryingNativeAuthDns())
.followRedirects(false)
.followSslRedirects(false)
.retryOnConnectionFailure(false)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
@@ -390,15 +392,28 @@ internal class NativeDashboardCallbackException(
val retryable: Boolean = true,
) : IOException(message)
internal fun isNativeDashboardTransportEligible(baseUrl: String): Boolean {
internal fun isNativeDashboardTransportEligible(
baseUrl: String,
httpConsentOrigins: Set<String> = emptySet(),
): Boolean {
val url = baseUrl.trim().trimEnd('/').toHttpUrlOrNull() ?: return false
return url.scheme == "https" ||
(
url.scheme == "http" &&
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host))
(url.host == "127.0.0.1" || isPrivateNetworkLiteral(url.host) ||
com.hermesandroid.relay.data.dashboardHttpConsentMatches(baseUrl, httpConsentOrigins))
)
}
/** A cleartext exception never authorizes following a request onto another origin. */
internal fun dashboardClientWithHttpConsent(
client: OkHttpClient,
baseUrl: String,
httpConsentOrigins: Set<String>,
): OkHttpClient = if (com.hermesandroid.relay.data.dashboardHttpConsentMatches(baseUrl, httpConsentOrigins)) {
client.newBuilder().followRedirects(false).followSslRedirects(false).build()
} else client
/**
* Hermes already permits explicitly configured HTTP dashboard sessions on
* local routes. The brokered flow is no less protected than that cookie flow,
@@ -327,7 +327,8 @@ internal class HermesRuntimeBinder(
connection.serverCapabilities,
connection.gatewayAvailability,
connection.effectiveDashboardUrl,
) { preference, _, gateway, dashboardUrl ->
connection.activeConnection,
) { preference, _, gateway, dashboardUrl, _ ->
Triple(preference, gateway, dashboardUrl)
}.collectLatest { (preference, _, dashboardUrl) ->
if (
@@ -1964,7 +1964,7 @@ fun RelayApp() {
// (WhatsApp-style; see ChatScreen). That's the "can I talk to the
// agent?" signal.
// • Relay socket (bridge/terminal/relay-voice) → the bottom
// RelayStatusStrip's "Reconnecting…" cue only. It never blocks chat,
// RelayStatusStrip is reserved for the active chat owner's status.
// so it stays ambient. (`connectionReconnecting` below.)
// A routine in-progress reconnect surfaces only in the bottom strip.
// Computed off the raw status (not the dismiss-gated `toast`) because the
@@ -2031,11 +2031,12 @@ fun ActiveCardRoutesSection(
original = routeEditorOriginal,
relayEnabled = connection.relayUrl.isNotBlank() ||
endpoints.any { it.relay != null },
onSave = { role, dashboardUrl, onResult ->
onSave = { role, dashboardUrl, httpConsentOrigin, onResult ->
connectionViewModel.saveExtraRoute(
role = role,
dashboardUrl = dashboardUrl,
original = routeEditorOriginal,
httpConsentOrigin = httpConsentOrigin,
onResult = onResult,
)
},
@@ -20,6 +20,8 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -249,9 +251,10 @@ fun ConnectionWizard(
var nearbyBusy by remember { mutableStateOf(false) }
var nearbyResults by remember { mutableStateOf<List<HermesLanDiscoveryResult>>(emptyList()) }
var nearbyMessage by remember { mutableStateOf<String?>(null) }
var dashboardAddress by rememberSaveable(wizardDraftIdentity, currentDashboardUrl) {
mutableStateOf(currentDashboardUrl)
var dashboardAddress by rememberSaveable(wizardDraftIdentity) {
mutableStateOf(if (connectionDraftId != null) "" else currentDashboardUrl)
}
var dashboardHttpConsentOrigin by rememberSaveable(wizardDraftIdentity) { mutableStateOf<String?>(null) }
var dashboardProbeBusy by remember { mutableStateOf(false) }
var dashboardProbeError by remember { mutableStateOf<String?>(null) }
var dashboardProbeResult by remember {
@@ -425,16 +428,24 @@ fun ConnectionWizard(
val inspectDashboard: (String, String?) -> Unit = { address, suggestedHostname ->
dashboardAddress = address
dashboardSuggestedHostname = suggestedHostname
dashboardProbeBusy = true
dashboardProbeError = null
connectionViewModel.probeHermesDashboard(address) { result ->
dashboardProbeBusy = false
if (result.ok) {
dashboardProbeResult = result
dashboardAddress = result.dashboardUrl
step = WizardStep.DashboardFound
} else {
dashboardProbeError = result.message
val normalized = Connection.normalizeDashboardUrlInput(address)
if (com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalized) &&
dashboardHttpConsentOrigin != com.hermesandroid.relay.data.dashboardHttpOrigin(normalized)
) {
dashboardEntryIntent = DashboardEntryIntent.Server
step = WizardStep.DashboardManual
} else {
dashboardProbeBusy = true
connectionViewModel.probeHermesDashboard(address, dashboardHttpConsentOrigin) { result ->
dashboardProbeBusy = false
if (result.ok) {
dashboardProbeResult = result
dashboardAddress = result.dashboardUrl
step = WizardStep.DashboardFound
} else {
dashboardProbeError = result.message
}
}
}
}
@@ -552,6 +563,7 @@ fun ConnectionWizard(
connectionViewModel.saveDashboardConnection(
dashboardUrl = draft.dashboardUrl,
discoveredHostname = draft.discoveredHostname,
httpConsentOrigin = draft.httpConsentOrigin,
) { saved ->
standardBusy = false
saved.fold(
@@ -652,7 +664,7 @@ fun ConnectionWizard(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (step != WizardStep.Nearby) {
WizardStepIndicator(currentStep = step.indicatorIndex, method = chosenMethod)
WizardStepIndicator(currentStep = step.indicatorIndex, method = chosenMethod, directApi = step == WizardStep.StandardEntry)
}
AnimatedContent(
@@ -707,8 +719,11 @@ fun ConnectionWizard(
address = dashboardAddress,
onAddressChange = {
dashboardAddress = it
dashboardHttpConsentOrigin = null
dashboardProbeError = null
},
httpConsentOrigin = dashboardHttpConsentOrigin,
onHttpConsentChange = { dashboardHttpConsentOrigin = it },
busy = dashboardProbeBusy,
error = dashboardProbeError,
onBack = { step = WizardStep.Nearby },
@@ -729,6 +744,7 @@ fun ConnectionWizard(
dashboardUrl = result.dashboardUrl,
signInRequired = result.signInRequired,
discoveredHostname = dashboardSuggestedHostname,
httpConsentOrigin = result.httpConsentOrigin,
)
val existing = findDuplicateFor("", "", result.dashboardUrl)
if (existing != null) {
@@ -1526,6 +1542,7 @@ private data class DashboardConnectionDraft(
val dashboardUrl: String,
val signInRequired: Boolean,
val discoveredHostname: String? = null,
val httpConsentOrigin: String? = null,
)
private const val SetupGuideUrl = "https://hermes-relay.dev/docs/guide/getting-started"
@@ -1537,7 +1554,7 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
}
@Composable
private fun WizardStepIndicator(currentStep: Int, method: PairMethod) {
private fun WizardStepIndicator(currentStep: Int, method: PairMethod, directApi: Boolean = false) {
val totalSteps = 3
Row(
modifier = Modifier
@@ -1599,7 +1616,7 @@ private fun WizardStepIndicator(currentStep: Int, method: PairMethod) {
text = when (currentStep) {
0 -> stringResource(R.string.cw_step_1_3)
1 -> when (method) {
PairMethod.Standard -> stringResource(R.string.cw_step_2_3_standard)
PairMethod.Standard -> stringResource(if (directApi) R.string.cw_hermes_label else R.string.cw_step_2_3_standard)
PairMethod.Scan -> stringResource(R.string.cw_step_2_3_scan)
PairMethod.EnterCode -> stringResource(R.string.cw_step_2_3_enter_code)
PairMethod.ShowCode -> stringResource(R.string.cw_step_2_3_show_code)
@@ -1644,6 +1661,7 @@ private fun NearbyHermesStep(
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun NewNearbyHermesStep(
busy: Boolean,
@@ -1754,10 +1772,9 @@ private fun NewNearbyHermesStep(
)
}
Row(
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = { openExternalUrl(context, SetupGuideUrl) }) {
Icon(
@@ -1886,6 +1903,8 @@ private fun DashboardManualStep(
intent: DashboardEntryIntent,
address: String,
onAddressChange: (String) -> Unit,
httpConsentOrigin: String?,
onHttpConsentChange: (String?) -> Unit,
busy: Boolean,
error: String?,
onBack: () -> Unit,
@@ -1904,6 +1923,9 @@ private fun DashboardManualStep(
optionalHttpUrlError(address, context)
}
val resolvedAddress = if (cloudSlugMode) resolveNousCloudDashboardAddress(address) else address.trim()
val normalizedAddress = Connection.normalizeDashboardUrlInput(resolvedAddress)
val httpAccepted = !com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalizedAddress) ||
httpConsentOrigin == com.hermesandroid.relay.data.dashboardHttpOrigin(normalizedAddress)
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(
text = stringResource(
@@ -1959,7 +1981,7 @@ private fun DashboardManualStep(
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go, autoCorrectEnabled = false),
keyboardActions = KeyboardActions(onGo = {
if (address.isNotBlank() && fieldError == null && !busy) onSubmit(resolvedAddress)
if (address.isNotBlank() && fieldError == null && !busy && httpAccepted) onSubmit(resolvedAddress)
}),
modifier = Modifier.fillMaxWidth(),
)
@@ -1983,13 +2005,17 @@ private fun DashboardManualStep(
error?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
DashboardHttpConsentControl(normalizedAddress, httpConsentOrigin, onHttpConsentChange, enabled = !busy)
TextButton(onClick = { openExternalUrl(context, SetupGuideUrl) }) {
Text(stringResource(R.string.cw_setup_guide))
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onBack, enabled = !busy, modifier = Modifier.weight(1f).heightIn(min = 48.dp)) {
Text(stringResource(R.string.cw_back))
}
Button(
onClick = { onSubmit(resolvedAddress) },
enabled = address.isNotBlank() && fieldError == null && !busy,
enabled = address.isNotBlank() && fieldError == null && !busy && httpAccepted,
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
) {
if (busy) CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
@@ -2039,12 +2065,12 @@ private fun DashboardFoundStep(
}
FoundCapabilityLine(
label = stringResource(R.string.cw_chat),
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_ready),
ready = !result.signInRequired,
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_chat_checked_on_open),
ready = false,
)
FoundCapabilityLine(
label = stringResource(R.string.cw_manage),
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_ready),
value = if (result.signInRequired) stringResource(R.string.cw_available_after_signin) else stringResource(R.string.cw_auth_verified),
ready = !result.signInRequired,
)
FoundCapabilityLine(
@@ -0,0 +1,52 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.dashboardHttpConsentRequired
import com.hermesandroid.relay.data.dashboardHttpOrigin
@Composable
fun DashboardHttpConsentControl(
address: String,
confirmedOrigin: String?,
onConfirmationChange: (String?) -> Unit,
enabled: Boolean = true,
required: Boolean = dashboardHttpConsentRequired(address),
) {
if (!required) return
val origin = dashboardHttpOrigin(address) ?: return
val checked = confirmedOrigin == origin
Column {
Text(
stringResource(R.string.dashboard_http_risk, origin),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).toggleable(
value = checked,
enabled = enabled,
role = Role.Checkbox,
onValueChange = { onConfirmationChange(if (it) origin else null) },
).padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = checked, onCheckedChange = null, enabled = enabled)
Text(stringResource(R.string.dashboard_http_allow), modifier = Modifier.weight(1f))
}
}
}
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -925,10 +927,11 @@ internal fun EndpointCandidate.hasPlainRelayTransport(): Boolean {
@Composable
fun DashboardAddressEditorDialog(
initialUrl: String,
onSave: (dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
onSave: (dashboardUrl: String, httpConsentOrigin: String?, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
) {
var url by remember(initialUrl) { mutableStateOf(initialUrl) }
var httpConsentOrigin by remember(url) { mutableStateOf<String?>(null) }
var errorText by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
val normalized = remember(url) {
@@ -940,7 +943,7 @@ fun DashboardAddressEditorDialog(
onDismissRequest = { if (!saving) onDismiss() },
title = { Text(stringResource(R.string.dashboard_address_editor_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.dashboard_address_editor_body),
style = MaterialTheme.typography.bodySmall,
@@ -973,14 +976,17 @@ fun DashboardAddressEditorDialog(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DashboardHttpConsentControl(normalized, httpConsentOrigin, { httpConsentOrigin = it }, enabled = !saving)
}
},
confirmButton = {
TextButton(
enabled = valid && !saving && normalized != initialUrl.trim().trimEnd('/'),
enabled = valid && !saving && normalized != initialUrl.trim().trimEnd('/') &&
(!com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalized) ||
httpConsentOrigin == com.hermesandroid.relay.data.dashboardHttpOrigin(normalized)),
onClick = {
saving = true
onSave(normalized) { error ->
onSave(normalized, httpConsentOrigin) { error ->
saving = false
if (error == null) onDismiss() else errorText = error
}
@@ -1029,7 +1035,7 @@ private fun isValidDashboardEditorAddress(address: String): Boolean {
fun RouteEditorDialog(
original: EndpointCandidate?,
relayEnabled: Boolean = false,
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
onSave: (role: String, dashboardUrl: String, httpConsentOrigin: String?, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
@@ -1049,12 +1055,17 @@ fun RouteEditorDialog(
)
}
var url by remember(original) { mutableStateOf(routeEditorInitialGatewayUrl(original)) }
var httpConsentOrigin by remember(url, selectedRole) { mutableStateOf<String?>(null) }
var errorText by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
val effectiveRole = if (selectedRole == CUSTOM_ROLE) customRole else selectedRole
val normalizedAddress = Connection.normalizeDashboardUrlInput(url)
val needsHttpConsent = com.hermesandroid.relay.data.dashboardHttpConsentRequired(normalizedAddress) ||
(effectiveRole == "public" && com.hermesandroid.relay.data.dashboardHttpOrigin(normalizedAddress) != null)
val saveEnabled = !saving &&
url.isNotBlank() &&
(!needsHttpConsent || httpConsentOrigin == com.hermesandroid.relay.data.dashboardHttpOrigin(normalizedAddress)) &&
(selectedRole != CUSTOM_ROLE || customRole.isNotBlank())
AlertDialog(
@@ -1066,7 +1077,7 @@ fun RouteEditorDialog(
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringResource(R.string.endpoints_route_editor_desc),
style = MaterialTheme.typography.bodySmall,
@@ -1154,6 +1165,10 @@ fun RouteEditorDialog(
previewCandidate?.let { candidate ->
RouteSurfaceMap(candidate = candidate)
}
DashboardHttpConsentControl(
normalizedAddress, httpConsentOrigin, { httpConsentOrigin = it },
enabled = !saving, required = needsHttpConsent,
)
if (selectedRole == "tailscale") {
Text(
text = stringResource(R.string.endpoints_tailscale_setup_hint),
@@ -1173,7 +1188,7 @@ fun RouteEditorDialog(
enabled = saveEnabled,
onClick = {
saving = true
onSave(effectiveRole, url) { error ->
onSave(effectiveRole, url, httpConsentOrigin) { error ->
saving = false
if (error == null) {
onDismiss()
@@ -46,9 +46,8 @@ fun RelayStatusStrip(
/** Optional security marker rendered just before the route label. */
securityGlyph: (@Composable () -> Unit)? = null,
/**
* When true, the strip shows an amber "Relay reconnecting" cue in place of the
* route label. This is where a **routine** in-progress relay reconnect
* surfaces — the top chrome stays empty so chat content never shifts.
* When true, the strip shows the active chat connection's pending state
* instead of its route label. Optional Relay availability is independent.
*/
reconnecting: Boolean = false,
maxContentWidth: Dp? = null,
@@ -120,7 +119,7 @@ fun RelayStatusStrip(
}
/**
* Amber "Relay reconnecting" cue with a softly pulsing dot. This is the *only*
* Amber connection-progress cue with a softly pulsing dot. This is the *only*
* surface for a routine in-progress relay reconnect — the top of the app stays
* empty (chat/agent status rides the chat header subtitle) so nothing shifts.
* Pulse is frame-throttled via [rememberAmbientPhase] to avoid pinning the
@@ -144,7 +143,7 @@ private fun ReconnectingCue(modifier: Modifier = Modifier) {
.background(RelayRefresh.Amber),
)
Text(
text = stringResource(R.string.settings_relay_reconnecting),
text = stringResource(R.string.session_path_connecting),
style = relayMetadataStyle(),
color = RelayRefresh.Amber,
maxLines = 1,
@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -63,7 +64,7 @@ fun OnboardingPage(
Box(
modifier = Modifier
.fillMaxWidth()
.height(210.dp),
.height(if (LocalConfiguration.current.screenHeightDp < 620 || LocalConfiguration.current.fontScale > 1.2f) 96.dp else 150.dp),
contentAlignment = Alignment.Center,
) {
heroContent()
@@ -129,7 +130,7 @@ private fun FeatureHero(
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = title,
contentDescription = null,
modifier = Modifier.size(46.dp),
tint = Color(0xFF7B55F6),
)
@@ -15,9 +15,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -207,7 +209,7 @@ fun OnboardingScreen(
)
}
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
val currentPage = pagerState.currentPage
val currentPageType = pages[currentPage]
@@ -221,7 +223,6 @@ fun OnboardingScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -363,7 +364,6 @@ private fun GradientOnboardingButton(
Surface(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(14.dp))
.clickable(onClick = onClick),
color = Color.Transparent,
@@ -371,13 +371,14 @@ private fun GradientOnboardingButton(
) {
Row(
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.heightIn(min = 52.dp)
.background(
Brush.horizontalGradient(
listOf(Color(0xFF7047F5), Color(0xFF6446F0)),
),
)
.padding(horizontal = 22.dp),
.padding(horizontal = 22.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
@@ -431,6 +432,9 @@ private fun SegmentedOnboardingProgress(
@Composable
private fun WelcomePage() {
val configuration = LocalConfiguration.current
val heroHeight = (configuration.screenHeightDp * if (configuration.fontScale > 1.2f) 0.18f else 0.27f)
.coerceIn(80f, 240f).dp
val titleParts = stringResource(R.string.onboarding_welcome_title).split("\n", limit = 2)
Column(
modifier = Modifier
@@ -442,15 +446,15 @@ private fun WelcomePage() {
Box(
modifier = Modifier
.fillMaxWidth()
.height(380.dp),
.height(heroHeight),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.onboarding_hero_option1),
contentDescription = stringResource(R.string.onboarding_hermes_logo),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
alignment = BiasAlignment(horizontalBias = 0f, verticalBias = -0.5f),
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
)
}
@@ -1220,6 +1220,7 @@ fun ChatScreen(
chatGatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
val isGatewayTransport = remember(
streamingEndpointPref, chatServerCapabilities, chatGatewayAvailability,
activeConnection,
) {
connectionViewModel.resolveStreamingEndpoint(streamingEndpointPref) == "gateway"
}
@@ -3331,6 +3332,9 @@ fun ChatScreen(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.then(if (targetConnectState == ChatConnectState.NeedsConnection) {
Modifier.verticalScroll(rememberScrollState())
} else Modifier)
.padding(horizontal = 32.dp)
.then(
responsiveLayout.introMaxWidth?.let {
@@ -3345,7 +3349,9 @@ fun ChatScreen(
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity)
) {
val avatarModifier = responsiveLayout.avatarSize?.let { size ->
val avatarModifier = if (targetConnectState == ChatConnectState.NeedsConnection) {
Modifier.size(80.dp).clipToBounds()
} else responsiveLayout.avatarSize?.let { size ->
Modifier
.size(size)
.clipToBounds()
@@ -41,7 +41,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -265,7 +265,7 @@ fun ConnectionDetailScreen(
.padding(innerPadding),
) {
if (tabs.size > 1) {
TabRow(selectedTabIndex = safeIndex) {
ScrollableTabRow(selectedTabIndex = safeIndex, edgePadding = 0.dp) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = safeIndex == index,
@@ -276,7 +276,7 @@ fun ConnectionDetailScreen(
DetailTab.Routes -> stringResource(R.string.detail_tab_routes)
DetailTab.Access -> stringResource(R.string.detail_tab_access)
DetailTab.Advanced -> stringResource(R.string.detail_tab_advanced)
})
}, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
)
}
@@ -380,8 +380,8 @@ fun ConnectionDetailScreen(
if (showDashboardEditor) {
DashboardAddressEditorDialog(
initialUrl = connection.resolvedDashboardUrl,
onSave = { dashboardUrl, onResult ->
connectionViewModel.updateDashboardAddress(dashboardUrl, onResult)
onSave = { dashboardUrl, httpConsentOrigin, onResult ->
connectionViewModel.updateDashboardAddress(dashboardUrl, httpConsentOrigin, onResult)
},
onDismiss = { showDashboardEditor = false },
)
@@ -53,6 +53,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
@@ -419,6 +420,29 @@ private fun ConnectionListCard(
label = "connectionCardBorder",
)
val configuration = LocalConfiguration.current
val stackActions = configuration.screenWidthDp < 360 || configuration.fontScale > 1.2f
val showSwitch = onSwitch != null || isSwitching || justSwitched
val switchAction: @Composable (Modifier) -> Unit = { actionModifier ->
OutlinedButton(
modifier = actionModifier,
onClick = { onSwitch?.invoke() },
enabled = !isSwitching && !justSwitched,
) {
when {
isSwitching -> {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
}
justSwitched -> {
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
}
else -> Text(stringResource(R.string.conn_switch))
}
}
}
Card(
modifier = Modifier
.fillMaxWidth()
@@ -500,30 +524,16 @@ private fun ConnectionListCard(
)
}
}
if (onSwitch != null || isSwitching || justSwitched) {
OutlinedButton(
onClick = { onSwitch?.invoke() },
enabled = !isSwitching && !justSwitched,
) {
when {
isSwitching -> {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(stringResource(R.string.conn_switching), modifier = Modifier.padding(start = 6.dp))
}
justSwitched -> {
Icon(Icons.Filled.CheckCircle, contentDescription = null, modifier = Modifier.size(16.dp))
Text(stringResource(R.string.conn_switched), modifier = Modifier.padding(start = 6.dp))
}
else -> Text(stringResource(R.string.conn_switch))
}
}
}
if (showSwitch && !stackActions) switchAction(Modifier)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showSwitch && stackActions) {
switchAction(Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 12.dp))
}
}
}
@@ -317,7 +317,7 @@ fun DashboardSignInScreen(
?.takeIf { it.isNotEmpty() }
?: status.authProviderDetails
authFlows = status.authFlows
var session = if (status.authRequired) client.currentSession().getOrNull() else null
var session = client.currentSession().getOrNull()
var ticketAvailable = if (session?.authenticated == true) {
client.requestWsTicket().isSuccess
} else {
@@ -359,10 +359,12 @@ fun DashboardSignInScreen(
gatewayTicketAvailable = ticketAvailable,
)
if (
!status.authRequired ||
session?.let { dashboardAuthenticationReady(it, ticketAvailable) } == true
) {
finishAuthentication()
} else if (!status.authRequired) {
actionMessage = resources.getString(R.string.dashboard_local_auth_help)
actionIsError = true
}
} finally {
loading = false
@@ -435,11 +437,6 @@ fun DashboardSignInScreen(
oauthProvider = provider
return
}
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
embeddedFallbackFromNative = true
oauthProvider = provider
return
}
val authClient = connectionViewModel.nativeDashboardAuthClientForActive(dashboardUrl)
if (authClient == null) {
embeddedFallbackFromNative = true
@@ -780,6 +780,9 @@ class ChatViewModel : ViewModel() {
/** Callback to persist session ID — set by RelayApp */
var onSessionChanged: ((String?) -> Unit)? = null
/** Capture a voice-session receipt at live admission, never during history replay. */
internal var gatewayInboundSpeechReceiver: (() -> ((String) -> Unit)?)? = null
var onFreshDraftSelected: ((String?, SessionTransport) -> Unit)? = null
/**
@@ -3070,6 +3073,7 @@ class ChatViewModel : ViewModel() {
var boundHandle: ActiveTurnHandle? = null
var inputTokens: Int? = null
var outputTokens: Int? = null
var speechReceiver: ((String) -> Unit)? = null
fun ownsTranscriptSession(): Boolean =
chatHandler === handler && handler.currentSessionId.value == storedSessionId
@@ -3148,9 +3152,9 @@ class ChatViewModel : ViewModel() {
onTurnComplete = {
if (acceptsEvent()) handler.onTurnComplete(messageId)
},
// Server-initiated turns already take the bounded durable-history
// reconcile below on every completion.
onReconcileRequired = { },
// Recovery can settle a partial live bubble before durable history
// arrives. That history repairs Chat, but is not a speech receipt.
onReconcileRequired = { speechReceiver = null },
onComplete = {
val canWriteTranscript = acceptsEvent()
val expectedText = handler.messages.value
@@ -3168,7 +3172,9 @@ class ChatViewModel : ViewModel() {
} else {
finalizeTurnSideEffects(handler, messageId)
AppAnalytics.onStreamComplete(inputTokens, outputTokens)
speechReceiver?.invoke(expectedText.orEmpty())
}
speechReceiver = null
scheduleGatewayHistoryReconcile(
storedSessionId = storedSessionId,
expectedAssistantText = expectedText,
@@ -3271,6 +3277,9 @@ class ChatViewModel : ViewModel() {
baselineAssistantCount = handler.messages.value.count {
it.role == MessageRole.ASSISTANT && !it.clientOnly
}
if (queuedRecovery == null) {
speechReceiver = gatewayInboundSpeechReceiver?.invoke()
}
boundHandle = handle
accepted = true
activeStream = handle
@@ -64,6 +64,10 @@ import com.hermesandroid.relay.data.LEGACY_AUTHENTICATED_DASHBOARD_ROUTE_ROLE
import com.hermesandroid.relay.data.ConnectionValidation
import com.hermesandroid.relay.data.computeConnectionSecurity
import com.hermesandroid.relay.data.normalizeCredentialFreeAuthenticatedDashboardOrigin
import com.hermesandroid.relay.data.dashboardHttpConsentMatches
import com.hermesandroid.relay.data.dashboardHttpOrigin
import com.hermesandroid.relay.data.updatedDashboardHttpConsents
import com.hermesandroid.relay.network.upstream.verifySetup
import com.hermesandroid.relay.data.BuildFlavor
import com.hermesandroid.relay.data.BotChatTarget
import com.hermesandroid.relay.data.BotModeState
@@ -515,7 +519,7 @@ internal fun resolveEffectiveDashboardUrl(
): String {
if (connection == null) return ""
connection.authenticatedDashboardOrigin
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
?.let { normalizeCredentialFreeAuthenticatedDashboardOrigin(it, connection.dashboardHttpConsentOrigins) }
?.let { return it }
// The resolver publishes independently of the active connection. During a
// switch its last winner can still belong to the outgoing installation.
@@ -571,12 +575,14 @@ internal fun normalizeDashboardAddressForEdit(raw: String): String? {
internal fun publicDashboardAddressRequiresHttps(
role: String?,
normalizedAddress: String,
httpConsentOrigins: Set<String> = emptySet(),
): Boolean {
val uri = runCatching { URI(normalizedAddress) }.getOrNull() ?: return false
val explicitRole = role?.trim()?.lowercase()?.takeIf { it.isNotBlank() }
val inferredRole = Connection.inferRouteRole(normalizedAddress)
return uri.scheme.equals("http", ignoreCase = true) &&
(explicitRole == "public" || inferredRole == "public")
(explicitRole == "public" || inferredRole == "public") &&
!dashboardHttpConsentMatches(normalizedAddress, httpConsentOrigins)
}
internal fun standardApiDashboardSecurityError(
@@ -596,6 +602,7 @@ internal data class PendingConnectionDraft(
val previousConnectionId: String?,
var pairingPayload: com.hermesandroid.relay.ui.components.HermesPairingPayload? = null,
var label: String? = null,
val dashboardHttpConsentOrigins: Set<String> = emptySet(),
)
/** Hide user-confirmed removals immediately while serialized cleanup finishes. */
@@ -1331,6 +1338,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
?: connection?.dashboardAuthProviders.orEmpty(),
)
},
dashboardHttpConsentOriginsProvider = { cid -> dashboardHttpConsentsFor(cid) },
pinnedClientProvider = { url, base ->
pluginProxyClientForUrl(url, base, includeRelaySessionHeader = false)
},
@@ -1788,8 +1796,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
origin: String,
allowMissingInstallIdentity: Boolean = false,
): Boolean {
val normalized = normalizeAuthenticatedDashboardOrigin(origin) ?: return false
val activeId = connectionStore.activeConnectionId.value ?: return false
val normalized = normalizeCredentialFreeAuthenticatedDashboardOrigin(
origin, dashboardHttpConsentsFor(activeId),
) ?: return false
val previous = connectionStore.connections.value
.firstOrNull { it.id == activeId }
?: return false
@@ -1808,7 +1818,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
withTimeoutOrNull(8_000L) {
val status = verificationClient.getStatus().getOrNull() ?: return@withTimeoutOrNull false
candidateInstallId = status.installId
!status.authRequired || verificationClient.currentSession().getOrNull()?.authenticated == true
verificationClient.currentSession().getOrNull()?.authenticated == true &&
verificationClient.requestWsTicket().isSuccess
} == true
} finally {
verificationClient.shutdown()
@@ -1868,6 +1879,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun retireNativeDashboardAuthentication(connectionId: String) =
upstreamTransport.retireNativeDashboardAuthentication(connectionId)
private fun dashboardHttpConsentsFor(connectionId: String): Set<String> =
pendingConnectionDraft?.takeIf { it.id == connectionId }?.dashboardHttpConsentOrigins
?: connectionStore.connections.value.firstOrNull { it.id == connectionId }
?.dashboardHttpConsentOrigins.orEmpty()
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? =
upstreamTransport.nativeDashboardAuthClientForActive(dashboardUrl)
@@ -2870,12 +2886,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// this Home-Assistant-class persistent-connection use case). Mirrors
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
viewModelScope.launch {
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
persistent to turns
}.distinctUntilChanged().collect { (persistent, turns) ->
combine(
gatewayKeepAlive,
ActiveTurnKeepAliveRegistry.snapshot,
AppForegroundTracker.isForeground,
) { persistent, turns, foreground ->
Triple(persistent, turns, foreground)
}.distinctUntilChanged().collect { (persistent, turns, foreground) ->
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
val ctx = getApplication<Application>()
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
GatewayKeepAliveService.update(ctx, persistent, turns, foreground)
}
}
}
@@ -6459,6 +6479,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val signInRequired: Boolean = false,
val authenticated: Boolean = false,
val voiceAvailability: StandardVoiceAvailability = StandardVoiceAvailability.Unknown,
val httpConsentOrigin: String? = null,
)
/**
@@ -6468,9 +6489,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
*/
fun probeHermesDashboard(
address: String,
httpConsentOrigin: String? = null,
onResult: (DashboardSetupResult) -> Unit,
) {
val dashboardUrl = Connection.normalizeApiUrlInput(
val dashboardUrl = Connection.normalizeDashboardUrlInput(
address,
defaultPort = Connection.DEFAULT_DASHBOARD_PORT,
)
@@ -6484,7 +6506,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
)
return
}
if (publicDashboardAddressRequiresHttps(role = null, normalizedAddress = dashboardUrl)) {
if (publicDashboardAddressRequiresHttps(null, dashboardUrl, setOfNotNull(httpConsentOrigin))) {
onResult(
DashboardSetupResult(
ok = false,
@@ -6495,27 +6517,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return
}
viewModelScope.launch {
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
val client = upstreamTransport.dashboardClientForActive(dashboardUrl, setupProbe = true)
try {
val statusResult = client.getStatus()
val status = statusResult.getOrNull()
if (status == null) {
onResult(
DashboardSetupResult(
ok = false,
dashboardUrl = dashboardUrl,
message = statusResult.exceptionOrNull()?.message
?: "Hermes was not found at this address",
),
)
return@launch
}
val session = if (status.authRequired) {
client.currentSession().getOrNull()
} else {
null
}
val authenticated = !status.authRequired || session?.authenticated == true
val verification = client.verifySetup()
val status = verification.status
val authenticated = verification.authenticated
val voice = when {
!authenticated -> StandardVoiceAvailability.SignInRequired
client.audioRoutesPresent() -> StandardVoiceAvailability.Ready
@@ -6526,9 +6532,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
ok = true,
dashboardUrl = dashboardUrl,
message = status.message ?: "Hermes is ready",
signInRequired = status.authRequired && !authenticated,
signInRequired = !authenticated,
authenticated = authenticated,
voiceAvailability = voice,
httpConsentOrigin = httpConsentOrigin?.takeIf { it == dashboardHttpOrigin(dashboardUrl) },
),
)
} catch (e: kotlinx.coroutines.CancellationException) {
@@ -6538,7 +6545,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
DashboardSetupResult(
ok = false,
dashboardUrl = dashboardUrl,
message = e.message ?: "Hermes was not found at this address",
message = if (e is com.hermesandroid.relay.network.upstream.DashboardLocalAuthenticationRequiredException) {
getApplication<Application>().getString(R.string.dashboard_local_auth_help)
} else e.message ?: "Hermes was not found at this address",
),
)
} finally {
@@ -6551,6 +6560,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun saveDashboardConnection(
dashboardUrl: String,
discoveredHostname: String? = null,
httpConsentOrigin: String? = null,
onComplete: (Result<Unit>) -> Unit,
) {
val normalized = Connection.normalizeDashboardUrlInput(
@@ -6561,7 +6571,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
onComplete(Result.failure(IllegalArgumentException("Hermes address is required")))
return
}
if (publicDashboardAddressRequiresHttps(role = null, normalizedAddress = normalized)) {
if (publicDashboardAddressRequiresHttps(null, normalized, setOfNotNull(httpConsentOrigin))) {
onComplete(Result.failure(IllegalArgumentException("Public Gateway addresses require HTTPS")))
return
}
@@ -6573,7 +6583,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
draft = draft,
dashboardUrl = normalized,
discoveredHostname = discoveredHostname,
)
).copy(dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
draft.dashboardHttpConsentOrigins, null, normalized, httpConsentOrigin,
))
true
}
if (stagedDraft) return@runCatching
@@ -6597,6 +6609,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val next = current.copy(
label = nextLabel,
dashboardUrl = normalized,
dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
current.dashboardHttpConsentOrigins, current.configuredDashboardUrl, normalized, httpConsentOrigin,
),
authenticatedDashboardOrigin = current.authenticatedDashboardOrigin
?.takeIf {
it.trimEnd('/').equals(normalized.trimEnd('/'), ignoreCase = true)
@@ -6635,6 +6650,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
*/
fun updateDashboardAddress(
url: String,
httpConsentOrigin: String? = null,
onResult: (String?) -> Unit,
) {
val normalized = normalizeDashboardAddressForEdit(url)
@@ -6642,7 +6658,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
onResult("Enter an http:// or https:// Hermes Dashboard address")
return
}
if (publicDashboardAddressRequiresHttps(role = null, normalizedAddress = normalized)) {
if (publicDashboardAddressRequiresHttps(null, normalized, setOfNotNull(httpConsentOrigin))) {
onResult("Public Gateway addresses require HTTPS")
return
}
@@ -6660,7 +6676,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return@launch
}
val originChanged = !sameDashboardBase(current.resolvedDashboardUrl, normalized)
val next = withExplicitDashboardAddress(current, normalized)
val next = withExplicitDashboardAddress(current, normalized).copy(
dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
current.dashboardHttpConsentOrigins, current.configuredDashboardUrl, normalized, httpConsentOrigin,
),
)
connectionStore.updateConnection(next)
if (originChanged) {
withContext(Dispatchers.IO) {
@@ -6747,6 +6767,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
tokenStoreKey = Connection.buildTokenStoreKey(connectionId),
dashboardUrl = dashboardUrl,
routeCandidates = routes,
dashboardHttpConsentOrigins = draft.dashboardHttpConsentOrigins,
pairedAt = paired?.let { System.currentTimeMillis() },
transportHint = pairedSession?.transportHint,
expiresAt = pairedSession?.expiresAt?.let { it * 1000L },
@@ -6787,16 +6808,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private suspend fun probeDashboardAddress(
dashboardUrl: String,
): Result<Pair<DashboardStatus, DashboardAuthSession?>> {
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
val client = upstreamTransport.dashboardClientForActive(dashboardUrl, setupProbe = true)
return try {
withTimeoutOrNull(8_000L) {
val status = client.getStatus().getOrElse { throw it }
val session = if (status.authRequired) {
client.currentSession().getOrNull()
} else {
null
}
Result.success(status to session)
val verification = client.verifySetup()
Result.success(verification.status to verification.session)
} ?: Result.failure(java.net.SocketTimeoutException("Dashboard check timed out"))
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
@@ -6819,7 +6835,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
reachable = true,
authRequired = status.authRequired,
authProviders = status.authProviders,
authenticated = if (status.authRequired) session?.authenticated else true,
authenticated = session?.authenticated == true,
authProvider = session?.provider,
message = status.message,
gatewayMode = status.gatewayMode,
@@ -7740,6 +7756,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
role: String,
dashboardUrl: String,
original: EndpointCandidate? = null,
httpConsentOrigin: String? = null,
onResult: (String?) -> Unit,
) {
if (original?.priority == 0) {
@@ -7755,7 +7772,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// Accept bare hosts/IPs — http:// is assumed and the standard
// Dashboard/Gateway port defaults to 9119.
val trimmedUrl = Connection.normalizeDashboardUrlInput(dashboardUrl)
if (publicDashboardAddressRequiresHttps(role, trimmedUrl)) {
if (publicDashboardAddressRequiresHttps(role, trimmedUrl, setOfNotNull(httpConsentOrigin))) {
onResult("Public Gateway routes require HTTPS")
return@launch
}
@@ -7808,7 +7825,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
val next = (withoutOriginal + candidate)
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
connectionStore.updateConnection(current.copy(routeCandidates = next))
connectionStore.updateConnection(current.copy(
routeCandidates = next,
dashboardHttpConsentOrigins = updatedDashboardHttpConsents(
current.dashboardHttpConsentOrigins, original?.dashboard?.url, trimmedUrl, httpConsentOrigin,
),
))
// Full probe cycle (not a bare refresh) so the just-saved route
// immediately shows a reachability verdict in the Routes card.
probeNow()
@@ -900,6 +900,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* we don't re-process older turns when the history list updates. */
private var assistantSpeechCursor: AssistantSpeechCursor? = null
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
private var inboundSpeechGeneration = 0L
private var inboundSpeechOwner: Pair<ConversationBinding, String?>? = null
private var inboundSpeechObserver: Job? = null
private val pendingInboundSpeech = ArrayDeque<Pair<() -> Boolean, String>>()
private var inboundSpeechPlaying = false
private var sentenceBuffer: StringBuilder = StringBuilder()
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
private val brokeredToolSpeechKeys = mutableSetOf<String>()
@@ -1199,9 +1204,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
) {
cancelStandardSpeechStream("voice dependencies rewired")
retireInboundSpeech()
this.chatViewModel?.gatewayInboundSpeechReceiver = null
this.voiceClient = voiceClient
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
this.chatViewModel = chatViewModel
chatViewModel.gatewayInboundSpeechReceiver = ::captureInboundSpeechReceiver
this.recorder = recorder
this.player = player
this.realtimePcmPlayer = realtimePcmPlayer
@@ -1537,6 +1545,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
*/
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
if (voiceEngineMode != nextEngineMode) {
if (inboundSpeechPlaying) interruptSpeaking(cancelActiveTurn = false)
else retireInboundSpeech()
}
val finalAnswerPolicyChanged = finalAnswerOnly != settings.finalAnswerOnly
val realtimeSelectionChanged =
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
@@ -1675,6 +1687,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
backgroundRun = if (orphanedRun != null) null else it.backgroundRun,
)
}
if (freshEntry) bindInboundSpeechOwner()
prewarmRealtimeSession()
}
@@ -1885,6 +1898,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
fun exitVoiceMode() {
retireInboundSpeech()
cancelPendingListeningStart()
// Idempotence guard — added 2026-04-21 after logcat showed the voice-
// exit chime playing on every Add-connection tap.
@@ -2291,6 +2305,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* listening turn; until then, idle queue-drain callbacks are ignored.
*/
fun pauseContinuousMode() {
retireInboundSpeech()
cancelPendingListeningStart()
continuousLoopArmed = false
continuousListeningPaused = _uiState.value.interactionMode == InteractionMode.Continuous
@@ -2427,6 +2442,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* new turn on the next mic tap).
*/
fun interruptSpeaking(cancelActiveTurn: Boolean = true): Job? {
retireInboundSpeech()
cancelPendingListeningStart()
Log.i(
TAG,
@@ -3584,6 +3600,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
if (inboundSpeechOwner == null) bindInboundSpeechOwner()
beginBargeInTurnIfEnabled()
startStreamObserver(chatVm)
}
@@ -4738,6 +4755,99 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
standardSpeechStreamBargeInStarted.set(false)
}
/** The voice overlay owns live completions only for the conversation it entered. */
private fun bindInboundSpeechOwner() {
val chat = chatViewModel ?: return
retireInboundSpeech()
inboundSpeechOwner = chat.conversationBinding.value to chat.currentSessionId.value
inboundSpeechObserver = viewModelScope.launch {
combine(chat.conversationBinding, chat.currentSessionId, _uiState) { binding, id, state ->
Triple(binding, id, state)
}.collect { (binding, id, _) ->
val owner = inboundSpeechOwner ?: return@collect
if (owner != (binding to id)) {
// The first voice submission may create/adopt a durable session.
if (owner.second == null && owner.first.contextKey == binding.contextKey &&
voiceTurnSessionFence?.accepts(id, chat.messages.value) == true
) {
inboundSpeechOwner = binding to id
} else {
val wasPlaying = inboundSpeechPlaying
retireInboundSpeech()
if (wasPlaying) interruptSpeaking(cancelActiveTurn = false)
return@collect
}
}
drainInboundSpeech()
}
}
}
private fun retireInboundSpeech() {
inboundSpeechGeneration++
inboundSpeechOwner = null
inboundSpeechObserver?.cancel()
inboundSpeechObserver = null
pendingInboundSpeech.clear()
inboundSpeechPlaying = false
}
/** Called on Main before Chat installs the new live assistant placeholder. */
private fun captureInboundSpeechReceiver(): ((String) -> Unit)? {
val chat = chatViewModel ?: return null
val owner = inboundSpeechOwner ?: return null
val generation = inboundSpeechGeneration
fun current(): Boolean =
generation == inboundSpeechGeneration && _uiState.value.voiceMode &&
voiceEngineMode == VoiceEngineMode.HermesVoiceOutput &&
inboundSpeechOwner == owner &&
owner == (chat.conversationBinding.value to chat.currentSessionId.value)
if (owner.second == null || !current()) return null
// A fast unsolicited start can overtake combine's final local-turn snapshot.
// Consume that final snapshot before the new placeholder exists so the two
// speech paths cannot narrate the same assistant bubble.
if (streamObserverJob?.isActive == true && !chat.isStreaming.value) {
assistantSpeechCursor?.let { cursor ->
consumeAssistantSpeech(cursor.poll(chat.messages.value), runActive = false)
}
streamObserverJob?.cancel()
}
var consumed = false
return { text ->
if (!consumed) {
consumed = true
if (current() && sanitizeForTts(text).isNotBlank()) {
pendingInboundSpeech.addLast(::current to text)
drainInboundSpeech()
}
}
}
}
/** Wait for a capture/earlier reply to settle; use the configured output renderer. */
private fun drainInboundSpeech(): Boolean {
if (pendingInboundSpeech.isEmpty()) return false
if (_uiState.value.state != VoiceState.Idle || isMicCaptureActive() ||
streamObserverJob?.isActive == true ||
chatViewModel?.isStreaming?.value == true ||
!agentAudioCompletionDecision().finishNow
) return false
while (pendingInboundSpeech.isNotEmpty()) {
val (current, text) = pendingInboundSpeech.removeAt(0)
if (!current()) continue
cancelPendingListeningStart()
streamComplete = true
inboundSpeechPlaying = true
resetTtsTurnStats()
clearSpokenChunksState()
speakSettledFinalAnswer(text)
scheduleAgentAudioCompletionCheck()
return true
}
return false
}
/**
* Observe every assistant bubble created by the active Hermes run. A tool
* turn can finalize one bubble while the run is still active and later
@@ -4770,40 +4880,43 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return@collect
}
val batch = cursor.poll(messages)
if (finalAnswerOnly) {
if (batch.deltas.isNotEmpty()) {
onVisualStreamDelta(batch.aggregateText)
}
} else {
batch.deltas.forEach { update ->
if (update.startsNewBubble) {
beginAssistantSpeechBubble()
}
onStreamDelta(update.text, batch.aggregateText)
}
}
// Tool state can change without text growth.
if (!finalAnswerOnly) {
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
}
if (!runActive && batch.hasTurnAssistant) {
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
if (finalAnswerOnly) {
speakSettledFinalAnswer(batch.finalAnswerText)
} else if (!finishStandardSpeechStream()) {
flushRemainingBuffer()
}
streamObserverJob?.cancel()
scheduleAgentAudioCompletionCheck()
}
consumeAssistantSpeech(cursor.poll(messages), runActive)
}
}
}
private fun consumeAssistantSpeech(batch: AssistantSpeechBatch, runActive: Boolean) {
if (finalAnswerOnly) {
if (batch.deltas.isNotEmpty()) {
onVisualStreamDelta(batch.aggregateText)
}
} else {
batch.deltas.forEach { update ->
if (update.startsNewBubble) {
beginAssistantSpeechBubble()
}
onStreamDelta(update.text, batch.aggregateText)
}
}
// Tool state can change without text growth.
if (!finalAnswerOnly) {
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
}
if (!runActive && batch.hasTurnAssistant) {
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
if (finalAnswerOnly) {
speakSettledFinalAnswer(batch.finalAnswerText)
} else if (!finishStandardSpeechStream()) {
flushRemainingBuffer()
}
streamObserverJob?.cancel()
scheduleAgentAudioCompletionCheck()
}
}
private fun onVisualStreamDelta(fullContent: String) {
_uiState.update {
it.copy(
@@ -5800,6 +5913,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
private fun finishAgentAudioOutput() {
inboundSpeechPlaying = false
continuousResumeJob = null
_responseSpeechActive.value = false
stopBargeInListener()
@@ -5825,6 +5939,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(amplitude = 0f, outputAudioActive = false) }
}
if (drainInboundSpeech()) return
if (_uiState.value.interactionMode == InteractionMode.Continuous &&
continuousLoopArmed &&
_uiState.value.state == VoiceState.Idle
@@ -6686,6 +6801,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
override fun onCleared() {
retireInboundSpeech()
chatViewModel?.gatewayInboundSpeechReceiver = null
super.onCleared()
voicePreviewJob?.cancel()
voicePreviewJob = null
@@ -104,6 +104,7 @@ class UpstreamTransportController(
private val trustedDashboardUrlProvider: (String) -> String? = { null },
/** False when the host's advertised auth topology requires cookie compatibility mode. */
private val nativeDashboardBearerEligibleProvider: (String) -> Boolean = { true },
private val dashboardHttpConsentOriginsProvider: (String) -> Set<String> = { emptySet() },
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
{ _, _ -> null },
@@ -190,18 +191,29 @@ class UpstreamTransportController(
}
}
private fun dashboardClientTransport(
connectionId: String?,
url: String,
base: okhttp3.OkHttpClient,
): okhttp3.OkHttpClient = com.hermesandroid.relay.network.upstream.dashboardClientWithHttpConsent(
pinnedClientProvider(url, base) ?: base,
url,
connectionId?.let(dashboardHttpConsentOriginsProvider).orEmpty(),
)
private fun bearerAuthForTrustedDashboard(
connectionId: String,
dashboardUrl: String,
): DashboardBearerAuth? {
if (!nativeDashboardBearerEligibleProvider(connectionId)) return null
if (!isNativeDashboardTransportEligible(dashboardUrl)) return null
if (!isNativeDashboardTransportEligible(dashboardUrl, dashboardHttpConsentOriginsProvider(connectionId))) return null
val trustedDashboardUrl = trustedDashboardUrlProvider(connectionId)
?: (if (activeConnectionIdProvider() == connectionId) dashboardUrlProvider() else null)
?: return null
return trustedDashboardBearerAuthOrNull(
candidate = dashboardUrl,
trusted = trustedDashboardUrl,
httpConsentOrigins = dashboardHttpConsentOriginsProvider(connectionId),
tokenStoreProvider = { dashboardTokenStoreFor(connectionId) },
)
}
@@ -223,7 +235,7 @@ class UpstreamTransportController(
dashboardCookieStoreFor(connectionId),
bearerAuthForTrustedDashboard(connectionId, normalizedUrl),
)
pinnedClientProvider(normalizedUrl, base) ?: base
dashboardClientTransport(connectionId, normalizedUrl, base)
}
return DashboardApiClient(
baseUrl = normalizedUrl,
@@ -257,16 +269,27 @@ class UpstreamTransportController(
* [dashboardUrl], falling back to an in-memory cookie store when there is
* no active connection (the standard-voice probe path).
*/
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient {
fun dashboardClientForActive(dashboardUrl: String, setupProbe: Boolean = false): DashboardApiClient {
val ownerId = activeConnectionIdProvider()
val trustedBase = ownerId?.let(trustedDashboardUrlProvider) ?: dashboardUrlProvider()
val cookies = if (!setupProbe || (trustedBase != null &&
com.hermesandroid.relay.network.upstream.sameDashboardBase(dashboardUrl, trustedBase))) {
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore()
} else InMemoryDashboardCookieStore()
val base = dashboardHttpClientFactory(
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
cookies,
activeConnectionIdProvider()?.let {
bearerAuthForTrustedDashboard(it, dashboardUrl)
},
)
val boundedBase = if (setupProbe) base.newBuilder()
.callTimeout(8, java.util.concurrent.TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build() else base
return DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = pinnedClientProvider(dashboardUrl, base) ?: base,
okHttpClient = dashboardClientTransport(ownerId, dashboardUrl, boundedBase),
)
}
@@ -281,7 +304,7 @@ class UpstreamTransportController(
)
return DashboardApiClient(
baseUrl = dashboardUrl,
okHttpClient = pinnedClientProvider(dashboardUrl, base) ?: base,
okHttpClient = dashboardClientTransport(activeConnectionIdProvider(), dashboardUrl, base),
)
}
@@ -293,7 +316,7 @@ class UpstreamTransportController(
fun nativeDashboardAuthClientForActive(dashboardUrl: String): NativeDashboardAuthClient? {
val connectionId = activeConnectionIdProvider() ?: return null
val trustedDashboardUrl = dashboardUrlProvider() ?: return null
if (!isNativeDashboardTransportEligible(dashboardUrl)) {
if (!isNativeDashboardTransportEligible(dashboardUrl, dashboardHttpConsentOriginsProvider(connectionId))) {
return null
}
if (!com.hermesandroid.relay.network.upstream.sameDashboardBase(
@@ -312,7 +335,7 @@ class UpstreamTransportController(
return NativeDashboardAuthClient(
baseUrl = dashboardUrl,
tokenStore = dashboardTokenStoreFor(connectionId),
client = pinnedClientProvider(dashboardUrl, base) ?: base,
client = dashboardClientTransport(activeConnectionIdProvider(), dashboardUrl, base),
)
}
@@ -331,7 +354,7 @@ class UpstreamTransportController(
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
},
)
return (pinnedClientProvider(dashboardUrl, base) ?: base)
return (dashboardClientTransport(activeConnectionIdProvider(), dashboardUrl, base))
.also { dashboardHttpClientCache = Triple(connectionId, dashboardUrl, it) }
}
+25 -20
View File
@@ -294,7 +294,7 @@
<string name="cw_cloud_subtitle">Conecte ao seu agente hospedado</string>
<string name="cw_server_vps_title">Gateway remoto</string>
<string name="cw_server_vps_subtitle">Informe o endereço do Dashboard</string>
<string name="cw_relay_optional_note">Endereços privados LAN e Tailscale podem usar HTTP ou HTTPS. Endereços públicos exigem HTTPS. Relay e API direta são opcionais.</string>
<string name="cw_relay_optional_note">HTTPS é recomendado. Outros endereços HTTP exigem aceitação explícita do risco. O aplicativo não impõe proteção VPN. Relay e Direct API são opcionais.</string>
<string name="cw_cloud_entry_title">Conectar ao Hermes hospedado pela Nous</string>
<string name="cw_cloud_entry_description">Insira o endereço do agente mostrado no Nous Portal. Você entrará com segurança depois que o Hermes for encontrado.</string>
<string name="cw_cloud_agent_name">Endereço do agente</string>
@@ -302,7 +302,7 @@
<string name="cw_cloud_slug_hint">Conectaremos a seu-agente.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Insira exatamente as letras, os números e os hifens antes de .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://seu-agente.example.com</string>
<string name="cw_cloud_address_hint">Use o endereço HTTPS completo exibido para seu agente hospedado.</string>
<string name="cw_cloud_address_hint">Use o endereço HTTPS completo exibido para seu agente hospedado, incluindo :porta se ela for fornecida.</string>
<string name="cw_cloud_use_custom_address">Usar URL personalizada</string>
<string name="cw_cloud_use_nous_address">Usar endereço hospedado pela Nous</string>
<string name="cw_before_connecting">Antes de conectar</string>
@@ -322,10 +322,10 @@
<string name="cw_nearby_enter_address">Informar endereço</string>
<string name="cw_other_connection_methods">Outros métodos de conexão</string>
<string name="cw_manual_hermes_title">Informe o endereço do gateway</string>
<string name="cw_manual_hermes_description">Informe o endereço do Painel usado no navegador. Se ele não abrir neste celular, inicie hermes dashboard e verifique o Wi-Fi ou o Tailscale.</string>
<string name="cw_manual_hermes_description">Digite o endereço do Dashboard que pode abrir neste telefone. Para LAN ou VPN, o host precisa de um Dashboard acessível e autenticação. Consulte o guia de configuração se o navegador não conectar.</string>
<string name="cw_hermes_address">Endereço do Hermes</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 ou hermes.example.com</string>
<string name="cw_hermes_address_hint">Nenhuma chave de API é necessária. A porta 9119 é usada quando nenhuma porta é informada.</string>
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
<string name="cw_hermes_address_hint">Exemplos: 192.168.1.10:9119 ou https://hermes.example.com:10443. Se omitida, hosts privados usam a porta 9119 e HTTPS usa a 443. Nenhuma chave API é necessária.</string>
<string name="cw_find_hermes">Procurar Hermes</string>
<string name="cw_hermes_found">Hermes encontrado</string>
<string name="cw_ready_to_connect">Pronto para conectar</string>
@@ -348,12 +348,12 @@
<string name="cw_skip_for_now">Pular por enquanto — configure depois em Configurações</string>
<string name="cw_back">Voltar</string>
<string name="cw_connect_button">Conectar</string>
<string name="cw_hermes_label">Hermes</string>
<string name="cw_hermes_label_desc">Use isto para Chat e Gerenciar. Pareie o Relay depois somente quando ativar Terminal, Bridge, sessões do Relay ou permissões. O login no painel é o caminho de autenticação upstream preferencial; a chave da API continua sendo a alternativa para o Chat do Android.</string>
<string name="cw_hermes_label">Direct API</string>
<string name="cw_hermes_label_desc">Direct API é uma conexão de compatibilidade escolhida explicitamente para o chat do servidor API. Manage, sessões do Dashboard e voz padrão exigem login separado no Dashboard. Ela não assume uma conversa existente do Gateway.</string>
<string name="cw_api_url_label">URL ou host do servidor API</string>
<string name="cw_api_key_label">Chave da API</string>
<string name="cw_api_key_placeholder">Valor de API_SERVER_KEY</string>
<string name="cw_api_key_hint">Necessária para o Chat do Android até que o transporte do Gateway pelo painel seja ativado.</string>
<string name="cw_api_key_hint">Chave bearer apenas para este servidor API. Ela não faz login no Dashboard.</string>
<string name="cw_tailscale_label">Acesso remoto — URL do Tailscale (opcional)</string>
<string name="cw_dashboard">Painel</string>
<string name="cw_dashboard_routes_hint">Rotas: primeiro a padrão, depois o Tailscale quando estiver acessível.</string>
@@ -368,7 +368,7 @@
<string name="cw_cancel">Cancelar</string>
<!-- Method tiles -->
<string name="cw_method_hermes_title">Hermes</string>
<string name="cw_method_hermes_subtitle">Configuração de API/painel para Chat, Gerenciar, Habilidades, Cron, MCP, Perfis, Modelos e Configurações</string>
<string name="cw_method_hermes_subtitle">Chat explícito do servidor API para configurações sem interface ou de compatibilidade</string>
<string name="cw_method_scan_title">Ler QR de configuração</string>
<string name="cw_method_scan_subtitle">Leia um QR com a URL/chave da API do Hermes; os detalhes do QR do Relay exigem o plugin do Relay</string>
<string name="cw_method_pair_code_title">Parear o Relay por código</string>
@@ -377,7 +377,7 @@
<string name="cw_method_show_code_subtitle">Sem câmera ou QR? Registre o código deste celular no host</string>
<!-- Standard connect form -->
<string name="cw_api_url_placeholder">192.168.1.10 ou http://your-server:8642</string>
<string name="cw_api_url_supporting">API do Hermes usada pelo Chat e pelas sessões — a porta 8642 da API e http:// são presumidos para hosts sem esquema (a porta 9119 do painel é determinada separadamente)</string>
<string name="cw_api_url_supporting">Endereço do servidor para chat Direct API. Hosts sem esquema usam HTTP e a porta 8642.</string>
<string name="cw_scan_message">Procurando o painel/a API do Hermes nesta LAN…</string>
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e voz — a chave da API é usada apenas em conexões explícitas pela API direta.</string>
<string name="cw_pair_relay_section">Parear o Relay (opcional)</string>
@@ -467,7 +467,7 @@
<string name="cw_timeout_entercode_showcode">O host ainda não aceitou este código de pareamento. Execute o comando de pareamento no host do Hermes (ou confira o código) e toque em Tentar novamente.</string>
<string name="cw_timeout_scan">O tempo se esgotou antes de o relay confirmar o pareamento. Verifique se o relay está em execução e acessível nesta rede e tente novamente. Se o pareamento parecer concluído, mas o Hermes ainda estiver inacessível, talvez o servidor API esteja atrás de um Gateway de login.</string>
<string name="cw_timeout_other">O tempo de espera pelo relay se esgotou. Verifique se o relay está em execução e se a URL está correta.</string>
<string name="cw_camera_denied">Permissão da câmera negada. Faça o pareamento manual — escolha \"Parear o Relay por código\" ou insira a URL do servidor.</string>
<string name="cw_camera_denied">Permissão de câmera negada. Escolha Gateway remoto e digite o endereço do Dashboard. Os códigos de pareamento Relay estão em Avançado.</string>
<!-- EndpointsCard -->
<string name="endpoints_no_routes_stored">Nenhuma rota armazenada para esta conexão.</string>
<string name="endpoints_add_route">Adicionar rota</string>
@@ -3179,7 +3179,7 @@
<string name="endpoints_pin_title">Endpoints fixados</string>
<string name="endpoints_route_editor_desc">Adicione o endereço do Dashboard/Gateway que este celular deve usar nessa rede.</string>
<string name="endpoints_route_name_placeholder">Nome da rota</string>
<string name="endpoints_url_host_placeholder">100.x.y.z ou host.ts.net</string>
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 ou https://host.ts.net:10443</string>
<string name="endpoints_url_supporting_blank">Insira o endereço do Dashboard do servidor</string>
<string name="endpoints_url_supporting_enter">Use um endereço de Dashboard http:// ou https://</string>
<string name="endpoints_url_supporting_preview">Será testado: %1$s</string>
@@ -3365,16 +3365,16 @@
<string name="cw_relay_pair_qr">Emparelhar Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">Escaneie um QR de configuração do Relay</string>
<string name="cw_relay_enter_code">Inserir código de emparelhamento</string>
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
<string name="cw_dashboard_connected_body">Dashboard e Gateway estão prontos. Você pode começar a conversar ou abrir Manage.</string>
<string name="cw_dashboard_connected_title">Acesso ao Dashboard verificado</string>
<string name="cw_dashboard_connected_body">Continue para o Chat. A conexão Gateway e a disponibilidade de voz são verificadas separadamente.</string>
<string name="cw_continue">Continuar</string>
<string name="cw_timeline_discovered">Hermes encontrado</string>
<string name="cw_timeline_discovered_detail">Identidade do Dashboard e endpoint de status verificados</string>
<string name="cw_timeline_discovered_detail">Estado do Dashboard obtido</string>
<string name="cw_timeline_access">Acesso ao Dashboard</string>
<string name="cw_timeline_access_ready">Nenhum login adicional necessário</string>
<string name="cw_timeline_authenticated">Autenticação verificada</string>
<string name="cw_timeline_ready">Conexão pronta</string>
<string name="cw_timeline_ready_detail">Chat, Manage e Voice podem usar este Dashboard</string>
<string name="cw_timeline_ready">Pronto para continuar</string>
<string name="cw_timeline_ready_detail">Chat conecta ao abrir; a voz é verificada separadamente</string>
<string name="active_section_optional_api_fallback">API direta opcional</string>
<string name="active_section_api_not_required">Não é necessário quando esta conexão usa o Hermes Dashboard.</string>
<string name="active_section_where_api_key">Onde obtenho essa chave?</string>
@@ -4391,11 +4391,11 @@
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
<string name="api_fallback_title">API direta</string>
<string name="current_surface_paths_title">Current paths</string>
<string name="dashboard_address_editor_body">Defina o endereço do Dashboard e do Gateway que este telefone deve usar. Rotas privadas de LAN e Tailscale podem usar HTTP ou HTTPS; rotas públicas exigem HTTPS.</string>
<string name="dashboard_address_editor_body">Defina o endereço do Dashboard e Gateway desta conexão. Inclua :porta quando necessário, por exemplo 192.168.1.10:9119 ou https://hermes.example.com:10443. HTTPS é recomendado. Outros endereços HTTP exigem aceitação explícita do risco.</string>
<string name="dashboard_address_editor_title">Endereço do gateway</string>
<string name="dashboard_address_label">Dashboard &amp; Gateway address</string>
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
<string name="dashboard_address_oidc_hint">Alterar o endereço preserva este gateway e as referências ao histórico, mas uma origem diferente exige novo login. Os retornos OIDC são configurados no Hermes.</string>
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
<string name="dashboard_address_preview">Will use: %1$s</string>
<string name="dashboard_address_required">Informe o endereço do Gateway Hermes</string>
<string name="dashboard_gateway_configured">Configured address</string>
@@ -4453,4 +4453,9 @@
<string name="voice_overlay_settings_hint">Opcional nas duas versões. Abra o foco de voz no Chat e escolha Sobreposição. As permissões sozinhas nunca iniciam a escuta.</string>
<string name="voice_overlay_reset_position">Redefinir posição</string>
<string name="injected_context_gateway_unsupported">Não é enviado no chat do Gateway. Esta conexão não oferece suporte a contexto adicional por turno.</string>
<string name="dashboard_http_risk">HTTP não criptografa solicitações a %1$s. Senhas, tokens de sessão e conversas podem ficar expostos. Se depender de uma VPN, você é responsável por manter o tráfego dentro dela, inclusive se ela cair. O aplicativo não detecta nem impõe proteção VPN.</string>
<string name="dashboard_http_allow">Aceito o risco e permito HTTP para este endereço</string>
<string name="cw_auth_verified">Login verificado</string>
<string name="cw_chat_checked_on_open">Verificado ao abrir o Chat</string>
<string name="dashboard_local_auth_help">Hermes está acessível, mas as solicitações protegidas não estão autorizadas. Se o Dashboard for encaminhado do loopback, confira o endereço de escuta, o provedor de autenticação e dashboard.public_url no host. Um 401 sozinho não identifica a causa.</string>
</resources>
+25 -20
View File
@@ -315,7 +315,7 @@
<string name="cw_cloud_subtitle">连接到你的托管智能体</string>
<string name="cw_server_vps_title">远程网关</string>
<string name="cw_server_vps_subtitle">输入其 Dashboard 地址</string>
<string name="cw_relay_optional_note">私有 LAN 和 Tailscale 地址可使用 HTTP 或 HTTPS。公共地址必须使用 HTTPS。Relay 与 API 后备均为可选。</string>
<string name="cw_relay_optional_note">建议使用 HTTPS。其他 HTTP 地址需要明确接受风险。应用不会强制执行 VPN 保护。Relay 和 Direct API 均为可选。</string>
<string name="cw_cloud_entry_title">连接到 Nous 托管的 Hermes</string>
<string name="cw_cloud_entry_description">输入 Nous Portal 中显示的智能体地址。找到 Hermes 后,你将安全登录。</string>
<string name="cw_cloud_agent_name">智能体地址</string>
@@ -323,7 +323,7 @@
<string name="cw_cloud_slug_hint">我们将连接到 your-agent.agents.nousresearch.com。</string>
<string name="cw_cloud_slug_error">请准确输入 .agents.nousresearch.com 前的字母、数字和连字符。</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">请使用为托管智能体显示的完整 HTTPS 地址。</string>
<string name="cw_cloud_address_hint">请使用为托管智能体显示的完整 HTTPS 地址;如有提供,也请包含 :端口。</string>
<string name="cw_cloud_use_custom_address">使用自定义网址</string>
<string name="cw_cloud_use_nous_address">使用 Nous 托管地址</string>
<string name="cw_before_connecting">连接之前</string>
@@ -343,10 +343,10 @@
<string name="cw_nearby_enter_address">改为输入地址</string>
<string name="cw_other_connection_methods">其他连接方式</string>
<string name="cw_manual_hermes_title">输入网关地址</string>
<string name="cw_manual_hermes_description">请输入你在浏览器中打开的仪表板地址。如果此手机无法打开,请启动 hermes dashboard 并检查 Wi-Fi 或 Tailscale。</string>
<string name="cw_manual_hermes_description">输入可在此手机浏览器中打开的 Dashboard 地址。通过 LAN 或 VPN 访问时,主机需要可达的 Dashboard 和身份验证。若浏览器无法连接,请参阅设置指南。</string>
<string name="cw_hermes_address">Hermes 地址</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 或 hermes.example.com</string>
<string name="cw_hermes_address_hint">无需 API 密钥。未指定端口时使用仪表板端口 9119。</string>
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
<string name="cw_hermes_address_hint">示例:192.168.1.10:9119 或 https://hermes.example.com:10443。省略时,私有主机使用端口 9119,HTTPS 使用端口 443。无需 API 密钥。</string>
<string name="cw_find_hermes">查找 Hermes</string>
<string name="cw_hermes_found">已找到 Hermes</string>
<string name="cw_ready_to_connect">可以连接</string>
@@ -369,12 +369,12 @@
<string name="cw_skip_for_now">暂时跳过——稍后在设置中配置</string>
<string name="cw_back">上一步</string>
<string name="cw_connect_button">连接</string>
<string name="cw_hermes_label">Hermes</string>
<string name="cw_hermes_label_desc">用于聊天和管理。等启用终端、Bridge、Relay 会话或授权时再配对 Relay。仪表盘登录是首选的上游认证方式;API 密钥仍是 Android 聊天的备用方式。</string>
<string name="cw_hermes_label">Direct API</string>
<string name="cw_hermes_label_desc">Direct API 是为 API 服务器聊天明确选择的兼容连接。Manage、Dashboard 会话和标准语音需要单独登录 Dashboard。它不会接管现有 Gateway 对话。</string>
<string name="cw_api_url_label">API 服务器地址或主机名</string>
<string name="cw_api_key_label">API 密钥</string>
<string name="cw_api_key_placeholder">API_SERVER_KEY 的值</string>
<string name="cw_api_key_hint">在仪表盘网关传输启用前,Android 聊天需要此密钥。</string>
<string name="cw_api_key_hint">仅用于此 API 服务器的 Bearer 密钥,不能用于登录 Dashboard。</string>
<string name="cw_tailscale_label">远程访问——Tailscale 地址(可选)</string>
<string name="cw_dashboard">仪表盘</string>
<string name="cw_dashboard_routes_hint">路由:默认优先,Tailscale 在可达时作为备用。</string>
@@ -390,7 +390,7 @@
<!-- 方法卡片 -->
<string name="cw_method_hermes_title">Hermes</string>
<string name="cw_method_hermes_subtitle">API/仪表盘设置,用于聊天、管理、技能、定时任务、MCP、配置文件、模型和设置</string>
<string name="cw_method_hermes_subtitle">为无界面或兼容配置明确选择的 API 服务器聊天</string>
<string name="cw_method_scan_title">扫描设置二维码</string>
<string name="cw_method_scan_subtitle">扫描包含 API 地址/密钥的二维码;Relay 二维码详情需要 Relay 插件</string>
<string name="cw_method_pair_code_title">用配对码配对 Relay</string>
@@ -400,7 +400,7 @@
<!-- 标准连接表单 -->
<string name="cw_api_url_placeholder">192.168.1.10 或 http://你的服务器:8642</string>
<string name="cw_api_url_supporting">聊天和会话使用的 Hermes API——裸主机名默认使用 API 端口 8642 和 http://(仪表盘的 9119 端口单独推导)</string>
<string name="cw_api_url_supporting">Direct API 聊天使用的 API 服务器地址。仅输入主机时使用 HTTP 和端口 8642。</string>
<string name="cw_scan_message">正在扫描本局域网寻找 Hermes 仪表盘/API…</string>
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥仅用于明确配置的 Direct API 连接。</string>
<string name="cw_pair_relay_section">配对 Relay(可选)</string>
@@ -495,7 +495,7 @@
<string name="cw_timeout_entercode_showcode">主机尚未接受此配对码。请在 Hermes 主机上运行配对命令(或重新检查配对码),然后点击重试。</string>
<string name="cw_timeout_scan">在 Relay 确认配对之前超时。请检查 Relay 是否在运行且在此网络上可达,然后重试。如果配对似乎成功但仍无法访问 Hermes,则 API 服务器可能位于登录网关之后。</string>
<string name="cw_timeout_other">等待 Relay 超时。请检查 Relay 是否正在运行以及地址是否正确。</string>
<string name="cw_camera_denied">摄像头权限被拒绝。请改为手动配对——选择"用配对码配对 Relay"或输入您的服务器地址。</string>
<string name="cw_camera_denied">相机权限被拒绝。请选择远程 Gateway 并输入 Dashboard 地址。Relay 配对码位于高级选项中。</string>
<!-- EndpointsCard -->
<string name="endpoints_no_routes_stored">此连接尚未存储任何路由。</string>
@@ -3278,7 +3278,7 @@
<string name="endpoints_pin_title">固定端点</string>
<string name="endpoints_route_editor_desc">添加此手机在该网络上应使用的 Dashboard/Gateway 地址。</string>
<string name="endpoints_route_name_placeholder">路由名称</string>
<string name="endpoints_url_host_placeholder">100.x.y.z 或 host.ts.net</string>
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 或 https://host.ts.net:10443</string>
<string name="endpoints_url_supporting_blank">输入服务器的 Dashboard 地址</string>
<string name="endpoints_url_supporting_enter">使用 http:// 或 https:// Dashboard 地址</string>
<string name="endpoints_url_supporting_preview">将测试:%1$s</string>
@@ -3517,16 +3517,16 @@
<string name="cw_relay_pair_qr">配对 Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">扫描 Relay 设置二维码</string>
<string name="cw_relay_enter_code">输入配对码</string>
<string name="cw_dashboard_connected_title">Hermes 已连接</string>
<string name="cw_dashboard_connected_body">Dashboard 和 Gateway 已就绪。你可以开始聊天或打开 Manage。</string>
<string name="cw_dashboard_connected_title">Dashboard 访问已验证</string>
<string name="cw_dashboard_connected_body">继续进入 Chat。Gateway 连接和语音可用性会分别检查。</string>
<string name="cw_continue">继续</string>
<string name="cw_timeline_discovered">已发现 Hermes</string>
<string name="cw_timeline_discovered_detail">已验证 Dashboard 标识和状态端点</string>
<string name="cw_timeline_discovered_detail">已获取 Dashboard 状态</string>
<string name="cw_timeline_access">Dashboard 访问</string>
<string name="cw_timeline_access_ready">无需额外登录</string>
<string name="cw_timeline_authenticated">身份验证已确认</string>
<string name="cw_timeline_ready">连接已就绪</string>
<string name="cw_timeline_ready_detail">Chat、Manage 和 Voice 可以使用此 Dashboard</string>
<string name="cw_timeline_ready">可以继续</string>
<string name="cw_timeline_ready_detail">打开 Chat 时连接,语音可用性单独检查</string>
<string name="active_section_optional_api_fallback">可选 Direct API</string>
<string name="active_section_api_not_required">此连接使用 Hermes Dashboard 时不需要配置。</string>
<string name="active_section_where_api_key">从哪里获取此密钥?</string>
@@ -4472,11 +4472,11 @@
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
<string name="api_fallback_title">直接 API</string>
<string name="current_surface_paths_title">Current paths</string>
<string name="dashboard_address_editor_body">设置此手机使用的 Dashboard 和 Gateway 地址。私有 LAN 与 Tailscale 路由可使用 HTTP 或 HTTPS;公共路由必须使用 HTTPS。</string>
<string name="dashboard_address_editor_body">设置此连接的 Dashboard 和 Gateway 地址。需要时请包含 :端口,例如 192.168.1.10:9119 或 https://hermes.example.com:10443。建议使用 HTTPS。其他 HTTP 地址需要明确接受风险。</string>
<string name="dashboard_address_editor_title">网关地址</string>
<string name="dashboard_address_label">Dashboard &amp; Gateway address</string>
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
<string name="dashboard_address_oidc_hint">更改地址会保留此 Gateway 和历史记录引用,但更换源后需要重新登录。OIDC 回调在 Hermes 上配置。</string>
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
<string name="dashboard_address_preview">Will use: %1$s</string>
<string name="dashboard_address_required">输入 Hermes Gateway 地址</string>
<string name="dashboard_gateway_configured">Configured address</string>
@@ -4534,4 +4534,9 @@
<string name="voice_overlay_settings_hint">两个版本均可选择使用。在聊天的语音专注模式中选择悬浮窗。仅授予权限不会开始监听。</string>
<string name="voice_overlay_reset_position">重置位置</string>
<string name="injected_context_gateway_unsupported">Gateway 聊天不会发送此内容。此连接不支持每轮附加上下文。</string>
<string name="dashboard_http_risk">HTTP 不会加密发送到 %1$s 的请求。密码、会话令牌和对话可能泄露。如果依赖 VPN,您有责任确保流量始终处于 VPN 内,包括 VPN 断开时。应用不会检测或强制执行 VPN 保护。</string>
<string name="dashboard_http_allow">我接受风险并允许此地址使用 HTTP</string>
<string name="cw_auth_verified">登录已验证</string>
<string name="cw_chat_checked_on_open">打开 Chat 时验证</string>
<string name="dashboard_local_auth_help">Hermes 可达,但受保护的请求未获授权。如果 Dashboard 从回环地址转发,请检查主机的监听地址、身份验证提供方和 dashboard.public_url。仅凭 401 无法确定原因。</string>
</resources>
+25 -20
View File
@@ -315,7 +315,7 @@
<string name="cw_cloud_subtitle">Mit deinem gehosteten Agenten verbinden</string>
<string name="cw_server_vps_title">Remote-Gateway</string>
<string name="cw_server_vps_subtitle">Dashboard-Adresse eingeben</string>
<string name="cw_relay_optional_note">Private LAN- und Tailscale-Adressen dürfen HTTP oder HTTPS verwenden. Öffentliche Adressen erfordern HTTPS. Relay und Direct API sind optional.</string>
<string name="cw_relay_optional_note">HTTPS wird empfohlen. Andere HTTP-Adressen erfordern eine ausdrückliche Risikoannahme. Die App erzwingt keinen VPN-Schutz. Relay und Direct API sind optional.</string>
<string name="cw_cloud_entry_title">Mit von Nous gehostetem Hermes verbinden</string>
<string name="cw_cloud_entry_description">Gib die im Nous Portal angezeigte Agentenadresse ein. Nach dem Auffinden von Hermes meldest du dich sicher an.</string>
<string name="cw_cloud_agent_name">Agentenadresse</string>
@@ -323,7 +323,7 @@
<string name="cw_cloud_slug_hint">Wir verbinden dich mit dein-agent.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Gib exakt die Buchstaben, Zahlen und Bindestriche vor .agents.nousresearch.com ein.</string>
<string name="cw_cloud_address_placeholder">https://dein-agent.example.com</string>
<string name="cw_cloud_address_hint">Verwende die vollständige HTTPS-Adresse, die für deinen gehosteten Agenten angezeigt wird.</string>
<string name="cw_cloud_address_hint">Verwende die vollständige HTTPS-Adresse, die für deinen gehosteten Agenten angezeigt wird, einschließlich :Port, falls angegeben.</string>
<string name="cw_cloud_use_custom_address">Benutzerdefinierte URL verwenden</string>
<string name="cw_cloud_use_nous_address">Von Nous gehostete Adresse verwenden</string>
<string name="cw_before_connecting">Vor dem Verbinden</string>
@@ -343,10 +343,10 @@
<string name="cw_nearby_enter_address">Stattdessen Adresse eingeben</string>
<string name="cw_other_connection_methods">Andere Verbindungsmethoden</string>
<string name="cw_manual_hermes_title">Gateway-Adresse eingeben</string>
<string name="cw_manual_hermes_description">Gib die Dashboard-Adresse ein, die du im Browser öffnest. Wenn sie auf diesem Smartphone nicht geöffnet wird, starte hermes dashboard und prüfe WLAN oder Tailscale.</string>
<string name="cw_manual_hermes_description">Gib die Dashboard-Adresse ein, die sich auf diesem Telefon öffnen lässt. Für LAN oder VPN benötigt der Host ein erreichbares Dashboard mit Authentifizierung. Wenn der Browser keine Verbindung herstellt, lies die Einrichtungsanleitung.</string>
<string name="cw_hermes_address">Hermes-Adresse</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 oder hermes.example.com</string>
<string name="cw_hermes_address_hint">Kein API-Schlüssel erforderlich. Ohne Port wird Dashboard-Port 9119 verwendet.</string>
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
<string name="cw_hermes_address_hint">Beispiele: 192.168.1.10:9119 oder https://hermes.example.com:10443. Ohne Angabe verwenden private Hosts Port 9119 und HTTPS Port 443. Kein API-Schlüssel nötig.</string>
<string name="cw_find_hermes">Hermes suchen</string>
<string name="cw_hermes_found">Hermes gefunden</string>
<string name="cw_ready_to_connect">Verbindungsbereit</string>
@@ -369,12 +369,12 @@
<string name="cw_skip_for_now">Vorerst überspringen — später in den Einstellungen einrichten</string>
<string name="cw_back">Zurück</string>
<string name="cw_connect_button">Verbinden</string>
<string name="cw_hermes_label">Hermes</string>
<string name="cw_hermes_label_desc">Verwende dies für Chat und Verwaltung. Kopple Relay später nur, wenn du Terminal, Bridge, Relay-Sitzungen oder Berechtigungen aktivierst. Die Dashboard-Anmeldung ist der bevorzugte vorgelagerte Authentifizierungsweg; der API-Schlüssel bleibt die Ausweichlösung für Android-Chat.</string>
<string name="cw_hermes_label">Direct API</string>
<string name="cw_hermes_label_desc">Direct API ist eine ausdrücklich gewählte Kompatibilitätsverbindung für API-Server-Chats. Manage, Dashboard-Sitzungen und Standard-Sprachfunktionen erfordern eine separate Dashboard-Anmeldung. Bestehende Gateway-Unterhaltungen wechseln nicht zu Direct API.</string>
<string name="cw_api_url_label">API-Server-URL oder Host</string>
<string name="cw_api_key_label">API-Schlüssel</string>
<string name="cw_api_key_placeholder">Wert aus API_SERVER_KEY</string>
<string name="cw_api_key_hint">Für Android-Chat erforderlich, bis der Dashboard-Gateway-Transport aktiviert ist.</string>
<string name="cw_api_key_hint">Bearer-Schlüssel nur für diesen API-Server. Er meldet dich nicht am Dashboard an.</string>
<string name="cw_tailscale_label">Fernzugriff — Tailscale-URL (optional)</string>
<string name="cw_dashboard">Dashboard</string>
<string name="cw_dashboard_routes_hint">Routen: zuerst Standard, bei Erreichbarkeit Tailscale als Ausweichroute.</string>
@@ -390,7 +390,7 @@
<!-- Method tiles -->
<string name="cw_method_hermes_title">Hermes</string>
<string name="cw_method_hermes_subtitle">API-/Dashboard-Einrichtung für Chat, Verwaltung, Skills, Cron, MCP, Profile, Modelle und Einstellungen</string>
<string name="cw_method_hermes_subtitle">Ausdrücklich gewählter API-Server-Chat für Headless- oder Kompatibilitätskonfigurationen</string>
<string name="cw_method_scan_title">Einrichtungs-QR-Code scannen</string>
<string name="cw_method_scan_subtitle">Scanne einen QR-Code mit API-URL/-Schlüssel für Hermes; Relay-QR-Daten erfordern das Relay-Plugin</string>
<string name="cw_method_pair_code_title">Relay per Code koppeln</string>
@@ -400,7 +400,7 @@
<!-- Standard connect form -->
<string name="cw_api_url_placeholder">192.168.1.10 oder http://dein-server:8642</string>
<string name="cw_api_url_supporting">Von Chat und Sitzungen verwendete Hermes-API — bei reinen Hosts werden API-Port 8642 und http:// angenommen (Dashboard-Port 9119 wird separat abgeleitet)</string>
<string name="cw_api_url_supporting">API-Server-Adresse für Direct-API-Chat. Reine Hostnamen verwenden HTTP und Port 8642.</string>
<string name="cw_scan_message">Dieses LAN wird nach Hermes-Dashboard/API durchsucht…</string>
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel gilt nur für eine explizite Direct-API-Verbindung.</string>
<string name="cw_pair_relay_section">Relay koppeln (optional)</string>
@@ -495,7 +495,7 @@
<string name="cw_timeout_entercode_showcode">Der Host hat diesen Kopplungscode noch nicht angenommen. Führe den Kopplungsbefehl auf deinem Hermes-Host aus (oder prüfe den Code erneut) und tippe dann auf Erneut versuchen.</string>
<string name="cw_timeout_scan">Zeitüberschreitung, bevor das Relay die Kopplung bestätigt hat. Prüfe, ob das Relay läuft und in diesem Netzwerk erreichbar ist, und versuche es erneut. Falls die Kopplung erfolgreich scheint, Hermes aber weiterhin nicht erreichbar ist, befindet sich der API-Server möglicherweise hinter einem Anmelde-Gateway.</string>
<string name="cw_timeout_other">Zeitüberschreitung beim Warten auf das Relay. Prüfe, ob das Relay läuft und die URL stimmt.</string>
<string name="cw_camera_denied">Kameraberechtigung abgelehnt. Kopple stattdessen manuell — wähle \"Relay per Code koppeln\" oder gib deine Server-URL ein.</string>
<string name="cw_camera_denied">Kamerazugriff verweigert. Wähle Remote-Gateway und gib die Dashboard-Adresse ein. Relay-Kopplungscodes findest du unter Erweitert.</string>
<!-- EndpointsCard -->
<string name="endpoints_no_routes_stored">Für diese Verbindung sind keine Routen gespeichert.</string>
@@ -3347,7 +3347,7 @@
<string name="endpoints_pin_title">Angeheftete Endpunkte</string>
<string name="endpoints_route_editor_desc">Füge die Dashboard-/Gateway-Adresse hinzu, die dieses Telefon in diesem Netzwerk verwenden soll.</string>
<string name="endpoints_route_name_placeholder">Routenname</string>
<string name="endpoints_url_host_placeholder">100.x.y.z oder host.ts.net</string>
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 oder https://host.ts.net:10443</string>
<string name="endpoints_url_supporting_blank">Dashboard-Adresse des Servers eingeben</string>
<string name="endpoints_url_supporting_enter">Eine http://- oder https://-Dashboard-Adresse verwenden</string>
<string name="endpoints_url_supporting_preview">Wird getestet: %1$s</string>
@@ -3585,16 +3585,16 @@
<string name="cw_relay_pair_qr">Hermes Relay koppeln</string>
<string name="cw_relay_pair_qr_desc">Einen Relay-Einrichtungs-QR-Code scannen</string>
<string name="cw_relay_enter_code">Pairing-Code eingeben</string>
<string name="cw_dashboard_connected_title">Hermes ist verbunden</string>
<string name="cw_dashboard_connected_body">Dashboard und Gateway sind bereit. Du kannst chatten oder Manage öffnen.</string>
<string name="cw_dashboard_connected_title">Dashboard-Zugriff bestätigt</string>
<string name="cw_dashboard_connected_body">Weiter zu Chat. Gateway-Verbindung und Sprachverfügbarkeit werden getrennt geprüft.</string>
<string name="cw_continue">Weiter</string>
<string name="cw_timeline_discovered">Hermes gefunden</string>
<string name="cw_timeline_discovered_detail">Dashboard-Identität und Status-Endpunkt bestätigt</string>
<string name="cw_timeline_discovered_detail">Dashboard-Status abgerufen</string>
<string name="cw_timeline_access">Dashboard-Zugriff</string>
<string name="cw_timeline_access_ready">Keine weitere Anmeldung erforderlich</string>
<string name="cw_timeline_authenticated">Authentifizierung bestätigt</string>
<string name="cw_timeline_ready">Verbindung bereit</string>
<string name="cw_timeline_ready_detail">Chat, Manage und Voice können dieses Dashboard verwenden</string>
<string name="cw_timeline_ready">Bereit zum Fortfahren</string>
<string name="cw_timeline_ready_detail">Chat verbindet sich beim Öffnen; Sprachverfügbarkeit wird getrennt geprüft</string>
<string name="active_section_optional_api_fallback">Optionale Direct API</string>
<string name="active_section_api_not_required">Nicht erforderlich, wenn diese Verbindung das Hermes Dashboard verwendet.</string>
<string name="active_section_where_api_key">Wo erhalte ich diesen Schlüssel?</string>
@@ -4548,11 +4548,11 @@
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
<string name="api_fallback_title">Direct API</string>
<string name="current_surface_paths_title">Current paths</string>
<string name="dashboard_address_editor_body">Lege die Dashboard- und Gateway-Adresse für dieses Telefon fest. Private LAN- und Tailscale-Routen dürfen HTTP oder HTTPS verwenden; öffentliche Routen erfordern HTTPS.</string>
<string name="dashboard_address_editor_body">Lege die Dashboard- und Gateway-Adresse dieser Verbindung fest. Gib bei Bedarf :port an, zum Beispiel 192.168.1.10:9119 oder https://hermes.example.com:10443. HTTPS wird empfohlen. Andere HTTP-Adressen erfordern deine ausdrückliche Risikoannahme.</string>
<string name="dashboard_address_editor_title">Gateway-Adresse</string>
<string name="dashboard_address_label">Dashboard &amp; Gateway address</string>
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
<string name="dashboard_address_oidc_hint">Eine neue Adresse erhält dieses Gateway und seine Verlaufsverweise. Bei einem anderen Ursprung musst du dich erneut anmelden. OIDC-Rückrufe werden auf Hermes konfiguriert.</string>
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
<string name="dashboard_address_preview">Will use: %1$s</string>
<string name="dashboard_address_required">Gib die Hermes-Gateway-Adresse ein</string>
<string name="dashboard_gateway_configured">Configured address</string>
@@ -4610,4 +4610,9 @@
<string name="voice_overlay_settings_hint">In beiden Versionen optional. Öffne den Sprachfokus im Chat und wähle Overlay. Berechtigungen allein starten kein Zuhören.</string>
<string name="voice_overlay_reset_position">Position zurücksetzen</string>
<string name="injected_context_gateway_unsupported">Wird im Gateway-Chat nicht gesendet. Diese Verbindung unterstützt keinen zusätzlichen Kontext pro Nachricht.</string>
<string name="dashboard_http_risk">HTTP verschlüsselt Anfragen an %1$s nicht. Passwörter, Sitzungstoken und Unterhaltungen können offengelegt werden. Wenn du ein VPN nutzt, bist du dafür verantwortlich, dass der Verkehr darin bleibt, auch bei VPN-Ausfall. Die App erkennt oder erzwingt keinen VPN-Schutz.</string>
<string name="dashboard_http_allow">Ich akzeptiere das Risiko und erlaube HTTP für diese Adresse</string>
<string name="cw_auth_verified">Anmeldung bestätigt</string>
<string name="cw_chat_checked_on_open">Wird beim Öffnen von Chat geprüft</string>
<string name="dashboard_local_auth_help">Hermes ist erreichbar, aber geschützte Anfragen sind nicht autorisiert. Wird das Dashboard von Loopback weitergeleitet, prüfe Bind-Adresse, Authentifizierungsanbieter und dashboard.public_url auf dem Host. Ein 401 allein bestimmt die Ursache nicht.</string>
</resources>
+25 -20
View File
@@ -277,7 +277,7 @@
<string name="cw_cloud_subtitle">Conéctate a tu agente alojado</string>
<string name="cw_server_vps_title">Gateway remoto</string>
<string name="cw_server_vps_subtitle">Introduce la dirección del Dashboard</string>
<string name="cw_relay_optional_note">Las direcciones privadas LAN y Tailscale pueden usar HTTP o HTTPS. Las direcciones públicas requieren HTTPS. Relay y la alternativa API son opcionales.</string>
<string name="cw_relay_optional_note">Se recomienda HTTPS. Otras direcciones HTTP requieren aceptar el riesgo. La aplicación no impone protección VPN. Relay y Direct API son opcionales.</string>
<string name="cw_cloud_entry_title">Conectarse a Hermes alojado por Nous</string>
<string name="cw_cloud_entry_description">Introduce la dirección del agente que aparece en Nous Portal. Iniciarás sesión de forma segura después de encontrar Hermes.</string>
<string name="cw_cloud_agent_name">Dirección del agente</string>
@@ -285,7 +285,7 @@
<string name="cw_cloud_slug_hint">Nos conectaremos a tu-agente.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Introduce exactamente las letras, números y guiones anteriores a .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://tu-agente.example.com</string>
<string name="cw_cloud_address_hint">Usa la dirección HTTPS completa que aparece para tu agente alojado.</string>
<string name="cw_cloud_address_hint">Usa la dirección HTTPS completa que aparece para tu agente alojado, incluido :puerto si se proporciona.</string>
<string name="cw_cloud_use_custom_address">Usar URL personalizada</string>
<string name="cw_cloud_use_nous_address">Usar dirección alojada por Nous</string>
<string name="cw_before_connecting">Antes de conectar</string>
@@ -305,10 +305,10 @@
<string name="cw_nearby_enter_address">Introducir la dirección</string>
<string name="cw_other_connection_methods">Otros métodos de conexión</string>
<string name="cw_manual_hermes_title">Introduce la dirección del gateway</string>
<string name="cw_manual_hermes_description">Introduce la dirección del panel que abres en el navegador. Si no abre en este teléfono, inicia hermes dashboard y comprueba Wi-Fi o Tailscale.</string>
<string name="cw_manual_hermes_description">Introduce la dirección del Dashboard que puedes abrir en este teléfono. Para LAN o VPN, el host necesita un Dashboard accesible y autenticación. Consulta la guía de configuración si el navegador no conecta.</string>
<string name="cw_hermes_address">Dirección de Hermes</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 o hermes.example.com</string>
<string name="cw_hermes_address_hint">No se necesita una clave de API. Se usa el puerto 9119 si no incluyes uno.</string>
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
<string name="cw_hermes_address_hint">Ejemplos: 192.168.1.10:9119 o https://hermes.example.com:10443. Si se omite, los hosts privados usan el puerto 9119 y HTTPS usa el 443. No se necesita clave API.</string>
<string name="cw_find_hermes">Buscar Hermes</string>
<string name="cw_hermes_found">Hermes encontrado</string>
<string name="cw_ready_to_connect">Listo para conectar</string>
@@ -331,12 +331,12 @@
<string name="cw_skip_for_now">Saltar por ahora: configurar más tarde en Configuración</string>
<string name="cw_back">Atrás</string>
<string name="cw_connect_button">Conectar</string>
<string name="cw_hermes_label">Hermes</string>
<string name="cw_hermes_label_desc">Utilice esto para chatear y administrar. Empareje Relay más tarde solo cuando habilite Terminal, sesiones Bridge, Relay o concesiones. El inicio de sesión en el panel es la ruta de autenticación ascendente preferida; la clave API sigue siendo la alternativa del chat Android.</string>
<string name="cw_hermes_label">Direct API</string>
<string name="cw_hermes_label_desc">Direct API es una conexión de compatibilidad elegida explícitamente para el chat del servidor API. Manage, las sesiones del Dashboard y la voz estándar requieren iniciar sesión por separado en el Dashboard. No sustituye una conversación existente del Gateway.</string>
<string name="cw_api_url_label">URL o host del servidor API</string>
<string name="cw_api_key_label">Clave API</string>
<string name="cw_api_key_placeholder">Valor de API_SERVER_KEY</string>
<string name="cw_api_key_hint">Necesario para el chat Android hasta que se habilite el transporte gateway del panel.</string>
<string name="cw_api_key_hint">Clave bearer solo para este servidor API. No inicia sesión en el Dashboard.</string>
<string name="cw_tailscale_label">Acceso remoto: URL Tailscale (opcional)</string>
<string name="cw_dashboard">Panel</string>
<string name="cw_dashboard_routes_hint">Rutas: predeterminada primero, Tailscale alternativa cuando sea accesible.</string>
@@ -350,7 +350,7 @@
<string name="cw_update_button">Actualizar</string>
<string name="cw_cancel">Cancelar</string>
<string name="cw_method_hermes_title">Hermes</string>
<string name="cw_method_hermes_subtitle">Configuración de API/dashboard para chat, administración, habilidades, cron, MCP, perfiles, modelos y configuraciones</string>
<string name="cw_method_hermes_subtitle">Chat explícito del servidor API para instalaciones sin interfaz o de compatibilidad</string>
<string name="cw_method_scan_title">Configuración de escaneo QR</string>
<string name="cw_method_scan_subtitle">Escanee un QR con API URL/key para Hermes; Los detalles de Relay QR requieren el complemento Relay</string>
<string name="cw_method_pair_code_title">Emparejar Relay por código</string>
@@ -358,7 +358,7 @@
<string name="cw_method_show_code_title">Mostrar código Relay</string>
<string name="cw_method_show_code_subtitle">¿Sin cámara o QR? Registra el código de este teléfono en el anfitrión</string>
<string name="cw_api_url_placeholder">192.168.1.10 o http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API utilizado por Chat y sesiones — API puerto 8642 y http:// asumido para hosts desnudos (el 9119 del tablero se deriva por separado)</string>
<string name="cw_api_url_supporting">Dirección del servidor para el chat Direct API. Los hosts sin esquema usan HTTP y el puerto 8642.</string>
<string name="cw_scan_message">Escaneando esta LAN en busca de Hermes dashboard/API…</string>
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API solo se usa para la alternativa directa opcional mediante API.</string>
<string name="cw_pair_relay_section">Emparejar Relay (opcional)</string>
@@ -448,7 +448,7 @@
<string name="cw_timeout_entercode_showcode">El anfitrión aún no ha aceptado este código de vinculación. Ejecuta el comando de emparejamiento en tu host Hermes (o vuelve a comprobar el código) y, a continuación, toca Reintentar.</string>
<string name="cw_timeout_scan">Se agotó el tiempo antes de que el relay confirmara el emparejamiento. Compruebe que el relay se esté ejecutando y sea accesible en esta red y, a continuación, vuelva a intentarlo. Si el emparejamiento parece tener éxito pero aún no se puede acceder a Hermes, el servidor API puede estar detrás de un inicio de sesión gateway.</string>
<string name="cw_timeout_other">Se agotó el tiempo de espera para el relay. Verifique que relay se esté ejecutando y que la URL sea correcta.</string>
<string name="cw_camera_denied">Permiso de cámara denegado. En su lugar, empareje manualmente: elija \"Empareje Relay mediante el código\" o ingrese la URL de su servidor.</string>
<string name="cw_camera_denied">Permiso de cámara denegado. Elige Gateway remoto e introduce la dirección del Dashboard. Los códigos de emparejamiento Relay están en Avanzado.</string>
<string name="endpoints_no_routes_stored">No hay rutas almacenadas para esta conexión.</string>
<string name="endpoints_add_route">Agregar ruta</string>
<string name="endpoints_resolving">Resolviendo…</string>
@@ -3011,7 +3011,7 @@
<string name="endpoints_pin_title">Puntos finales fijados</string>
<string name="endpoints_route_editor_desc">Añada la dirección de Dashboard/Gateway que debe usar este teléfono en esa red.</string>
<string name="endpoints_route_name_placeholder">Nombre de la ruta</string>
<string name="endpoints_url_host_placeholder">100.x.y.z o host.ts.net</string>
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 o https://host.ts.net:10443</string>
<string name="endpoints_url_supporting_blank">Introduzca la dirección de Dashboard del servidor</string>
<string name="endpoints_url_supporting_enter">Use una dirección de Dashboard http:// o https://</string>
<string name="endpoints_url_supporting_preview">Se probará: %1$s</string>
@@ -3274,16 +3274,16 @@
<string name="cw_relay_pair_qr">Vincular Hermes Relay</string>
<string name="cw_relay_pair_qr_desc">Escanea un QR de configuración de Relay</string>
<string name="cw_relay_enter_code">Introducir código de vinculación</string>
<string name="cw_dashboard_connected_title">Hermes está conectado</string>
<string name="cw_dashboard_connected_body">Dashboard y Gateway están listos. Puedes empezar a chatear o abrir Manage.</string>
<string name="cw_dashboard_connected_title">Acceso al Dashboard verificado</string>
<string name="cw_dashboard_connected_body">Continúa a Chat. La conexión Gateway y la disponibilidad de voz se comprueban por separado.</string>
<string name="cw_continue">Continuar</string>
<string name="cw_timeline_discovered">Hermes encontrado</string>
<string name="cw_timeline_discovered_detail">Identidad del Dashboard y endpoint de estado verificados</string>
<string name="cw_timeline_discovered_detail">Estado del Dashboard obtenido</string>
<string name="cw_timeline_access">Acceso al Dashboard</string>
<string name="cw_timeline_access_ready">No se requiere otro inicio de sesión</string>
<string name="cw_timeline_authenticated">Autenticación verificada</string>
<string name="cw_timeline_ready">Conexión lista</string>
<string name="cw_timeline_ready_detail">Chat, Manage y Voice pueden usar este Dashboard</string>
<string name="cw_timeline_ready">Listo para continuar</string>
<string name="cw_timeline_ready_detail">Chat conecta al abrirse; la voz se comprueba por separado</string>
<string name="active_section_optional_api_fallback">Alternativa directa opcional mediante API</string>
<string name="active_section_api_not_required">No es necesaria cuando esta conexión usa Hermes Dashboard.</string>
<string name="active_section_where_api_key">¿Dónde obtengo esta clave?</string>
@@ -4239,11 +4239,11 @@
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
<string name="api_fallback_title">API directa</string>
<string name="current_surface_paths_title">Current paths</string>
<string name="dashboard_address_editor_body">Define la dirección del Dashboard y Gateway que usará este teléfono. Las rutas privadas LAN y Tailscale pueden usar HTTP o HTTPS; las rutas públicas requieren HTTPS.</string>
<string name="dashboard_address_editor_body">Configura la dirección del Dashboard y Gateway de esta conexión. Incluye :puerto cuando sea necesario, por ejemplo 192.168.1.10:9119 o https://hermes.example.com:10443. Se recomienda HTTPS. Otras direcciones HTTP requieren aceptar expresamente el riesgo.</string>
<string name="dashboard_address_editor_title">Dirección del gateway</string>
<string name="dashboard_address_label">Dashboard &amp; Gateway address</string>
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
<string name="dashboard_address_oidc_hint">Cambiar la dirección conserva este gateway y sus referencias al historial, pero un origen distinto requiere volver a iniciar sesión. Las devoluciones OIDC se configuran en Hermes.</string>
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
<string name="dashboard_address_preview">Will use: %1$s</string>
<string name="dashboard_address_required">Introduce la dirección del Gateway Hermes</string>
<string name="dashboard_gateway_configured">Configured address</string>
@@ -4301,4 +4301,9 @@
<string name="voice_overlay_settings_hint">Opcional en ambas versiones. Abre el enfoque de voz en Chat y elige Superposición. Los permisos por sí solos nunca inician la escucha.</string>
<string name="voice_overlay_reset_position">Restablecer posición</string>
<string name="injected_context_gateway_unsupported">No se envía en el chat de Gateway. Esta conexión no admite contexto adicional por turno.</string>
<string name="dashboard_http_risk">HTTP no cifra las solicitudes a %1$s. Las contraseñas, los tokens de sesión y las conversaciones podrían quedar expuestos. Si dependes de una VPN, eres responsable de mantener el tráfico dentro de ella, incluso si falla. La aplicación no detecta ni impone protección VPN.</string>
<string name="dashboard_http_allow">Acepto el riesgo y permito HTTP para esta dirección</string>
<string name="cw_auth_verified">Inicio de sesión verificado</string>
<string name="cw_chat_checked_on_open">Se verifica al abrir Chat</string>
<string name="dashboard_local_auth_help">Hermes es accesible, pero las solicitudes protegidas no están autorizadas. Si el Dashboard se reenvía desde loopback, comprueba la dirección de escucha, el proveedor de autenticación y dashboard.public_url en el host. Un 401 por sí solo no identifica la causa.</string>
</resources>
+25 -20
View File
@@ -315,7 +315,7 @@
<string name="cw_cloud_subtitle">ホスト済みエージェントに接続します</string>
<string name="cw_server_vps_title">リモートゲートウェイ</string>
<string name="cw_server_vps_subtitle">Dashboard アドレスを入力</string>
<string name="cw_relay_optional_note">プライベート LAN と Tailscale のアドレスは HTTP または HTTPS を使用できます。公開アドレスには HTTPS が必要です。Relay と Direct API は任意です。</string>
<string name="cw_relay_optional_note">HTTPS を推奨します。それ以外の HTTP アドレスには明示的なリスク同意が必要です。アプリは VPN 保護を強制しません。Relay と Direct API は任意です。</string>
<string name="cw_cloud_entry_title">Nous ホスト版 Hermes に接続</string>
<string name="cw_cloud_entry_description">Nous Portal に表示されるエージェントのアドレスを入力してください。Hermes が見つかった後、安全にサインインします。</string>
<string name="cw_cloud_agent_name">エージェントのアドレス</string>
@@ -323,7 +323,7 @@
<string name="cw_cloud_slug_hint">your-agent.agents.nousresearch.com に接続します。</string>
<string name="cw_cloud_slug_error">.agents.nousresearch.com の前にある英字、数字、ハイフンを正確に入力してください。</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">ホスト済みエージェントに表示される完全な HTTPS アドレスを使用してください。</string>
<string name="cw_cloud_address_hint">ホスト済みエージェントに表示される完全な HTTPS アドレスを使用し、指定されている場合は :ポートも含めてください。</string>
<string name="cw_cloud_use_custom_address">カスタム URL を使用</string>
<string name="cw_cloud_use_nous_address">Nous ホスト版アドレスを使用</string>
<string name="cw_before_connecting">接続する前に</string>
@@ -343,10 +343,10 @@
<string name="cw_nearby_enter_address">アドレスを入力</string>
<string name="cw_other_connection_methods">その他の接続方法</string>
<string name="cw_manual_hermes_title">ゲートウェイのアドレスを入力</string>
<string name="cw_manual_hermes_description">ブラウザで開くダッシュボードアドレスを入力します。このスマートフォンで開けない場合は hermes dashboard を起動し、Wi-Fi または Tailscale を確認してください。</string>
<string name="cw_manual_hermes_description">この端末のブラウザーで開ける Dashboard アドレスを入力してください。LAN や VPN では、ホストの Dashboard が到達可能で認証を設定済みである必要があります。接続できない場合はセットアップガイドを確認してください。</string>
<string name="cw_hermes_address">Hermes アドレス</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 または hermes.example.com</string>
<string name="cw_hermes_address_hint">API キーは不要です。ポートを省略するとダッシュボードの 9119 番ポートを使用します。</string>
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
<string name="cw_hermes_address_hint">例: 192.168.1.10:9119 または https://hermes.example.com:10443。省略した場合、プライベートホストはポート 9119、HTTPS はポート 443 を使用します。API キーは不要です。</string>
<string name="cw_find_hermes">Hermes を検索</string>
<string name="cw_hermes_found">Hermes が見つかりました</string>
<string name="cw_ready_to_connect">接続できます</string>
@@ -369,12 +369,12 @@
<string name="cw_skip_for_now">今はスキップします — 後で [設定] で設定します</string>
<string name="cw_back">戻る</string>
<string name="cw_connect_button">接続する</string>
<string name="cw_hermes_label">Hermes</string>
<string name="cw_hermes_label_desc">チャットと管理に使用します。後でターミナル、Bridge、Relay セッション、または許可を有効にする場合にのみ、Relay をペアリングします。ダッシュボード サインインは、優先されるアップストリーム認証パスです。 API キーは Android チャット フォールバックのままです。</string>
<string name="cw_hermes_label">Direct API</string>
<string name="cw_hermes_label_desc">Direct API は、API サーバーのチャット用に明示的に選ぶ互換接続です。Manage、Dashboard のセッション、標準音声には別途 Dashboard へのサインインが必要です。既存の Gateway の会話を引き継ぐものではありません。</string>
<string name="cw_api_url_label">API サーバー URL またはホスト</string>
<string name="cw_api_key_label">API キー</string>
<string name="cw_api_key_placeholder">API_SERVER_KEY の値</string>
<string name="cw_api_key_hint">ダッシュボード Gateway トランスポートが有効になるまで、Android チャットに必要です。</string>
<string name="cw_api_key_hint">この API サーバー専用の Bearer キーです。Dashboard へのサインインには使えません。</string>
<string name="cw_tailscale_label">リモート アクセス — Tailscale URL (オプション)</string>
<string name="cw_dashboard">ダッシュボード</string>
<string name="cw_dashboard_routes_hint">ルート: 最初はデフォルト、到達可能な場合は Tailscale フォールバック。</string>
@@ -390,7 +390,7 @@
<!-- Method tiles -->
<string name="cw_method_hermes_title">Hermes</string>
<string name="cw_method_hermes_subtitle">API/チャット、管理、スキル、Cron、MCP、プロファイル、モデル、設定のダッシュボード設定</string>
<string name="cw_method_hermes_subtitle">ヘッドレス構成や互換用途で明示的に使う API サーバーチャット</string>
<string name="cw_method_scan_title">スキャン設定 QR</string>
<string name="cw_method_scan_subtitle">QR を API URL/キーで Hermes にスキャンします。 Relay QR の詳細には Relay プラグインが必要です</string>
<string name="cw_method_pair_code_title">Relay をコードでペアリングする</string>
@@ -400,7 +400,7 @@
<!-- Standard connect form -->
<string name="cw_api_url_placeholder">192.168.1.10 または http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API チャットとセッションで使用されます — API ポート 8642 および http:// はベア ホストとして想定されます (ダッシュボードの 9119 は個別に派生します)</string>
<string name="cw_api_url_supporting">Direct API チャット用の API サーバーアドレス。ホスト名だけの場合は HTTP とポート 8642 を使います。</string>
<string name="cw_scan_message">この LAN をスキャンして Hermes ダッシュボード/API を探しています…</string>
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。API キーは明示的な Direct API 接続でのみ使います。</string>
<string name="cw_pair_relay_section">Relay のペア (オプション)</string>
@@ -495,7 +495,7 @@
<string name="cw_timeout_entercode_showcode">ホストはまだこのペアリング コードを受け入れていません。 Hermes ホストでペアリング コマンドを実行し (またはコードを再確認し)、[再試行] をタップします。</string>
<string name="cw_timeout_scan">Relayがペアリングを確認する前にタイムアウトしました。Relayが実行中であり、このネットワーク上で到達可能であることを確認してから、再試行してください。ペアリングが成功したように見えても、Hermes にまだ到達できない場合は、API サーバーがログイン Gatewayの背後にある可能性があります。</string>
<string name="cw_timeout_other">Relay待ちでタイムアウト。Relayが動作していること、URL が正しいことを確認してください。</string>
<string name="cw_camera_denied">カメラの許可が拒否されました。代わりに手動でペアリングします。「コードで Relay をペアリング」を選択するか、サーバー URL を入力します。</string>
<string name="cw_camera_denied">カメラの権限が拒否されました。リモート Gateway を選び、Dashboard アドレスを入力してください。Relay のペアリングコードは詳細設定にあります。</string>
<!-- EndpointsCard -->
<string name="endpoints_no_routes_stored">この接続にはルートが保存されていません。</string>
@@ -3354,7 +3354,7 @@
<string name="endpoints_pin_title">固定されたエンドポイント</string>
<string name="endpoints_route_editor_desc">このネットワークで電話が使用する Dashboard/Gateway アドレスを追加します。</string>
<string name="endpoints_route_name_placeholder">路線名</string>
<string name="endpoints_url_host_placeholder">100.x.y.z または host.ts.net</string>
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 または https://host.ts.net:10443</string>
<string name="endpoints_url_supporting_blank">サーバーの Dashboard アドレスを入力します</string>
<string name="endpoints_url_supporting_enter">http:// または https:// の Dashboard アドレスを使用します</string>
<string name="endpoints_url_supporting_preview">テスト対象: %1$s</string>
@@ -3581,16 +3581,16 @@
<string name="cw_relay_pair_qr">Hermes Relay をペアリング</string>
<string name="cw_relay_pair_qr_desc">Relay セットアップ QR をスキャン</string>
<string name="cw_relay_enter_code">ペアリングコードを入力</string>
<string name="cw_dashboard_connected_title">Hermes に接続しました</string>
<string name="cw_dashboard_connected_body">Dashboard と Gateway の準備ができました。チャットを開始するか Manage を開けます。</string>
<string name="cw_dashboard_connected_title">Dashboard アクセス確認済み</string>
<string name="cw_dashboard_connected_body">Chat に進みます。Gateway 接続と音声の利用可否は別途確認します。</string>
<string name="cw_continue">続行</string>
<string name="cw_timeline_discovered">Hermes を検出</string>
<string name="cw_timeline_discovered_detail">Dashboard の識別情報とステータスエンドポイントを確認済み</string>
<string name="cw_timeline_discovered_detail">Dashboard の状態を取得しました</string>
<string name="cw_timeline_access">Dashboard アクセス</string>
<string name="cw_timeline_access_ready">追加のサインインは不要です</string>
<string name="cw_timeline_authenticated">認証を確認済み</string>
<string name="cw_timeline_ready">接続準備完了</string>
<string name="cw_timeline_ready_detail">Chat、Manage、Voice でこの Dashboard を使用できます</string>
<string name="cw_timeline_ready">続行できます</string>
<string name="cw_timeline_ready_detail">Chat は開く際に接続し、音声の利用可否は別途確認します</string>
<string name="active_section_optional_api_fallback">任意の Direct API</string>
<string name="active_section_api_not_required">この接続が Hermes Dashboard を使用する場合は必要ありません。</string>
<string name="active_section_where_api_key">このキーはどこで入手しますか?</string>
@@ -4543,11 +4543,11 @@
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
<string name="api_fallback_title">Direct API</string>
<string name="current_surface_paths_title">Current paths</string>
<string name="dashboard_address_editor_body">この端末で使う Dashboard と Gateway のアドレスを設定します。プライベート LAN と Tailscale ルートは HTTP または HTTPS を使用でき、公開ルートには HTTPS が必要です。</string>
<string name="dashboard_address_editor_body">この接続の Dashboard と Gateway のアドレスを設定します。必要に応じて :ポートを指定してください(例: 192.168.1.10:9119 または https://hermes.example.com:10443)。HTTPS を推奨します。それ以外の HTTP アドレスにはリスクへの明示的な同意が必要です。</string>
<string name="dashboard_address_editor_title">ゲートウェイのアドレス</string>
<string name="dashboard_address_label">Dashboard &amp; Gateway address</string>
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
<string name="dashboard_address_oidc_hint">アドレスを変更しても Gateway と履歴への参照は保持されますが、接続元が変わる場合は再サインインが必要です。OIDC コールバックは Hermes 側で設定します。</string>
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
<string name="dashboard_address_preview">Will use: %1$s</string>
<string name="dashboard_address_required">Hermes Gateway のアドレスを入力してください</string>
<string name="dashboard_gateway_configured">Configured address</string>
@@ -4605,4 +4605,9 @@
<string name="voice_overlay_settings_hint">両方のビルドで任意に利用できます。チャットの音声フォーカスでオーバーレイを選択します。権限の付与だけで録音は始まりません。</string>
<string name="voice_overlay_reset_position">位置をリセット</string>
<string name="injected_context_gateway_unsupported">Gateway チャットでは送信されません。この接続はターンごとの追加コンテキストに対応していません。</string>
<string name="dashboard_http_risk">HTTP は %1$s へのリクエストを暗号化しません。パスワード、セッショントークン、会話が露出する可能性があります。VPN に依存する場合、VPN 切断時も含め、通信を VPN 内に保つ責任は利用者にあります。アプリは VPN の保護を検出も強制もしません。</string>
<string name="dashboard_http_allow">リスクを理解し、このアドレスで HTTP を許可します</string>
<string name="cw_auth_verified">サインイン確認済み</string>
<string name="cw_chat_checked_on_open">Chat を開く際に確認します</string>
<string name="dashboard_local_auth_help">Hermes には到達できますが、保護されたリクエストが認可されていません。Dashboard をループバックから転送している場合は、ホストのバインドアドレス、認証プロバイダー、dashboard.public_url を確認してください。401 だけでは原因を特定できません。</string>
</resources>
+25 -20
View File
@@ -298,7 +298,7 @@
<string name="cw_cloud_subtitle">Подключитесь к своему размещённому агенту</string>
<string name="cw_server_vps_title">Удалённый шлюз</string>
<string name="cw_server_vps_subtitle">Введите адрес Dashboard</string>
<string name="cw_relay_optional_note">Частные адреса LAN и Tailscale могут использовать HTTP или HTTPS. Публичным адресам требуется HTTPS. Relay и Direct API необязательны.</string>
<string name="cw_relay_optional_note">Рекомендуется HTTPS. Для других HTTP-адресов нужно явно принять риск. Приложение не обеспечивает защиту VPN. Relay и Direct API необязательны.</string>
<string name="cw_cloud_entry_title">Подключиться к Hermes на хостинге Nous</string>
<string name="cw_cloud_entry_description">Введите адрес агента, указанный в Nous Portal. После обнаружения Hermes вы безопасно войдёте в систему.</string>
<string name="cw_cloud_agent_name">Адрес агента</string>
@@ -306,7 +306,7 @@
<string name="cw_cloud_slug_hint">Будет выполнено подключение к ваш-агент.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Точно введите буквы, цифры и дефисы перед .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">Используйте полный HTTPS-адрес, указанный для вашего размещённого агента.</string>
<string name="cw_cloud_address_hint">Используйте полный HTTPS-адрес, указанный для вашего размещённого агента, включая :порт, если он указан.</string>
<string name="cw_cloud_use_custom_address">Использовать собственный URL</string>
<string name="cw_cloud_use_nous_address">Использовать адрес хостинга Nous</string>
<string name="cw_before_connecting">Перед подключением</string>
@@ -333,10 +333,10 @@
<string name="cw_relay_pair_qr_desc">Просканируйте QR-код настройки Relay</string>
<string name="cw_relay_enter_code">Введите код сопоставления Relay</string>
<string name="cw_manual_hermes_title">Введите адрес шлюза</string>
<string name="cw_manual_hermes_description">Введите адрес панели управления, который вы открываете в браузере. Если он не открывается на этом телефоне, запустите панель управления Гермесом и проверьте Wi-Fi или Tailscale.</string>
<string name="cw_manual_hermes_description">Введите адрес Dashboard, который открывается на этом телефоне. Для LAN или VPN на хосте нужен доступный Dashboard с аутентификацией. Если браузер не подключается, откройте руководство по настройке.</string>
<string name="cw_hermes_address">Адрес Гермеса</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 или hermes.example.com</string>
<string name="cw_hermes_address_hint">API-ключ не требуется. Порт панели управления 9119 используется, если порт не указан.</string>
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
<string name="cw_hermes_address_hint">Примеры: 192.168.1.10:9119 или https://hermes.example.com:10443. Если порт не указан, частные хосты используют 9119, а HTTPS — 443. Ключ API не нужен.</string>
<string name="cw_find_hermes">Найти Гермеса</string>
<string name="cw_hermes_found">Гермес найден</string>
<string name="cw_ready_to_connect">Готов к подключению</string>
@@ -346,16 +346,16 @@
<string name="cw_could_not_verify">Не удалось проверить</string>
<string name="cw_choose_another">Выбрать другой</string>
<string name="cw_sign_in_to_hermes">Войти в Гермес</string>
<string name="cw_dashboard_connected_title">Гермес подключен</string>
<string name="cw_dashboard_connected_body">Панель управления и шлюз готовы. Вы можете начать чат или открыть управление.</string>
<string name="cw_dashboard_connected_title">Доступ к Dashboard подтверждён</string>
<string name="cw_dashboard_connected_body">Перейдите в чат. Подключение Gateway и доступность голоса проверяются отдельно.</string>
<string name="cw_continue">Продолжить</string>
<string name="cw_timeline_discovered">Обнаружен Гермес</string>
<string name="cw_timeline_discovered_detail">Проверен идентификатор панели управления и endpoint состояния</string>
<string name="cw_timeline_discovered_detail">Статус Dashboard получен</string>
<string name="cw_timeline_access">Доступ к панели управления</string>
<string name="cw_timeline_access_ready">Дополнительный вход не требуется</string>
<string name="cw_timeline_authenticated">Аутентификация проверена</string>
<string name="cw_timeline_ready">Подключение готово</string>
<string name="cw_timeline_ready_detail">Чат, Управление и Голос могут использовать эту панель управления</string>
<string name="cw_timeline_ready">Можно продолжить</string>
<string name="cw_timeline_ready_detail">Чат подключается при открытии; голос проверяется отдельно</string>
<string name="cw_semantics_hermes_available">Гермес доступен</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Используйте эти параметры для совместимости только с API, настройки пользователя или альтернативного сопряжения Relay.</string>
@@ -369,12 +369,12 @@
<string name="cw_skip_for_now">Пропустить на данный момент — настроить позже в настройках</string>
<string name="cw_back">Назад</string>
<string name="cw_connect_button">Подключить</string>
<string name="cw_hermes_label">Гермес</string>
<string name="cw_hermes_label_desc">Используйте это для Чата и Управления. Сопрягайте плагин Relay позже только при включении Терминала, Моста, сессий Relay или разрешений. Вход на панели управления является предпочтительным способом аутентификации; ключ API остается резервным для Android Chat.</string>
<string name="cw_hermes_label">Direct API</string>
<string name="cw_hermes_label_desc">Direct API — явно выбранное совместимое подключение для чата API-сервера. Для Manage, сеансов Dashboard и стандартного голоса нужен отдельный вход в Dashboard. Существующий разговор Gateway не переносится в Direct API.</string>
<string name="cw_api_url_label">URL или хост сервера API</string>
<string name="cw_api_key_label">Ключ API</string>
<string name="cw_api_key_placeholder">Значение из API_SERVER_KEY</string>
<string name="cw_api_key_hint">Необходимо для Android Chat, пока не включен транспорт шлюза панели управления.</string>
<string name="cw_api_key_hint">Bearer-ключ только для этого API-сервера. Он не выполняет вход в Dashboard.</string>
<string name="cw_tailscale_label">Удалённый доступ — URL Tailscale (необязательно)</string>
<string name="cw_dashboard">Панель управления</string>
<string name="cw_dashboard_routes_hint">Маршруты: по умолчанию сначала, Tailscale при достижимости.</string>
@@ -388,7 +388,7 @@
<string name="cw_update_button">Обновить</string>
<string name="cw_cancel">Отмена</string>
<string name="cw_method_hermes_title">Подключение только через API</string>
<string name="cw_method_hermes_subtitle">Совместимость и резервное копирование, когда Панель управления/Шлюз недоступны</string>
<string name="cw_method_hermes_subtitle">Явно выбранный чат API-сервера для работы без интерфейса или совместимости</string>
<string name="cw_method_scan_title">Сканирование QR-кода настройки</string>
<string name="cw_method_scan_subtitle">Сканируйте QR-код с URL/ключом API для Гермеса; детали QR-кода Relay требуют плагина Relay</string>
<string name="cw_method_pair_code_title">Сопряжение Relay по коду</string>
@@ -396,7 +396,7 @@
<string name="cw_method_show_code_title">Показать код Relay</string>
<string name="cw_method_show_code_subtitle">Нет камеры или QR-кода? Зарегистрируйте код этого телефона на хосте</string>
<string name="cw_api_url_placeholder">192.168.1.10 или http://your-server:8642</string>
<string name="cw_api_url_supporting">API Гермеса, используемый Чатом и сеансами — порт API 8642 и http:// предполагаются для голых хостов (порт 9119 панели управления выводится отдельно)</string>
<string name="cw_api_url_supporting">Адрес API-сервера для чата Direct API. Для хоста без схемы используются HTTP и порт 8642.</string>
<string name="cw_scan_message">Сканирование этой локальной сети на предмет панели управления/API Гермеса…</string>
<string name="cw_dashboard_signin_hint">Войдите через панель управления, чтобы разблокировать Управление и голос — ключ API предназначен только для явно настроенного Direct API.</string>
<string name="cw_pair_relay_section">Сопряжение Relay (необязательно)</string>
@@ -491,7 +491,7 @@
<string name="cw_timeout_entercode_showcode">Хост ещё не принял этот код подключения. Запустите команду подключения на вашем хосте Гермеса (или проверьте код), затем нажмите Повторить.</string>
<string name="cw_timeout_scan">Истекло время ожидания подтверждения подключения Relay. Проверьте, что Relay работает и доступно в этой сети, затем повторите. Если подключение кажется успешным, но Гермес всё ещё недоступен, сервер API может быть за брандмауэром.</string>
<string name="cw_timeout_other">Истекло время ожидания ответа от Relay. Проверьте, что Relay работает и URL-адрес правильный.</string>
<string name="cw_camera_denied">Доступ к камере запрещён. Подключитесь вручную — выберите &quot;Подключить Relay по коду&quot; или введите URL-адрес сервера.</string>
<string name="cw_camera_denied">Доступ к камере запрещён. Выберите удалённый Gateway и введите адрес Dashboard. Коды сопряжения Relay доступны в дополнительных настройках.</string>
<string name="endpoints_no_routes_stored">Нет сохранённых маршрутов для этого подключения.</string>
<string name="endpoints_add_route">Добавить маршрут</string>
<string name="endpoints_resolving">Разрешение…</string>
@@ -3215,7 +3215,7 @@
<string name="endpoints_pin_title">Закрепленные конечные точки</string>
<string name="endpoints_route_editor_desc">Добавьте адрес Dashboard/Gateway, который это устройство должно использовать в этой сети.</string>
<string name="endpoints_route_name_placeholder">Название маршрута</string>
<string name="endpoints_url_host_placeholder">100.x.y.z или host.ts.net</string>
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 или https://host.ts.net:10443</string>
<string name="endpoints_url_supporting_blank">Введите адрес Dashboard сервера</string>
<string name="endpoints_url_supporting_enter">Используйте адрес Dashboard с http:// или https://</string>
<string name="endpoints_url_supporting_preview">Будет протестировано: %1$s</string>
@@ -4287,11 +4287,11 @@
<string name="active_section_unavailable_mechanism">Unavailable · %1$s</string>
<string name="api_fallback_title">Direct API</string>
<string name="current_surface_paths_title">Current paths</string>
<string name="dashboard_address_editor_body">Укажите адрес Dashboard и Gateway для этого телефона. Частные маршруты LAN и Tailscale могут использовать HTTP или HTTPS; публичным маршрутам требуется HTTPS.</string>
<string name="dashboard_address_editor_body">Укажите адрес Dashboard и Gateway для этого подключения. При необходимости добавьте :порт, например 192.168.1.10:9119 или https://hermes.example.com:10443. Рекомендуется HTTPS. Для других HTTP-адресов необходимо явно принять риск.</string>
<string name="dashboard_address_editor_title">Адрес шлюза</string>
<string name="dashboard_address_label">Dashboard &amp; Gateway address</string>
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
<string name="dashboard_address_oidc_hint">Изменение адреса сохраняет шлюз и ссылки на историю, но другой источник требует повторного входа. Обратные вызовы OIDC настраиваются в Hermes.</string>
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
<string name="dashboard_address_preview">Will use: %1$s</string>
<string name="dashboard_address_required">Введите адрес Hermes Gateway</string>
<string name="dashboard_gateway_configured">Configured address</string>
@@ -4349,4 +4349,9 @@
<string name="voice_overlay_settings_hint">Необязательно в обеих сборках. Откройте голосовой фокус в чате и выберите оверлей. Одни разрешения никогда не включают прослушивание.</string>
<string name="voice_overlay_reset_position">Сбросить положение</string>
<string name="injected_context_gateway_unsupported">Не отправляется в чате Gateway. Это подключение не поддерживает дополнительный контекст для каждого хода.</string>
<string name="dashboard_http_risk">HTTP не шифрует запросы к %1$s. Пароли, токены сеансов и разговоры могут стать доступны посторонним. При использовании VPN вы отвечаете за то, чтобы трафик оставался в нём, в том числе при его отключении. Приложение не определяет и не обеспечивает защиту VPN.</string>
<string name="dashboard_http_allow">Я принимаю риск и разрешаю HTTP для этого адреса</string>
<string name="cw_auth_verified">Вход подтверждён</string>
<string name="cw_chat_checked_on_open">Проверяется при открытии чата</string>
<string name="dashboard_local_auth_help">Hermes доступен, но защищённые запросы не авторизованы. Если Dashboard перенаправляется с loopback, проверьте адрес прослушивания, провайдер аутентификации и dashboard.public_url на хосте. Сам по себе код 401 не определяет причину.</string>
</resources>
+25 -20
View File
@@ -349,7 +349,7 @@
<string name="cw_cloud_subtitle">Connect to your hosted agent</string>
<string name="cw_server_vps_title">Remote gateway</string>
<string name="cw_server_vps_subtitle">Enter its Dashboard address</string>
<string name="cw_relay_optional_note">Private LAN and Tailscale addresses may use HTTP or HTTPS. Public addresses require HTTPS. Relay and Direct API are optional.</string>
<string name="cw_relay_optional_note">HTTPS is recommended. Other HTTP addresses need explicit risk acknowledgement. The app does not enforce VPN protection. Relay and Direct API are optional.</string>
<string name="cw_cloud_entry_title">Connect to Nous-hosted Hermes</string>
<string name="cw_cloud_entry_description">Enter the agent address shown in Nous Portal. You’ll sign in securely after Hermes is found.</string>
<string name="cw_cloud_agent_name">Agent address</string>
@@ -357,7 +357,7 @@
<string name="cw_cloud_slug_hint">We’ll connect to your-agent.agents.nousresearch.com.</string>
<string name="cw_cloud_slug_error">Enter the exact letters, numbers, and hyphens before .agents.nousresearch.com.</string>
<string name="cw_cloud_address_placeholder">https://your-agent.example.com</string>
<string name="cw_cloud_address_hint">Use the complete HTTPS address shown for your hosted agent.</string>
<string name="cw_cloud_address_hint">Use the complete HTTPS address shown for your hosted agent, including :port if provided.</string>
<string name="cw_cloud_use_custom_address">Use custom URL</string>
<string name="cw_cloud_use_nous_address">Use Nous-hosted address</string>
<string name="cw_before_connecting">Before you connect</string>
@@ -384,10 +384,10 @@
<string name="cw_relay_pair_qr_desc">Scan a Relay setup QR</string>
<string name="cw_relay_enter_code">Enter a Relay pairing code</string>
<string name="cw_manual_hermes_title">Enter gateway address</string>
<string name="cw_manual_hermes_description">Enter the Dashboard address you open in a browser. If it does not open on this phone, start hermes dashboard and check Wi-Fi or Tailscale.</string>
<string name="cw_manual_hermes_description">Enter the Dashboard address you can open on this phone. For LAN or VPN access, the host needs a reachable Dashboard and authentication. See the Setup Guide if the browser cannot connect.</string>
<string name="cw_hermes_address">Hermes address</string>
<string name="cw_hermes_address_placeholder">192.168.1.10 or hermes.example.com</string>
<string name="cw_hermes_address_hint">No API key is needed. Dashboard port 9119 is used when no port is included.</string>
<string name="cw_hermes_address_placeholder">192.168.1.10:9119</string>
<string name="cw_hermes_address_hint">Examples: 192.168.1.10:9119 or https://hermes.example.com:10443. If omitted, private hosts use port 9119 and HTTPS uses port 443. No API key is needed.</string>
<string name="cw_find_hermes">Find Hermes</string>
<string name="cw_hermes_found">Hermes found</string>
<string name="cw_ready_to_connect">Ready to connect</string>
@@ -397,16 +397,16 @@
<string name="cw_could_not_verify">Couldn’t verify</string>
<string name="cw_choose_another">Choose another</string>
<string name="cw_sign_in_to_hermes">Sign in to Hermes</string>
<string name="cw_dashboard_connected_title">Hermes is connected</string>
<string name="cw_dashboard_connected_body">Dashboard and Gateway are ready. You can start chatting or open Manage.</string>
<string name="cw_dashboard_connected_title">Dashboard access verified</string>
<string name="cw_dashboard_connected_body">Continue to Chat. The Gateway connection and voice availability are checked separately.</string>
<string name="cw_continue">Continue</string>
<string name="cw_timeline_discovered">Hermes discovered</string>
<string name="cw_timeline_discovered_detail">Dashboard identity and status endpoint verified</string>
<string name="cw_timeline_discovered_detail">Dashboard status retrieved</string>
<string name="cw_timeline_access">Dashboard access</string>
<string name="cw_timeline_access_ready">No additional sign-in required</string>
<string name="cw_timeline_authenticated">Authentication verified</string>
<string name="cw_timeline_ready">Connection ready</string>
<string name="cw_timeline_ready_detail">Chat, Manage, and Voice can use this Dashboard</string>
<string name="cw_timeline_ready">Ready to continue</string>
<string name="cw_timeline_ready_detail">Chat connects when opened; voice availability is checked separately</string>
<string name="cw_semantics_hermes_available">Hermes available</string>
<string name="cw_semantics_capability">%1$s: %2$s</string>
<string name="cw_connect_description">Use these options for API-only compatibility, custom setup, or alternate Relay pairing.</string>
@@ -420,12 +420,12 @@
<string name="cw_skip_for_now">Skip for now — set up later in Settings</string>
<string name="cw_back">Back</string>
<string name="cw_connect_button">Connect</string>
<string name="cw_hermes_label">Hermes</string>
<string name="cw_hermes_label_desc">Use this for Chat and Manage. Pair Relay later only when you enable Terminal, Bridge, Relay sessions, or grants. Dashboard sign-in is the standard upstream auth path; an API key is only for explicit Direct API connections.</string>
<string name="cw_hermes_label">Direct API</string>
<string name="cw_hermes_label_desc">Direct API is an explicit compatibility connection for API-server chat. Manage, Dashboard sessions, and standard voice require a separate Dashboard sign-in. It does not take over an existing Gateway conversation.</string>
<string name="cw_api_url_label">API server URL or host</string>
<string name="cw_api_key_label">API key</string>
<string name="cw_api_key_placeholder">Value from API_SERVER_KEY</string>
<string name="cw_api_key_hint">Needed for Android Chat until the dashboard gateway transport is enabled.</string>
<string name="cw_api_key_hint">Bearer key for this API server only. It does not sign in to Dashboard.</string>
<string name="cw_tailscale_label">Remote access — Tailscale URL (optional)</string>
<string name="cw_dashboard">Dashboard</string>
<string name="cw_dashboard_routes_hint">Routes: default first, Tailscale fallback when reachable.</string>
@@ -441,7 +441,7 @@
<!-- Method tiles -->
<string name="cw_method_hermes_title">API-only connection</string>
<string name="cw_method_hermes_subtitle">Compatibility and fallback when Dashboard/Gateway is unavailable</string>
<string name="cw_method_hermes_subtitle">Explicit API-server chat for headless or compatibility setups</string>
<string name="cw_method_scan_title">Scan setup QR</string>
<string name="cw_method_scan_subtitle">Scan a QR with API URL/key for Hermes; Relay QR details require the Relay plugin</string>
<string name="cw_method_pair_code_title">Pair Relay by code</string>
@@ -451,7 +451,7 @@
<!-- Standard connect form -->
<string name="cw_api_url_placeholder">192.168.1.10 or http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API used by Chat and sessions — API port 8642 and http:// assumed for bare hosts (the dashboard\'s 9119 is derived separately)</string>
<string name="cw_api_url_supporting">API-server address for Direct API chat. Bare hosts use HTTP and port 8642.</string>
<string name="cw_scan_message">Scanning this LAN for Hermes dashboard/API…</string>
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is only for optional Direct API compatibility.</string>
<string name="cw_pair_relay_section">Pair Relay (optional)</string>
@@ -561,7 +561,7 @@
<string name="cw_timeout_entercode_showcode">The host hasn\'t accepted this pairing code yet. Run the pairing command on your Hermes host (or re-check the code), then tap Retry.</string>
<string name="cw_timeout_scan">Timed out before the relay confirmed pairing. Check the relay is running and reachable on this network, then Retry. If pairing seems to succeed but Hermes still can\'t be reached, the API server may be behind a login gateway.</string>
<string name="cw_timeout_other">Timed out waiting for the relay. Check that the relay is running and the URL is correct.</string>
<string name="cw_camera_denied">Camera permission denied. Pair manually instead — choose \"Pair Relay by code\" or enter your server URL.</string>
<string name="cw_camera_denied">Camera permission denied. Choose Remote gateway to enter the Dashboard address. Relay pairing codes are available under Advanced.</string>
<!-- EndpointsCard -->
<string name="endpoints_no_routes_stored">No routes stored for this connection.</string>
@@ -617,12 +617,12 @@
<string name="endpoints_close">Close</string>
<string name="endpoints_actions">Endpoint actions</string>
<string name="dashboard_address_editor_title">Gateway address</string>
<string name="dashboard_address_editor_body">Set the Dashboard and Gateway address this phone should use. Private LAN and Tailscale routes may use HTTP or HTTPS; public routes require HTTPS.</string>
<string name="dashboard_address_editor_body">Set the Dashboard and Gateway address for this connection. Include :port when needed, for example 192.168.1.10:9119 or https://hermes.example.com:10443. HTTPS is recommended. Other HTTP addresses need your explicit risk acknowledgement.</string>
<string name="dashboard_address_label">Dashboard &amp; Gateway address</string>
<string name="dashboard_address_placeholder">https://hermes.example.com</string>
<string name="dashboard_address_placeholder">192.168.1.10:9119</string>
<string name="dashboard_address_required">Enter your Hermes Gateway address</string>
<string name="dashboard_address_preview">Will use: %1$s</string>
<string name="dashboard_address_oidc_hint">Use any Dashboard address this phone can reach. OIDC callback settings are configured on Hermes; no second sign-in address is required here.</string>
<string name="dashboard_address_oidc_hint">Changing the address preserves this gateway and its history references, but a different origin requires sign-in again. OIDC callbacks are configured on Hermes.</string>
<!-- BridgeMasterToggle -->
<string name="bmt_agent_control">Agent Control</string>
@@ -3745,7 +3745,7 @@
<string name="endpoints_pin_title">Pinned endpoints</string>
<string name="endpoints_route_editor_desc">Add the Dashboard/Gateway address this phone should use on that network.</string>
<string name="endpoints_route_name_placeholder">Route name</string>
<string name="endpoints_url_host_placeholder">100.x.y.z or host.ts.net</string>
<string name="endpoints_url_host_placeholder">100.x.y.z:9119 or https://host.ts.net:10443</string>
<string name="endpoints_url_supporting_blank">Enter the server’s Dashboard address</string>
<string name="endpoints_url_supporting_enter">Use an http:// or https:// Dashboard address</string>
<string name="endpoints_url_supporting_preview">Will test: %1$s</string>
@@ -4668,4 +4668,9 @@
<string name="voice_overlay_settings_hint">Optional in both builds. Open Voice Focus in Chat and choose Overlay to start. Permissions alone never start listening.</string>
<string name="voice_overlay_reset_position">Reset position</string>
<string name="injected_context_gateway_unsupported">Not sent in Gateway chat. This connection does not support extra per-turn context.</string>
<string name="dashboard_http_risk">HTTP does not encrypt requests to %1$s. Passwords, session tokens, and conversations may be exposed. If you rely on a VPN, you are responsible for keeping traffic inside it, including after VPN loss. The app does not detect or enforce VPN protection.</string>
<string name="dashboard_http_allow">I accept the risk and allow HTTP for this address</string>
<string name="cw_auth_verified">Sign-in verified</string>
<string name="cw_chat_checked_on_open">Verified when Chat opens</string>
<string name="dashboard_local_auth_help">Hermes is reachable, but protected requests are not authorized. If this Dashboard is forwarded from loopback, check its bind address, authentication provider, and dashboard.public_url on the host. A 401 alone does not identify the cause.</string>
</resources>
@@ -6,6 +6,15 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionCapabilitiesTest {
@Test
fun emptySetupPlaceholderRetainsGatewayIntentBeforeAddressIsSaved() {
val placeholder = connection(dashboardUrl = null, apiServerUrl = "", relayUrl = "")
assertEquals(SessionTransport.GATEWAY, placeholder.automaticChatTransport)
assertEquals(SessionTransport.GATEWAY, placeholder.chatTransportForPreference("auto"))
assertEquals(SessionTransport.SSE, placeholder.chatTransportForPreference("completions"))
assertEquals(SessionTransport.SSE, placeholder.copy(apiServerUrl = "http://127.0.0.1:8642").automaticChatTransport)
assertEquals(SessionTransport.GATEWAY, placeholder.copy(dashboardUrl = "https://gateway.example.test").automaticChatTransport)
}
@Test
fun dashboardOnlyConnection_exposesStandardHermesCapabilities() {
@@ -329,6 +329,22 @@ class ConnectionDashboardFieldsTest {
assertEquals("http://100.75.1.2:9119", Connection.normalizeDashboardUrlInput("100.75.1.2"))
}
@Test
fun normalizeDashboardUrlInput_preservesExplicitPorts() {
assertEquals(
"http://homelab.lan:9443",
Connection.normalizeDashboardUrlInput("homelab.lan:9443"),
)
assertEquals(
"https://hermes.example.com:10443",
Connection.normalizeDashboardUrlInput("hermes.example.com:10443"),
)
assertEquals(
"https://hermes.example.com:10443",
Connection.normalizeDashboardUrlInput("https://hermes.example.com:10443"),
)
}
@Test
fun discoveredLabel_prefersHostnameForAnUncustomizedIpLabel() {
assertEquals(
@@ -0,0 +1,82 @@
package com.hermesandroid.relay.data
import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligible
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
import com.hermesandroid.relay.network.upstream.dashboardClientWithHttpConsent
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.MockResponse
import com.hermesandroid.relay.viewmodel.publicDashboardAddressRequiresHttps
import io.mockk.mockk
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import org.junit.Assert.*
import org.junit.Test
class DashboardHttpConsentTest {
private val address = "http://11.0.0.1:9119"
private val consent = setOf(address)
@Test fun consentDoesNotFollowRedirectsToAnotherOrigin() {
MockWebServer().use { first ->
MockWebServer().use { second ->
first.enqueue(MockResponse().setResponseCode(307).setHeader("Location", second.url("/capture")))
val base = first.url("/").toString().trimEnd('/')
val client = dashboardClientWithHttpConsent(OkHttpClient(), base, setOf(base))
try {
client.newCall(Request.Builder().url(first.url("/api/auth/me"))
.header("Authorization", "Bearer synthetic-fixture").build()).execute().use {
assertEquals(307, it.code)
}
assertEquals(0, second.requestCount)
} finally {
client.connectionPool.evictAll()
client.dispatcher.executorService.shutdown()
}
}
}
}
@Test fun publicHttpRequiresExplicitExactOriginConsent() {
assertTrue(publicDashboardAddressRequiresHttps(null, address))
assertFalse(publicDashboardAddressRequiresHttps(null, address, consent))
assertTrue(publicDashboardAddressRequiresHttps("lan", "http://11.0.0.2:9119", consent))
assertTrue(publicDashboardAddressRequiresHttps(null, "http://11.0.0.1:9120", consent))
assertNull(dashboardHttpOrigin("http://user:password@11.0.0.1:9119"))
assertNull(dashboardHttpOrigin("http://11.0.0.1:9119?token=untrusted"))
assertFalse(dashboardHttpConsentRequired("https://11.0.0.1:9119"))
assertFalse(dashboardHttpConsentRequired("http://192.168.1.5:9119"))
}
@Test fun persistedConsentBelongsToOneConnectionAndDoesNotChangeSecurityClassification() {
val connection = Connection("one", "Gateway", "", "", "one-key",
dashboardUrl = address, dashboardHttpConsentOrigins = consent)
val restored = Json.decodeFromString<Connection>(Json.encodeToString(connection)).withDashboardDefaults()
assertEquals(consent, restored.dashboardHttpConsentOrigins)
assertTrue(dashboardHttpConsentMatches(address, restored.dashboardHttpConsentOrigins))
assertFalse(dashboardHttpConsentMatches(address, Connection("two", "Other", "", "", "two-key").dashboardHttpConsentOrigins))
assertEquals("public", Connection.inferRouteRole(address))
assertFalse(isTlsUrl(address))
}
@Test fun changingAnOriginRequiresFreshConsentAndRetiresTheOldException() {
val next = "http://11.0.0.2:9119"
assertEquals(emptySet<String>(), updatedDashboardHttpConsents(consent, address, next, null))
assertEquals(setOf(next), updatedDashboardHttpConsents(consent, address, next, next))
assertEquals(emptySet<String>(), updatedDashboardHttpConsents(consent, address, "https://11.0.0.1:9119", null))
assertEquals(consent, updatedDashboardHttpConsents(consent, address, "$address/prefix", null))
}
@Test fun nativeAuthenticationUsesTheSameConsentWithoutTrustingOtherBases() {
assertFalse(isNativeDashboardTransportEligible(address))
assertTrue(isNativeDashboardTransportEligible(address, consent))
assertFalse(isNativeDashboardTransportEligible("http://11.0.0.2:9119", consent))
assertNull(normalizeCredentialFreeAuthenticatedDashboardOrigin(address))
assertEquals(address, normalizeCredentialFreeAuthenticatedDashboardOrigin(address, consent))
assertNotNull(trustedDashboardBearerAuthOrNull(address, address, consent) { mockk(relaxed = true) })
assertNull(trustedDashboardBearerAuthOrNull("http://11.0.0.2:9119", address, consent) { error("Must not load credentials") })
assertNull(trustedDashboardBearerAuthOrNull("$address/other", "$address/hermes", consent) { error("Must not load credentials") })
}
}
@@ -0,0 +1,61 @@
package com.hermesandroid.relay.network.upstream
import java.io.IOException
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Test
class DashboardSetupVerificationTest {
private val server = MockWebServer()
private val client = DashboardApiClient(server.url("/").toString().trimEnd('/'))
@After fun close() { client.shutdown(); server.shutdown() }
private fun response(body: String, code: Int = 200) = server.enqueue(MockResponse().setResponseCode(code).setBody(body))
@Test fun publicStatusDoesNotAuthorizeLoopbackProtectedRoutes() = runTest {
response("""{"auth_required":false}""")
response("""{"detail":"Unauthorized"}""", 401)
assertTrue(runCatching { client.verifySetup() }.exceptionOrNull() is DashboardLocalAuthenticationRequiredException)
assertEquals("/api/status", server.takeRequest().path)
assertEquals("/api/auth/me", server.takeRequest().path)
assertEquals(2, server.requestCount)
}
@Test fun ordinaryExpiredSessionRequestsSignInWithoutClaimingMisconfiguration() = runTest {
response("""{"auth_required":true}""")
response("""{"error":"session_expired"}""", 401)
val result = client.verifySetup()
assertFalse(result.authenticated)
assertFalse(result.ticketAvailable)
assertTrue(result.status.authRequired)
assertEquals(2, server.requestCount)
}
@Test fun successfulRetryProvesSessionAndTicketOnTheSameDashboard() = runTest {
response("""{"auth_required":true}""")
response("""{"detail":"Unauthorized"}""", 401)
assertFalse(client.verifySetup().authenticated)
response("""{"auth_required":true}""")
response("""{"authenticated":true,"username":"fixture"}""")
response("""{"ticket":"fixture-ticket","ttl_seconds":30}""")
assertTrue(client.verifySetup().authenticated)
val paths = (1..5).map { server.takeRequest().path }
assertEquals(listOf("/api/status", "/api/auth/me", "/api/status", "/api/auth/me", "/api/auth/ws-ticket"), paths)
}
@Test fun ticketTransportFailureCannotProduceReady() = runTest {
response("""{"auth_required":true}""")
response("""{"authenticated":true}""")
response("""{"detail":"temporarily unavailable"}""", 503)
assertTrue(runCatching { client.verifySetup() }.exceptionOrNull() is IOException)
}
@Test fun expiryBetweenSessionVerificationAndTicketReturnsToSignIn() = runTest {
response("""{"auth_required":true}""")
response("""{"authenticated":true}""")
response("""{"error":"session_expired"}""", 401)
assertFalse(client.verifySetup().authenticated)
}
}
@@ -0,0 +1,331 @@
package com.hermesandroid.relay.network.upstream
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.app.NotificationManager
import android.os.Build
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.preferencesOf
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.data.relayDataStore
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Assert.*
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 = [31, 35])
@OptIn(ExperimentalCoroutinesApi::class)
class GatewayKeepAliveServiceTest {
private val context = mockk<Context>(relaxed = true)
private val start = slot<Intent>()
private var service: GatewayKeepAliveService? = null
private val preferences = TestPreferences()
init {
every { context.applicationContext } returns context
every { context.startForegroundService(capture(start)) } returns
ComponentName("test", GatewayKeepAliveService::class.java.name)
}
@Before fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
GatewayKeepAliveService.resetForTest()
mockkStatic("com.hermesandroid.relay.data.DataStoreProviderKt")
every { any<Context>().relayDataStore } returns preferences
}
@After fun cleanup() {
preferences.gate?.complete(Unit)
service?.onDestroy()
ActiveTurnKeepAliveRegistry.resetForTest()
GatewayKeepAliveService.resetForTest()
unmockkAll()
Dispatchers.resetMain()
}
private fun create(): GatewayKeepAliveService =
Robolectric.buildService(GatewayKeepAliveService::class.java).create().get()
.also { service = it }
@Test fun immediateStopWaitsForForegroundPromotion() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
GatewayKeepAliveService.stop(context)
verify(exactly = 0) { context.stopService(any()) }
val instance = create()
assertNotNull(shadowOf(instance).lastForegroundNotification)
instance.onStartCommand(start.captured, 0, 1)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun creationPromotesBeforePublishingTheInstance() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
assertNotNull(shadowOf(create()).lastForegroundNotification)
}
@Test fun queuedStartCannotOverwriteNewerDemand() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val oldStart = start.captured
val instance = create()
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
instance.onStartCommand(oldStart, 0, 1)
val notification = shadowOf(instance).lastForegroundNotification!!
assertEquals("Hermes is waiting for input", notification.extras.getString("android.title"))
assertTrue(notification.actions.isNullOrEmpty())
}
@Test fun overlappingStartsAreCoalescedAndLatestDemandWins() {
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
GatewayKeepAliveService.stop(context)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2))
verify(exactly = 1) { context.startForegroundService(any()) }
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
assertFalse(shadowOf(instance).isStoppedBySelf)
assertEquals("Hermes is finishing 2 turns", notificationTitle(instance))
}
@Test fun siblingSettlementKeepsTheRemainingSessionProtected() {
ActiveTurnKeepAliveRegistry.acquire("connection::profile-a::session")
ActiveTurnKeepAliveRegistry.acquire("connection::profile-b::session")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
ActiveTurnKeepAliveRegistry.release("connection::profile-a::session")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
assertEquals("Hermes is finishing a turn", notificationTitle(instance))
assertFalse(shadowOf(instance).isStoppedBySelf)
ActiveTurnKeepAliveRegistry.release("connection::profile-b::session")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun retiringInstanceIsNotReusedAndItsDestructionCannotClearReplacement() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val old = create()
old.onStartCommand(start.captured, 0, 1)
GatewayKeepAliveService.stop(context)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
verify(exactly = 2) { context.startForegroundService(any()) }
val replacement = create()
replacement.onStartCommand(start.captured, 0, 2)
old.onDestroy()
old.onConfigurationChanged(Configuration())
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2))
assertEquals("Hermes is finishing 2 turns", notificationTitle(replacement))
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun androidCanDeliverANewStartToTheRetiringInstance() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
GatewayKeepAliveService.stop(context)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
instance.onStartCommand(start.captured, 0, 2)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2))
assertEquals("Hermes is finishing 2 turns", notificationTitle(instance))
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun backgroundDemandWaitsForVisibilityAndExistingProtectionSurvivesBackgrounding() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(), false)
verify(exactly = 0) { context.startForegroundService(any()) }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(), true)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(1), false)
assertEquals("Hermes is finishing a turn", notificationTitle(instance))
assertFalse(shadowOf(instance).isStoppedBySelf)
}
@Test fun rejectedLaunchCanRetryOnNextForegroundWithoutReleasingTurnOwnership() {
ActiveTurnKeepAliveRegistry.acquire("connection::profile::session")
every { context.startForegroundService(any()) } throws IllegalStateException("background start")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
every { context.startForegroundService(capture(start)) } returns
ComponentName("test", GatewayKeepAliveService::class.java.name)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value, false)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value, true)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
assertEquals("Hermes is finishing a turn", notificationTitle(instance))
}
@Test fun promotionFailureRetiresTheInstanceAndAllowsAFreshLaunch() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = spyk(create()).also { service = it }
if (Build.VERSION.SDK_INT >= 34) {
every { instance.startForeground(any(), any(), any()) } throws SecurityException("denied")
} else {
every { instance.startForeground(any(), any()) } throws SecurityException("denied")
}
instance.onStartCommand(start.captured, 0, 1)
verify { instance.stopSelf() }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun channelFailureIsContainedBeforePublishingAnOwner() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = spyk(Robolectric.buildService(GatewayKeepAliveService::class.java).get())
.also { service = it }
every { instance.getSystemService(NotificationManager::class.java) } throws IllegalStateException("channel unavailable")
instance.onCreate()
verify { instance.stopSelf() }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun taskRemovalPreservesLeasesAndDoesNotRestartUntilTheNextVisibleLifecycle() {
ActiveTurnKeepAliveRegistry.acquire("connection::profile::session")
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
instance.onTaskRemoved(null)
assertTrue(shadowOf(instance).isStoppedBySelf)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value, false)
verify(exactly = 1) { context.startForegroundService(any()) }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value, true)
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun taskRemovalDuringStartupStillAcknowledgesPromotion() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onTaskRemoved(null)
assertFalse(shadowOf(instance).isStoppedBySelf)
instance.onStartCommand(start.captured, 0, 1)
assertNotNull(shadowOf(instance).lastForegroundNotification)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun processLossDoesNotReplayOldDemandButStillPromotesADeliveredStart() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val oldStart = start.captured
GatewayKeepAliveService.resetForTest()
val instance = create()
assertNull(shadowOf(instance).lastForegroundNotification)
instance.onStartCommand(oldStart, 0, 1)
assertNotNull(shadowOf(instance).lastForegroundNotification)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun staleStartCannotAcknowledgeANewerPendingStart() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val oldStart = start.captured
GatewayKeepAliveService.resetForTest()
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
val newStart = start.captured
val instance = create()
instance.onStartCommand(oldStart, 0, 1)
GatewayKeepAliveService.stop(context)
assertFalse(shadowOf(instance).isStoppedBySelf)
instance.onStartCommand(newStart, 0, 2)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun notificationOnlyDisablesIdleRetentionAndUsesLatestTurnDemand() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
val action = stopAction(instance)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
instance.onStartCommand(action, 0, 2)
assertEquals(false, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
assertFalse(shadowOf(instance).isStoppedBySelf)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
assertEquals("Hermes is waiting for input", notificationTitle(instance))
assertTrue(shadowOf(instance).lastForegroundNotification!!.actions.isNullOrEmpty())
}
@Test fun notificationWriteSurvivesServiceDestruction() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
preferences.gate = CompletableDeferred()
instance.onStartCommand(stopAction(instance), 0, 2)
instance.onTaskRemoved(null)
instance.onDestroy()
preferences.gate!!.complete(Unit)
assertEquals(false, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
}
@Test fun queuedNotificationEditCannotDisableAReenabledPreference() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(1))
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
val oldAction = stopAction(instance)
preferences.gate = CompletableDeferred()
instance.onStartCommand(oldAction, 0, 2)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(1))
preferences.gate!!.complete(Unit)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
instance.onStartCommand(oldAction, 0, 3)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
}
@Test fun failedPreferenceWriteKeepsTruthfulNotificationAndProtection() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
preferences.fail = true
instance.onStartCommand(stopAction(instance), 0, 2)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
assertFalse(shadowOf(instance).isStoppedBySelf)
assertEquals(1, shadowOf(instance).lastForegroundNotification!!.actions.size)
}
@Test fun coldOldNotificationAndNullRestartDoNotEnableIdleRetention() {
val instance = create()
assertEquals(android.app.Service.START_NOT_STICKY, instance.onStartCommand(null, 0, 1))
instance.onStartCommand(Intent().setAction(GatewayKeepAliveService.ACTION_STOP), 0, 2)
assertNull(shadowOf(instance).lastForegroundNotification)
assertTrue(shadowOf(instance).isStoppedBySelf)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
}
private fun notificationTitle(instance: GatewayKeepAliveService) =
shadowOf(instance).lastForegroundNotification!!.extras.getString("android.title")
private fun stopAction(instance: GatewayKeepAliveService): Intent =
shadowOf(shadowOf(instance).lastForegroundNotification!!.actions.single().actionIntent).savedIntent
private class TestPreferences : DataStore<Preferences> {
override val data = MutableStateFlow(preferencesOf(KEY_GATEWAY_KEEP_ALIVE to true))
var gate: CompletableDeferred<Unit>? = null
var fail = false
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
gate?.await()
if (fail) throw java.io.IOException("write failed")
return transform(data.value).also { data.value = it }
}
}
}
@@ -642,7 +642,7 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { loadedProfile == owner.name }
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
assertEquals(owner.name, gatewayClient.sessionProfileProvider())
assertEquals("X-bot", handler.activeAgentName)
assertEquals("x-bot", handler.activeAgentName)
assertEquals("x-bot-session", persistedSession)
viewModel.switchProfileContext(
@@ -694,7 +694,7 @@ class ChatViewModelGatewayInboundTurnTest {
)
assertEquals(alpha, selected)
assertEquals(alpha.name, viewModel.conversationBinding.value.profileName)
assertEquals("Alpha", handler.activeAgentName)
assertEquals("alpha", handler.activeAgentName)
viewModel.openProfileSession(
profileName = beta.name,
@@ -706,7 +706,7 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals(beta, selected)
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
assertEquals(beta.name, gatewayClient.sessionProfileProvider())
assertEquals("Beta", handler.activeAgentName)
assertEquals("beta", handler.activeAgentName)
assertEquals("beta-session", handler.currentSessionId.value)
}
@@ -1462,7 +1462,7 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("default", viewModel.conversationBinding.value.profileName)
assertEquals("default", gatewayClient.sessionProfileProvider())
assertEquals(null, handler.currentSessionId.value)
assertEquals("Hermes", handler.activeAgentName)
assertEquals("default", handler.activeAgentName)
assertEquals("cleared", persistedSession)
}
@@ -2000,7 +2000,7 @@ class ChatViewModelGatewayInboundTurnTest {
})
}
viewModel.setDashboardConfigLoader { Result.success(config) }
shadowOf(Looper.getMainLooper()).idle()
awaitCondition { viewModel.personalityNames.value == listOf("private-a") }
assertEquals(listOf("private-a"), viewModel.personalityNames.value)
viewModel.resetConnectionCatalogs()
@@ -2013,6 +2013,12 @@ class ChatViewModelGatewayInboundTurnTest {
@Test
fun unsolicitedGatewayCompletionAppearsAsOneAssistantTurnAndSettles() {
val spoken = mutableListOf<String>()
var admissions = 0
viewModel.gatewayInboundSpeechReceiver = {
admissions++
{ text -> spoken.add(text); Unit }
}
// Upstream's process-completion poller currently emits this adjacent
// duplicate pair; it must still create exactly one placeholder.
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
@@ -2048,6 +2054,13 @@ class ChatViewModelGatewayInboundTurnTest {
}
assertFalse(handler.messages.value.single().isStreaming)
assertFalse(gatewayHarness.rpcLog.any { it.first == "prompt.submit" })
assertEquals(1, admissions)
assertEquals(listOf(BACKGROUND_ANSWER), spoken)
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("text", BACKGROUND_ANSWER)
}, "live-resumed"))
shadowOf(Looper.getMainLooper()).idle()
assertEquals(listOf(BACKGROUND_ANSWER), spoken)
}
@Test
@@ -2077,6 +2090,42 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(activeOwner.content.isBlank())
}
@Test
fun inboundSpeechIgnoresForeignUnscopedAndFailedTurns() {
val spoken = mutableListOf<String>()
viewModel.gatewayInboundSpeechReceiver = { { text -> spoken.add(text); Unit } }
for (session in listOf("foreign-session", null)) {
serverWs.send(gatewayHarness.eventFrame("message.start", null, session))
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("text", "Foreign answer")
}, session))
}
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("status", "error")
put("text", "Failed answer")
put("error", "Synthetic failure")
}, "live-resumed"))
awaitCondition { handler.messages.value.any { "Error" in it.badges } }
assertTrue(spoken.isEmpty())
}
@Test
fun inboundSpeechUsesFinalAnswerAfterToolInterim() {
val spoken = mutableListOf<String>()
viewModel.gatewayInboundSpeechReceiver = { { text -> spoken.add(text); Unit } }
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.interim", buildJsonObject {
put("text", "Checking the completed work.")
put("already_streamed", false)
}, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("text", "The completed work passed.")
}, "live-resumed"))
awaitCondition { spoken.isNotEmpty() }
assertEquals(listOf("The completed work passed."), spoken)
}
@Test
fun queuedMainDispatchAdmitsBackgroundStartAfterLocalCompletion() {
viewModel.sendMessage("Local gateway turn")
@@ -2499,6 +2548,12 @@ class ChatViewModelGatewayInboundTurnTest {
gatewayHarness.awaitRpc("approval.respond")
awaitCondition { !handler.isStreaming.value }
awaitCondition { checkpointStore.checkpoint == null }
// The RPC log records request receipt, before its acknowledgement is
// dispatched back to Main. Checkpoint retirement is independent too.
awaitCondition {
handler.messages.value.singleOrNull { it.id == "ask-approval-1" }
?.cardDispatches?.isNotEmpty() == true
}
assertEquals(
"once",
handler.messages.value.single { it.id == "ask-approval-1" }
@@ -0,0 +1,269 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.audio.VoicePlayer
import com.hermesandroid.relay.audio.VoiceRecorder
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import com.hermesandroid.relay.network.upstream.ChatHandler
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class VoiceInboundCompletionTest {
private val dispatcher = StandardTestDispatcher()
private lateinit var chat: ChatViewModel
private lateinit var handler: ChatHandler
private lateinit var voice: VoiceViewModel
private lateinit var recorder: VoiceRecorder
private lateinit var player: VoicePlayer
private val synthesis = mutableListOf<String>()
@Before
fun setup() {
Dispatchers.setMain(dispatcher)
handler = ChatHandler().also { it.setSessionId("session-a") }
chat = ChatViewModel().also {
it.initialize(null, handler)
it.streamingEndpoint = "gateway"
}
recorder = mockk(relaxed = true) {
every { amplitude } returns MutableStateFlow(0f)
every { isRecording() } returns false
}
player = mockk(relaxed = true) {
every { amplitude } returns MutableStateFlow(0f)
}
val app = ApplicationProvider.getApplicationContext<Application>()
val audio = object : VoiceAudioClient {
override val route = VoiceAudioRoute.Standard
override suspend fun transcribe(audioFile: File) = Result.success("")
override suspend fun synthesize(text: String): Result<File> {
synthesis.add(text)
return Result.success(File.createTempFile("inbound-voice", ".wav", app.cacheDir))
}
}
voice = VoiceViewModel(app).also {
it.initialize(
voiceClient = mockk(relaxed = true), voiceAudioClient = audio,
chatViewModel = chat, recorder = recorder, player = player,
sfxPlayer = mockk(relaxed = true),
)
it.enterVoiceMode()
}
}
@After
fun teardown() {
voice.exitVoiceMode()
Dispatchers.resetMain()
}
private fun admission(): (String) -> Unit = requireNotNull(chat.gatewayInboundSpeechReceiver?.invoke())
@Test
fun settledCompletionUsesConfiguredTtsExactlyOnce() = runTest(dispatcher) {
runCurrent()
val receipt = admission()
receipt("The timer finished.")
receipt("The timer finished.")
runCurrent()
assertEquals(listOf("The timer finished."), synthesis)
verify(exactly = 1) { player.play(any()) }
}
@Test
fun fastInboundStartCannotBeConsumedByPreviousVoiceObserver() = runTest(dispatcher) {
runCurrent()
// Exercise the production observer with the Main dispatcher held between
// the old terminal and the next inbound admission, as OkHttp can do.
VoiceViewModel::class.java.getDeclaredMethod("startStreamObserver", ChatViewModel::class.java)
.apply { isAccessible = true }.invoke(voice, chat)
handler.addPlaceholderMessage(ChatMessage(
id = "ordinary", role = MessageRole.ASSISTANT, content = "Work started.",
timestamp = 1L, isStreaming = true,
))
handler.onStreamComplete("ordinary")
val receipt = admission()
handler.addPlaceholderMessage(ChatMessage(
id = "inbound", role = MessageRole.ASSISTANT, content = "Work finished.",
timestamp = 2L, isStreaming = true,
))
handler.onStreamComplete("inbound")
receipt("Work finished.")
runCurrent()
assertEquals(listOf("Work started.", "Work finished."), synthesis)
}
@Test
fun completionWaitsForEarlierSpeechAndPreservesOrder() = runTest(dispatcher) {
runCurrent()
voice.seedSpeakingStateForTest(listOf("Earlier answer"), 0)
admission()("Process finished.")
admission()("Delegated work finished.")
runCurrent()
assertTrue(synthesis.isEmpty())
voice.finishAgentAudioOutputForTest()
runCurrent()
assertEquals(listOf("Process finished.", "Delegated work finished."), synthesis)
}
@Test
fun activeCaptureIsNeverCancelledForCompletionSpeech() = runTest(dispatcher) {
runCurrent()
every { recorder.isRecording() } returns true
admission()("Watch matched.")
runCurrent()
assertTrue(synthesis.isEmpty())
verify(exactly = 0) { recorder.cancel() }
every { recorder.isRecording() } returns false
voice.finishAgentAudioOutputForTest()
runCurrent()
assertEquals(listOf("Watch matched."), synthesis)
}
@Test
fun stopInvalidatesAdmittedAndQueuedCompletions() = runTest(dispatcher) {
runCurrent()
val late = admission()
voice.seedSpeakingStateForTest(emptyList(), 0)
admission()("Queued answer.")
voice.interruptSpeaking()
late("Late answer.")
runCurrent()
assertTrue(synthesis.isEmpty())
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
}
@Test
fun exitAndReentryRejectsOldReceiptButAcceptsNewTurn() = runTest(dispatcher) {
runCurrent()
val old = admission()
voice.exitVoiceMode()
voice.enterVoiceMode()
old("Old answer.")
admission()("New answer.")
runCurrent()
assertEquals(listOf("New answer."), synthesis)
}
@Test
fun sessionSwitchRejectsOldReceiptAndDoesNotAdoptNewSession() = runTest(dispatcher) {
runCurrent()
val old = admission()
handler.setSessionId("session-b")
old("Wrong session.")
runCurrent()
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
handler.setSessionId("session-a")
old("Stale return.")
runCurrent()
assertTrue(synthesis.isEmpty())
}
@Test
fun sameSessionIdInAnotherProfileCannotSpeak() = runTest(dispatcher) {
runCurrent()
val old = admission()
chat.switchProfileContext("connection-b::profile-b", "session-a")
old("Wrong profile.")
runCurrent()
assertTrue(synthesis.isEmpty())
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
}
@Test
fun realtimeEngineDoesNotConsumeGatewaySpeech() = runTest(dispatcher) {
runCurrent()
val old = admission()
voice.setVoiceEngineModeForTest(VoiceEngineMode.RealtimeAgent)
old("Wrong engine.")
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
runCurrent()
assertTrue(synthesis.isEmpty())
}
@Test
fun switchingEnginesBackCannotReviveAnOldReceipt() = runTest(dispatcher) {
runCurrent()
val old = admission()
val applySettings = VoiceViewModel::class.java.getDeclaredMethod(
"applyVoiceSettingsSnapshot", com.hermesandroid.relay.data.VoiceSettings::class.java,
).apply { isAccessible = true }
applySettings.invoke(voice, com.hermesandroid.relay.data.VoiceSettings(
engineMode = VoiceEngineMode.RealtimeAgent.storageValue,
))
applySettings.invoke(voice, com.hermesandroid.relay.data.VoiceSettings())
old("Stale engine receipt.")
runCurrent()
assertTrue(synthesis.isEmpty())
}
@Test
fun newVoiceConversationAdoptsOnlyItsSubmittedSession() = runTest(dispatcher) {
runCurrent()
voice.exitVoiceMode()
handler.setSessionId(null)
voice.enterVoiceMode()
val fence = VoiceTurnSessionFence(null).also { it.bindSubmittedUser("voice-user") }
VoiceViewModel::class.java.getDeclaredField("voiceTurnSessionFence")
.apply { isAccessible = true }.set(voice, fence)
handler.addUserMessage(ChatMessage(
id = "voice-user", role = MessageRole.USER, content = "Start work.", timestamp = 1L,
))
handler.setSessionId("new-voice-session")
runCurrent()
admission()("New conversation completion.")
runCurrent()
assertEquals(listOf("New conversation completion."), synthesis)
}
@Test
fun conversationChangeCancelsPendingSynthesisWithoutCancellingChat() = runTest(dispatcher) {
runCurrent()
voice.stopTtsConsumerForTest()
admission()("Old profile output.")
chat.switchProfileContext("connection-b::profile-b", "session-b")
runCurrent()
assertTrue(voice.drainTtsQueueForTest().isEmpty())
assertTrue(synthesis.isEmpty())
verify(atLeast = 1) { player.stop() }
}
@Test
fun historyAndForegroundReplayNeverCreateSpeech() = runTest(dispatcher) {
runCurrent()
handler.addPlaceholderMessage(ChatMessage(
id = "history-a", role = MessageRole.ASSISTANT, content = "Historical answer.",
timestamp = 1L, isStreaming = false,
))
voice.onAppResumed()
runCurrent()
assertTrue(synthesis.isEmpty())
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.4.0" apply false
id("com.android.library") version "9.4.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.20" apply false
}
@@ -0,0 +1,63 @@
# Gateway foreground-service startup (#603)
## Confirmed defect and limits
Issue #603 reports `ForegroundServiceDidNotStartInTimeException` naming
`GatewayKeepAliveService` on Android 12/API 31. The report contains no preceding
lifecycle log and no comments. It does not establish an OEM-specific cause or
implicate the voice overlay.
Before this fix, demand could call `startForegroundService()` and then
`stopService()` before Android delivered `onCreate` or `onStartCommand`.
Android 12's `ActiveServices.bringDownServiceLocked` explicitly schedules a
foreground-service crash when teardown encounters `fgRequired`. Promotion in
`onStartCommand` alone therefore did not protect immediate cancellation.
Three focused API 31 regressions failed against the previous implementation:
immediate stop called `stopService` with an outstanding start; creation had no
foreground notification; and a queued persistent-only start overwrote newer
active/waiting-turn notification state. These establish client defects, not the
precise callback sequence on the reporting device.
## Lifecycle audit
| Path | Resulting contract |
| --- | --- |
| Controller | Main-thread collection combines the persistent preference, scoped turn leases, and process visibility. Socket-retention demand remains separate from service-launch eligibility. |
| First start | One pending token; channel creation, notification construction, and promotion happen synchronously in `onCreate`, without coroutine, datastore, or network work first. |
| Start then stop / overlapping demands | Coalesce current demand until the start command promotes and acknowledges its obligation. No `stopService` cancellation of pending starts. |
| Running updates | Apply current demand only to the live owner. Clear that owner before requesting teardown; a late old `onDestroy` cannot clear a replacement. |
| Delivered stale start / process loss | Promote a delivered foreground start, but never restore its old demand. A cold stale notification action does not restore idle retention. `START_NOT_STICKY` remains in effect. |
| Settlement / session switch | Existing connection/profile/session leases settle independently. This service sends no interrupt, resume, activate, or transport-routing command. |
| Always-on notification action | Identity includes the current enable cycle. Preference persistence outlives service teardown and rechecks its token inside the edit. Current turn demand determines eventual shutdown. Write failure leaves the preference and notification truthful. |
| Background eligibility | Do not request a new service from a known background lifecycle. Existing protection continues. Launch rejection is logged; a later visible transition retries remaining demand. |
| Task removal | Release local foreground protection without deleting chat-owned leases or assuming the process dies. Suppress restart until a new visible lifecycle transition. |
| Startup failure | Channel/build/promotion exceptions retire the unusable instance and clear pending launch state. They never cancel a server-owned turn. Platform-level asynchronous failures cannot be converted into successful foreground protection. |
| Configuration change | Only the current live owner refreshes the notification. |
| Notification/type policy | Existing low-importance channel, immutable intents, non-exported service, and both-flavor `specialUse` declaration remain. API 34+ uses the declared type/permission; API 31 uses the two-argument promotion. No permissions or dependencies added. |
The Gateway protocol, recovery/history contract, and session owner are unchanged,
so no new Gateway fixture scenario or upstream-conformance requirement is
introduced. The additional instrumentation exercises Android ActivityManager
and notification PendingIntent delivery without a server.
## Verification scope
`GatewayKeepAliveServiceTest` exercises API 31 and 35, including startup,
cancellation, failure, stale generations, notification persistence, task removal,
and scoped sibling settlement. It and `ActiveTurnKeepAliveRegistryTest` are in
both-flavor focused verification. `GatewayKeepAliveServiceInstrumentedTest`
targets the Standard Phone API 36 lane and observes the real platform watchdog
after rapid starts/stops, plus notification actions crossed by new turn demand.
Exact execution results belong to the PR's verification section.
Huawei firmware, physical-device behavior, and unrelated main-thread stalls
remain unverified. The change does not claim that every possible foreground-start
timeout has the same cause.
## Android sources
- [Android 12 ActiveServices](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android12-release/services/core/java/com/android/server/am/ActiveServices.java): `bringDownServiceLocked`, `setServiceForegroundInnerLocked`, and `serviceForegroundTimeout`.
- [Foreground-service troubleshooting](https://developer.android.com/develop/background-work/services/fgs/troubleshooting): startup timeout versus background-start rejection.
- [Launching a foreground service](https://developer.android.com/develop/background-work/services/fgs/launch): prompt promotion, notification priority, and API 34 type prerequisites.
- [Background-start restrictions](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start): API 31 restrictions and user-interaction exceptions.
+29
View File
@@ -4444,3 +4444,32 @@ 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).
## ADR 75 — Dashboard HTTP exceptions require exact-origin user consent
**Status:** Accepted (2026-09-18).
**Context.** Address-range classification does not establish whether traffic
travels through a custom VPN. It blocked adding or migrating a Dashboard using
HTTP on a non-private-range address, even when the operator intentionally routed
that address through a private tunnel.
**Decision.** Keep HTTPS as the default. Offer an unchecked, explicit warning
and acknowledgement when a user configures an otherwise-restricted HTTP
Dashboard address. Persist consent only on that connection for the exact HTTP
scheme, host and port. A different origin requires new consent; editing retires
the old origin's exception and Dashboard authentication without replacing the
connection identity or deleting drafts/history. Setup, route editing and native
authentication share this authority. No Relay grant or global insecure-mode flag
is reused. Consent is not a VPN or encryption verdict.
The app does not detect or enforce VPN protection. The user accepts cleartext
exposure if the tunnel drops or traffic uses another route. Authentication still
uses the upstream Dashboard contract; a public status response cannot prove
protected access or Gateway readiness. Generic 401s retain sign-in/retry recovery;
loopback/public_url guidance is conditional on additional setup evidence.
**Consequences.** Advanced network setups are usable with explicit assumed risk.
The exception must not cross connection or credential-origin boundaries. VPN
monitoring or route enforcement would be separate work, not an implied guarantee.
+30
View File
@@ -68,6 +68,7 @@ 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 |
| `unsolicited_voice_completions` | One submitted turn followed by live same-session process, watch, and delegation answers, including duplicate start/terminal frames; Standard Voice receives each admitted answer once |
| `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 |
@@ -179,6 +180,35 @@ redacted.
## Current-upstream conformance
Standard Voice receives successful unsolicited assistant answers from live Chat
admission, with a receipt captured before the new assistant placeholder exists.
The receipt belongs to the active voice generation and conversation binding;
history reads, passive Desktop observation, unmatched terminal recovery, and
queued-checkpoint restoration do not create speech receipts. Stop, voice exit,
engine changes, and conversation changes invalidate pending receipts. An active
microphone capture or earlier spoken answer finishes before queued speech starts.
The existing Continuous microphone release barrier still owns rearming.
Process completion/watch notifications and async delegation wakes enter upstream's
ordinary prompt runner (`tui_gateway/session_notifications.py` and `prompt_turn.py`
in current split upstream sources). The resulting assistant answer uses the same
live admission contract, regardless of its trigger. Raw process output, child
previews, and `background.complete` side-agent events are not assistant answers
and do not independently trigger narration. Reconnect history remains silent;
new live turns after reconnect can receive new receipts for the same owner.
`VoiceInboundCompletionTest` exercises the voice receipt and configured synthesis
path; `ChatViewModelGatewayInboundTurnTest` exercises real WebSocket admission.
The `unsolicited_voice_completions` manifest certifies the upstream terminal
contract without making provider or physical-audio claims.
For emulator lifecycle coverage, start that fixture on host loopback and run
`GatewayExternalFixtureInstrumentedTest#unsolicitedVoiceCompletions_surviveActivityPauseWithoutHistorySpeech`
on `standardPhoneApi36`, passing its emulator-accessible URL through
`gatewayFixtureBaseUrl`. The test uses production Chat/Voice view models and a
synthetic Standard audio client returning silent WAVs; it asserts three synthesis
requests across Activity pause/resume, with no provider calls or microphone capture.
Run against a clean checkout of `NousResearch/hermes-agent`:
```powershell
+16 -16
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -25,8 +25,8 @@
"docs_locale": "de",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/quick-start.md": "60d0ac8c35a03b3e4231329956e1bfee7dd276181d7513ecb5a877c323a282f2",
"guide/getting-started.md": "909d4114f0a99d62627673d69b50743ec257df8e83199f90b27788318b0b88bc",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -60,8 +60,8 @@
"docs_locale": "es",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/quick-start.md": "60d0ac8c35a03b3e4231329956e1bfee7dd276181d7513ecb5a877c323a282f2",
"guide/getting-started.md": "909d4114f0a99d62627673d69b50743ec257df8e83199f90b27788318b0b88bc",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -84,8 +84,8 @@
"docs_locale": "ja",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/quick-start.md": "60d0ac8c35a03b3e4231329956e1bfee7dd276181d7513ecb5a877c323a282f2",
"guide/getting-started.md": "909d4114f0a99d62627673d69b50743ec257df8e83199f90b27788318b0b88bc",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -108,8 +108,8 @@
"docs_locale": "pt-BR",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/quick-start.md": "60d0ac8c35a03b3e4231329956e1bfee7dd276181d7513ecb5a877c323a282f2",
"guide/getting-started.md": "909d4114f0a99d62627673d69b50743ec257df8e83199f90b27788318b0b88bc",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "8588c42385e783a2ddf888b515ca6b97a5df44a30d3619c048d3d03d91dd5278",
"main": "43a17ac24770db618668e146c8b6729233dbe91398275151c831861a551f1acc",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -147,8 +147,8 @@
"docs_locale": "zh-CN",
"docs_source_sha256": {
"index.md": "101ef2e9394b76e822d0c828e2100bf18a9d3f450224f5f0ae3ea01cb02fab0d",
"guide/quick-start.md": "57620215fb98a676f1b92d45596d44aa369dacc6e16dad17242f6e3e5cc38906",
"guide/getting-started.md": "a30d0553f962f780573054dfa148a5c1dd88e258cb8fcaee9fdaed63346efeba",
"guide/quick-start.md": "60d0ac8c35a03b3e4231329956e1bfee7dd276181d7513ecb5a877c323a282f2",
"guide/getting-started.md": "909d4114f0a99d62627673d69b50743ec257df8e83199f90b27788318b0b88bc",
"guide/release-tracks.md": "5d9ba422975459581fdbbc0b9e6f9b9c7dc53f9196606455b418da11d63d3a63",
"guide/troubleshooting.md": "9ace84208d2109d3ae7b35a21838eae146db3171236568d3ae60fb8d9de882cf"
},
+8 -2
View File
@@ -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
@@ -201,6 +201,12 @@ The Play build declares `SYSTEM_ALERT_WINDOW` only for explicitly user-started V
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
#### Reviewer recording retention
Android 1.17.0 (57) recordings cover [microphone use: Voice Overlay and local wake](https://hermes-relay.dev/play-review/android-microphone-fgs-v1.17.0.mp4) and the [connection foreground service](https://hermes-relay.dev/play-review/android-connection-service-v1.17.0.mp4). The [reviewer page](https://hermes-relay.dev/play-review/) records their emulator scope and limitations. These versioned recordings do not certify later builds.
Keep Console-linked footage in persistent media storage so website deployments preserve the links. Before updating a declaration, verify public access, `video/mp4` content type, byte-range playback and published checksums.
### Data safety
There is no telemetry, advertising, or third-party analytics SDK. App traffic goes
+16
View File
@@ -1,5 +1,21 @@
# Hermes-Relay — Dev Log
## 2026-09-14 — Android 1.17.0 and Plugin 1.11.3 publication verified
Published [Hermes-Relay Android 1.17.0](https://github.com/Codename-11/hermes-relay/releases/tag/android-v1.17.0) (versionCode 57) and [Hermes-Relay Plugin 1.11.3](https://github.com/Codename-11/hermes-relay/releases/tag/server-v1.11.3) from `52f3f7565811828824d42bc9b432296271a2f9c3`. Both immutable tags retain tree `b366c9567759e509d39b6495197ffd35b3eeb430`. Android public APK/AAB bytes match the signed Play preflight artifact; Plugin wheel/sdist metadata and checksums were independently verified.
Play accepted code 57 on Internal testing and Production. At initial verification, the Publisher API reported Production `completed` while Console showed the release in review, with Managed Publishing off. Console subsequently confirmed Android 1.17.0/code 57 as **Available on Google Play** on September 14, 2026, across 177 countries/regions. English and Chinese Play notes match the tagged sources.
Canonical and legacy privacy pages were published and matched the committed policy. Data Safety and foreground-service declarations were reconciled, and [versioned reviewer videos](https://hermes-relay.dev/play-review/) use persistent storage. API 36 emulator evidence covers overlay access gating, background capture, notification Stop, screen-lock shutdown, standalone wake listening and persistent-connection controls. Physical Android 14–16/OEM testing was waived; speech-provider behavior and combined voice/wake handoff are not certified by these recordings.
Release follow-ups closed #474 and #556 as fixed. #557 remains the upstream-tracked context gap; its existing reply was preserved. The contributor on #583 was notified after Plugin publication.
## 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.
+25 -1
View File
@@ -117,6 +117,14 @@ The normal UI reports outcomes such as Chat, Manage, Voice, Direct API, and
Relay extensions instead of treating a missing optional endpoint as a broken
connection.
Users may explicitly accept cleartext HTTP risk for an otherwise-restricted
Dashboard origin. Consent belongs to the saved connection and exact HTTP host
and port. It neither detects nor enforces VPN protection, asserts encryption,
bypasses authentication, nor grants Relay access. Address changes require fresh
consent and retire the previous origin's exception and Dashboard credentials.
Public status proves discovery only; setup verifies protected authentication
before presenting it as verified, and Chat readiness still requires Gateway Ready.
Dashboard route/auth availability and Gateway socket readiness are separate
authorities. A successful Dashboard status/auth probe enables Manage, session
browsing, and standard voice, but Chat is connected through Gateway only after
@@ -126,6 +134,22 @@ authentication, unsupported-protocol, and access-policy failures stop automatic
retry. After a socket has reached Ready once, ordinary network loss remains a
non-terminal reconnect episode while Chat is visible.
Gateway background protection follows process-local, connection/profile/session
turn leases. Idle retention remains opt-in through Persistent connection. An
accepted Android foreground-service start is promoted before shutdown, including
when a turn finishes before the start callback arrives. Overlapping demand uses
the latest state; old start commands never restore old turn counts. New service
launches wait for a visible application lifecycle, while an existing foreground
service continues protecting active work after backgrounding. A rejected launch
is logged and may retry on a later foreground transition.
The notification's **Turn off always-on** action persists that preference before
the collector reconciles current turn leases. It does not interrupt Hermes work.
Task removal drops local foreground protection without clearing chat-owned
leases or assuming process termination; if the process survives, returning to
the app can protect unfinished turns again. The service is not sticky and does
not restart idle retention from stale notification actions after process death.
The session drawer is a Dashboard REST consumer, not a Gateway-socket view.
Profile-scoped session browsing and stored transcript reads remain available
whenever the authenticated Dashboard route is available, including while the
@@ -1199,7 +1223,7 @@ utilities.
transfers ownership so assistant-process cleanup cannot cancel the main-app flow.
While keyguard is active, the surface keeps only generic phase and retry copy;
transcript, response, route-specific errors, and screen context remain hidden.
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
- Stable voice observes the submitted run through `messages: StateFlow`; transcribed text uses the normal Chat pipeline. While voice remains active, successful live unsolicited Gateway turns in that exact conversation also deliver their settled answer once through the configured voice output. Delivery waits for current capture/playback; Stop, exit, engine changes, and conversation changes invalidate pending speech. History/reconnect replay and passive observation never create speech. Experimental Realtime Agent retains its separate mirrored chat turn and broker events.
- `VoiceModeOverlay` — full-screen UI with the MorphingSphere at 60% height in `voiceMode=true`, transcribed + response text, mic button supporting Tap / Hold / Continuous interaction modes.
- The optional `SYSTEM_ALERT_WINDOW` Voice control is user-invoked from an
active in-app turn. It starts as a wide compact bar, expands for transcript,
+8 -8
View File
@@ -1,15 +1,15 @@
[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"
navigation-compose = "2.10.0"
kotlin = "2.4.20"
compose-bom = "2026.09.00"
navigation-compose = "2.10.1"
okhttp = "5.5.0"
kotlinx-serialization = "1.11.0"
kotlinx-coroutines = "1.11.0"
mockk = "1.14.11"
robolectric = "4.16.1"
robolectric = "4.17"
konsist = "0.17.3"
security-crypto = "1.1.0"
tink-android = "1.23.0"
@@ -22,13 +22,13 @@ exifinterface = "1.4.2"
datastore = "1.2.1"
splashscreen = "1.2.0"
markdown-renderer = "0.45.0"
coil = "3.6.2"
coil = "3.6.3"
haze = "1.7.3"
mlkit-barcode = "17.3.0"
zxing-core = "3.5.4"
camera = "1.6.1"
play-publisher = "4.1.1"
media3 = "1.11.0"
media3 = "1.11.1"
androidVad = "2.0.10"
sherpaOnnx = "v1.13.4"
onnxRuntime = "1.27.0"
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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__
+1 -1
View File
@@ -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 = [
+5 -1
View File
@@ -21,9 +21,13 @@ import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
FOCUSED_TESTS = (
"com.hermesandroid.relay.network.upstream.GatewayKeepAliveServiceTest",
"com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistryTest",
"com.hermesandroid.relay.viewmodel.InjectedContextTest",
"com.hermesandroid.relay.screenshots.InjectedContextSheetTest",
"com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest.injectedContextPreviewMatchesBareGatewayPayload",
"com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest",
"com.hermesandroid.relay.viewmodel.VoiceInboundCompletionTest",
"com.hermesandroid.relay.voice.VoiceViewModelBargeInTest",
"com.hermesandroid.relay.voice.VoiceOverlayLifecycleTest",
"com.hermesandroid.relay.voice.VoiceOverlayForegroundServiceTest",
"com.hermesandroid.relay.voice.VoiceOverlayPresentationTest",
+16
View File
@@ -133,3 +133,19 @@ Potential future lanes are emulator instrumentation, current-upstream
conformance, Desktop/TUI adapters, and physical-device ADB certification. This
package does not configure timers, cron jobs, scheduled workflows, or mandatory
device runs.
## Android onboarding and authentication
The opt-in `dashboard_onboarding` scenario adds public discovery, a password
provider, protected auth/session routes and the ordinary Gateway turn. It uses
only synthetic data. Start it on loopback with the normal CLI and use an
explicit emulator ADB reverse. Username and password are both `fixture`;
these values are test-only and never forwarded to a provider.
`POST /__fixture__/auth` with `{"mode":"expired"}` invalidates the synthetic
cookie and sockets so Android must sign in again. `{"mode":"loopback"}`
returns `auth_required=false` from public status while protected routes return
plain Unauthorized. `{"mode":"signin"}` restores gated sign-in. These controls
are available only for this opt-in scenario; existing contract scenarios keep
their behavior. They do not model a real VPN, TLS proxy or OIDC provider.
@@ -18,6 +18,34 @@ from vanilla_gateway.evidence import EvidenceLog # noqa: E402
class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
async def test_dashboard_setup_signin_expiry_and_loopback_are_distinct(self) -> None:
fixture, base_url = await self.start("dashboard_onboarding")
async with self.session.get(f"{base_url}/api/status") as response:
self.assertTrue((await response.json())["auth_required"])
async with self.session.get(f"{base_url}/api/auth/me") as response:
self.assertEqual(401, response.status)
async with self.session.post(f"{base_url}/auth/password-login", json={
"username": "fixture", "password": "fixture",
}) as response:
self.assertEqual(200, response.status)
cookie = response.cookies["hermes_session"].value
headers = {"Cookie": f"hermes_session={cookie}"}
async with self.session.get(f"{base_url}/api/auth/me", headers=headers) as response:
self.assertTrue((await response.json())["authenticated"])
async with self.session.post(f"{base_url}/api/auth/ws-ticket", headers=headers) as response:
self.assertIn("ticket", await response.json())
async with self.session.post(f"{base_url}/__fixture__/auth", json={"mode": "expired"}):
pass
async with self.session.get(f"{base_url}/api/auth/me", headers=headers) as response:
self.assertEqual("session_expired", (await response.json())["error"])
async with self.session.post(f"{base_url}/__fixture__/auth", json={"mode": "loopback"}):
pass
async with self.session.get(f"{base_url}/api/status") as response:
self.assertFalse((await response.json())["auth_required"])
async with self.session.get(f"{base_url}/api/auth/me", headers=headers) as response:
self.assertEqual({"detail": "Unauthorized"}, await response.json())
self.assertEqual([], fixture._history_rows)
async def test_clarify_batch_requires_each_qid_and_replays_partial_progress(self) -> None:
fixture, base_url = await self.start("clarify_batch")
ws, _ = await self.connect(base_url)
@@ -218,6 +246,21 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("child_session_id", receipt["display_metadata"])
self.assertEqual("Delegation complete.", history[3]["content"])
async def test_unsolicited_voice_turns_follow_one_submit(self) -> None:
_, base_url = await self.start("unsolicited_voice_completions")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "prompt.submit", {"text": "fixture"})
frames = await self.frames_until(ws, lambda f: (
f.get("params", {}).get("type") == "message.complete"
and f.get("params", {}).get("payload", {}).get("text") == "Delegated work finished."
))
answers = [f["params"]["payload"]["text"] for f in frames
if f.get("params", {}).get("type") == "message.complete"]
self.assertEqual([
"Work started.", "Process finished.", "Process finished.",
"Watch matched.", "Delegated work finished.",
], answers)
async def test_ownership_rejection_is_terminal_without_persisted_turn(self) -> None:
fixture, base_url = await self.start("ownership_rejection")
ws, _ = await self.connect(base_url)
@@ -537,6 +580,7 @@ class ScenarioTestCase(unittest.TestCase):
"cross_client_observation",
"initial_history_bind",
"ordinary_turn",
"unsolicited_voice_completions",
"ownership_rejection",
"rapid_tools_interims",
"subagent_child_preview",
@@ -31,6 +31,7 @@ class Scenario:
active_list_supported: bool
active_list_snapshots: tuple[tuple[dict[str, Any], ...], ...]
session_initialization_error: str | None = None
dashboard_setup: bool = False
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Scenario":
@@ -41,6 +42,8 @@ class Scenario:
if not isinstance(raw["turns"], list):
raise ScenarioError("turns must be a list")
initialization_error = raw.get("session_initialization_error")
if not isinstance(raw.get("dashboard_setup", False), bool):
raise ScenarioError("dashboard_setup must be a boolean")
if initialization_error is not None and (
not isinstance(initialization_error, str) or not initialization_error or len(initialization_error) > 500
):
@@ -141,6 +144,7 @@ class Scenario:
active_list_supported=active_list_supported,
active_list_snapshots=tuple(validated_snapshots),
session_initialization_error=initialization_error,
dashboard_setup=raw.get("dashboard_setup", False),
)
@@ -0,0 +1,58 @@
{
"name": "dashboard_onboarding",
"live_session_id": "fixture-live-1",
"stored_session_id": "20260821_120000_fixture",
"profile": "default",
"contract_requirements": [
"gateway.message_complete"
],
"initial_history": [],
"turns": [
{
"steps": [
{
"op": "event",
"type": "message.start"
},
{
"op": "event",
"type": "message.delta",
"payload": {
"text": "Fixture response."
}
},
{
"op": "persist",
"messages": [
{
"id": 1,
"role": "user",
"content": "Fixture prompt.",
"timestamp": 1.0
},
{
"id": 2,
"role": "assistant",
"content": "Fixture response.",
"timestamp": 2.0,
"finish_reason": "stop"
}
]
},
{
"op": "set_running",
"value": false
},
{
"op": "event",
"type": "message.complete",
"payload": {
"text": "Fixture response.",
"status": "complete"
}
}
]
}
],
"dashboard_setup": true
}
@@ -0,0 +1,37 @@
{
"name": "unsolicited_voice_completions",
"live_session_id": "fixture-live-1",
"stored_session_id": "20260821_120000_fixture",
"profile": "default",
"contract_requirements": ["gateway.message_complete"],
"turns": [{
"steps": [
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Work started.", "status": "complete"}},
{"op": "sleep", "milliseconds": 500},
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.delta", "payload": {"text": "Process finished."}},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}, {"id": 3, "role": "assistant", "content": "Process finished.", "timestamp": 3.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Process finished.", "status": "complete"}},
{"op": "event", "type": "message.complete", "payload": {"text": "Process finished.", "status": "complete"}},
{"op": "sleep", "milliseconds": 500},
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}, {"id": 3, "role": "assistant", "content": "Process finished.", "timestamp": 3.0}, {"id": 4, "role": "assistant", "content": "Watch matched.", "timestamp": 4.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Watch matched.", "status": "complete"}},
{"op": "sleep", "milliseconds": 500},
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}, {"id": 3, "role": "assistant", "content": "Process finished.", "timestamp": 3.0}, {"id": 4, "role": "assistant", "content": "Watch matched.", "timestamp": 4.0}, {"id": 5, "role": "assistant", "content": "Delegated work finished.", "timestamp": 5.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Delegated work finished.", "status": "complete"}}
]
}]
}
@@ -29,6 +29,19 @@ class GatewayFixture:
self.scenario = scenario
self.evidence = EvidenceLog(evidence_limit)
self.app = web.Application()
self._setup_mode = "signin"
self._setup_cookie = secrets.token_urlsafe(24)
if scenario.dashboard_setup:
self.app.middlewares.append(self._setup_auth)
self.app.add_routes([
web.get("/api/status", self._setup_status),
web.get("/api/auth/providers", self._setup_providers),
web.get("/api/auth/me", self._setup_me),
web.post("/auth/password-login", self._setup_login),
web.get("/api/profiles", self._setup_profiles),
web.get("/api/profiles/active", self._setup_profile_scope),
web.post("/__fixture__/auth", self._setup_control),
])
self.app.add_routes(
[
web.post("/api/auth/ws-ticket", self._ticket),
@@ -64,6 +77,61 @@ class GatewayFixture:
def running(self) -> bool:
return self._running
@web.middleware
async def _setup_auth(self, request: web.Request, handler: Any) -> web.StreamResponse:
protected = request.path.startswith("/api/") and request.path not in {
"/api/status", "/api/auth/providers", "/api/ws",
}
if protected and (self._setup_mode == "loopback" or
request.cookies.get("hermes_session") != self._setup_cookie):
self.evidence.add("auth", outcome="rejected_cookie" if request.cookies else "no_cookie")
body = {"detail": "Unauthorized"}
if self._setup_mode != "loopback":
body["error"] = "session_expired" if self._setup_mode == "expired" else "unauthenticated"
return web.json_response(body, status=401)
return await handler(request)
async def _setup_status(self, _request: web.Request) -> web.Response:
return web.json_response({
"auth_required": self._setup_mode != "loopback",
"auth_providers": ["basic"], "auth_flows": [],
"profiles": [self.scenario.profile],
"gateway_mode": "multiplex",
"gateways": [{"profile": self.scenario.profile, "served_profiles": [self.scenario.profile]}],
"version": "synthetic-setup-fixture", "install_id": self.scenario.name,
})
async def _setup_providers(self, _request: web.Request) -> web.Response:
return web.json_response({"providers": [{"name": "basic", "supports_password": True}]})
async def _setup_me(self, _request: web.Request) -> web.Response:
return web.json_response({"authenticated": True, "username": "Fixture", "provider": "basic"})
async def _setup_profiles(self, _request: web.Request) -> web.Response:
return web.json_response({"profiles": [{"name": self.scenario.profile, "model": "fixture"}]})
async def _setup_profile_scope(self, _request: web.Request) -> web.Response:
return web.json_response({"active": self.scenario.profile, "current": self.scenario.profile})
async def _setup_login(self, request: web.Request) -> web.Response:
body = await request.json()
if body.get("username") != "fixture" or body.get("password") != "fixture":
return web.json_response({"detail": "Invalid fixture credentials"}, status=401)
response = web.json_response({"ok": True, "next": "/"})
response.set_cookie("hermes_session", self._setup_cookie, httponly=True, path="/")
return response
async def _setup_control(self, request: web.Request) -> web.Response:
mode = (await request.json()).get("mode")
if mode not in {"signin", "expired", "loopback"}:
raise web.HTTPBadRequest(text="invalid fixture auth mode")
self._setup_mode = mode
self._setup_cookie = secrets.token_urlsafe(24)
self._tickets.clear()
for socket in tuple(self._sockets):
await socket.close(code=4401, message=b"fixture auth changed")
return web.json_response({"mode": mode})
async def close(self) -> None:
self._closing = True
self._queued.clear()
@@ -126,7 +194,11 @@ class GatewayFixture:
if not isinstance(method, str) or request_id is None:
return
self.evidence.add("rpc", connection=connection, method=method, outcome="received")
if method == "session.create":
if self.scenario.dashboard_setup and method == "profiles.list":
result = {"profiles": [{"name": self.scenario.profile, "model": "fixture", "is_default": True}]}
elif self.scenario.dashboard_setup and method == "pet.info":
result = {"enabled": False}
elif method == "session.create":
result = self._session_snapshot(include_stored=True)
if self.scenario.session_initialization_error:
result["info"]["lazy"] = True
@@ -340,10 +412,16 @@ class GatewayFixture:
if profile not in (None, self.scenario.profile):
raise web.HTTPNotFound(text="profile not found")
self.evidence.add("directory", outcome="listed")
sessions = [{
"id": self.scenario.stored_session_id,
"title": "Fixture conversation", "source": "cli",
"model": "fixture", "message_count": len(self._history_rows),
"created_at": 1.0, "updated_at": 2.0,
}] if self.scenario.dashboard_setup and self._history_rows else []
return web.json_response(
{
"sessions": [],
"pagination": {"limit": 50, "offset": 0, "returned": 0},
"sessions": sessions,
"pagination": {"limit": 50, "offset": 0, "returned": len(sessions)},
},
)
+4
View File
@@ -35,6 +35,10 @@ nicht die `.aab`-Datei herunter; sie ist nur für Google Play bestimmt.
## 2. Hermes erreichbar machen
Das Dashboard muss vom Telefon erreichbar sein und einen Authentifizierungsanbieter verwenden. `hermes dashboard` bindet standardmäßig nur an Loopback. Konfiguriere zuerst die Anmeldung und starte für direkten LAN-/VPN-Zugriff mit `hermes dashboard --host 0.0.0.0 --port 9119 --no-open`. Bei einem Reverse Proxy auf Loopback müssen die externe `dashboard.public_url` und ein Anbieter eingerichtet sein. Kopiere niemals den internen Sitzungstoken auf das Telefon.
Wähle **Hermes nearby** oder **Remote gateway**. HTTPS wird empfohlen. Andere HTTP-Adressen erfordern eine ausdrückliche Risikoannahme für diese Verbindung und den genauen Host und Port. Die App erkennt oder erzwingt keinen VPN-Schutz; bei VPN-Ausfall trägst du das Risiko unverschlüsselter Zugangsdaten und Gespräche. Eine neue Adresse benötigt neue Zustimmung und gegebenenfalls eine neue Anmeldung. Abbrechen erhält die alte Adresse; Verlauf und Entwürfe bleiben erhalten. Direct API ist eine ausdrücklich gewählte Alternative, kein automatischer Ersatz für Gateway-Chats. Ein 401 allein beweist keinen Fehler in `dashboard.public_url`.
Android verwendet standardmäßig das Hermes Dashboard/Gateway unter `:9119`.
Es stellt Chat, Sitzungen, Anmeldung, Manage, Standard-Voice und authentifizierte
eingehende Dateien bereit. Starte
+5 -7
View File
@@ -28,15 +28,13 @@ gleichzeitig auf demselben Gerät installiert sein.
## 2. Hermes starten
Auf dem Host muss das Hermes Dashboard/Gateway laufen und vom Telefon erreichbar
sein. Starte es bei Bedarf mit `hermes dashboard`. Das ist die gesamte
serverseitige Einrichtung für Standard-Chat, Sitzungen, Manage, Voice und
eingehende Dateien. Die ausführliche Einrichtung steht unter
[Installation & Einrichtung](/de/guide/getting-started).
Das Dashboard muss vom Telefon erreichbar sein und einen Authentifizierungsanbieter verwenden. `hermes dashboard` bindet standardmäßig nur an Loopback. Konfiguriere zuerst die Anmeldung und starte für direkten LAN-/VPN-Zugriff mit `hermes dashboard --host 0.0.0.0 --port 9119 --no-open`. Bei einem Reverse Proxy auf Loopback müssen die externe `dashboard.public_url` und ein Anbieter eingerichtet sein. Kopiere niemals den internen Sitzungstoken auf das Telefon.
Wähle **Hermes nearby** oder **Remote gateway**. HTTPS wird empfohlen. Andere HTTP-Adressen erfordern eine ausdrückliche Risikoannahme für diese Verbindung und den genauen Host und Port. Die App erkennt oder erzwingt keinen VPN-Schutz; bei VPN-Ausfall trägst du das Risiko unverschlüsselter Zugangsdaten und Gespräche. Eine neue Adresse benötigt neue Zustimmung und gegebenenfalls eine neue Anmeldung. Abbrechen erhält die alte Adresse; Verlauf und Entwürfe bleiben erhalten. Direct API ist eine ausdrücklich gewählte Alternative, kein automatischer Ersatz für Gateway-Chats. Ein 401 allein beweist keinen Fehler in `dashboard.public_url`.
## 3. Standardverbindung hinzufügen {#other-supported-paths}
Öffne in Android **Connect**. Nutze **Find Hermes on LAN** oder trage die
Öffne in Android **Connect**. Nutze **Hermes nearby** oder trage die
Dashboard-Adresse, normalerweise `http://<host>:9119`, manuell ein. Melde dich
bei Aufforderung an. Damit entsteht eine vollständige Standardverbindung ohne
Plugin oder Relay-URL.
@@ -56,7 +54,7 @@ Nutze `--no-ssl` nur in einem vertrauenswürdigen LAN oder VPN. Für den Zugriff
von unterwegs wird [Tailscale empfohlen](/guide/remote-access).
Öffne danach im Web Dashboard **Relay → Pair new device** und scanne den
einmaligen QR über **Settings → Connections → Pair Hermes Relay**.
einmaligen QR über **Settings → Gateways → Pair Hermes Relay**.
Der API-Server bleibt ein optionaler Fallback. Relay ist für den Upstream-Weg
nicht erforderlich, wird aber für Terminal/TUI, Benachrichtigungen,
+4
View File
@@ -34,6 +34,10 @@ descargues el archivo `.aab`; está destinado a Google Play.
## 2. Haz que Hermes sea accesible
El Dashboard debe ser accesible desde el teléfono y tener un proveedor de autenticación. `hermes dashboard` escucha solo en loopback de forma predeterminada. Configura primero el inicio de sesión y, para acceso directo por LAN/VPN, ejecuta `hermes dashboard --host 0.0.0.0 --port 9119 --no-open`. Un proxy inverso hacia loopback necesita la `dashboard.public_url` externa y un proveedor. Nunca copies el token interno al teléfono.
Elige **Hermes nearby** o **Remote gateway**. Se recomienda HTTPS. Otras direcciones HTTP requieren aceptar el riesgo para esta conexión, host y puerto exactos. La aplicación no detecta ni impone protección VPN; asumes la exposición de credenciales y conversaciones si la VPN falla. Cambiar el origen requiere nuevo consentimiento e inicio de sesión. Cancelar conserva la dirección anterior; el historial y los borradores se mantienen. Direct API es una alternativa explícita, nunca un reemplazo automático del chat Gateway. Un 401 por sí solo no demuestra un error en `dashboard.public_url`.
Android usa normalmente el Dashboard/Gateway de Hermes en `:9119`. Proporciona
Chat, sesiones, inicio de sesión, Manage, voz estándar y archivos entrantes
autenticados. Inícialo con
+5 -7
View File
@@ -28,15 +28,13 @@ estar instaladas a la vez.
## 2. Inicia Hermes
El Dashboard/Gateway de Hermes debe estar activo y accesible desde el teléfono.
Si es necesario, inícialo con `hermes dashboard`. Consulta
[Instalación y configuración](/es/guide/getting-started) para preparar el servidor.
Esta es toda la configuración del servidor necesaria para Chat, sesiones,
Manage, voz y archivos entrantes estándar.
El Dashboard debe ser accesible desde el teléfono y tener un proveedor de autenticación. `hermes dashboard` escucha solo en loopback de forma predeterminada. Configura primero el inicio de sesión y, para acceso directo por LAN/VPN, ejecuta `hermes dashboard --host 0.0.0.0 --port 9119 --no-open`. Un proxy inverso hacia loopback necesita la `dashboard.public_url` externa y un proveedor. Nunca copies el token interno al teléfono.
Elige **Hermes nearby** o **Remote gateway**. Se recomienda HTTPS. Otras direcciones HTTP requieren aceptar el riesgo para esta conexión, host y puerto exactos. La aplicación no detecta ni impone protección VPN; asumes la exposición de credenciales y conversaciones si la VPN falla. Cambiar el origen requiere nuevo consentimiento e inicio de sesión. Cancelar conserva la dirección anterior; el historial y los borradores se mantienen. Direct API es una alternativa explícita, nunca un reemplazo automático del chat Gateway. Un 401 por sí solo no demuestra un error en `dashboard.public_url`.
## 3. Añade la conexión estándar {#other-supported-paths}
En Android, abre **Connect**. Usa **Find Hermes on LAN** o introduce manualmente
En Android, abre **Connect**. Usa **Hermes nearby** o introduce manualmente
la dirección del Dashboard, normalmente `http://<host>:9119`. Inicia sesión
cuando se solicite. Así se crea una conexión estándar completa sin plugin ni
URL de Relay.
@@ -56,7 +54,7 @@ Usa `--no-ssl` solo en una LAN o VPN de confianza. Para acceder desde fuera de
casa, [se recomienda Tailscale](/guide/remote-access).
Después, abre **Relay → Pair new device** en el Web Dashboard y escanea el QR de
un solo uso desde **Settings → Connections → Pair Hermes Relay**.
un solo uso desde **Settings → Gateways → Pair Hermes Relay**.
El servidor de API sigue siendo un fallback opcional. Relay no es obligatorio
para upstream, pero se recomienda para Terminal/TUI, notificaciones,
+29 -4
View File
@@ -434,8 +434,10 @@ see [Remote access](/guide/remote-access) and the
Manage uses the Hermes dashboard/admin server and stores its native bearer or
compatibility cookies separately from Relay pairing credentials.
- **Dashboard auth disabled / open dashboard:** Manage works as long as Android
can reach the dashboard URL.
- **Loopback / internal-token mode:** a reachable public status endpoint does
not prove that Android can use protected routes. Configure a phone-reachable
authenticated Dashboard, or the external `dashboard.public_url` and provider
for a loopback reverse proxy. Do not copy the internal token to Android.
- **Basic username/password login:** supported. Current gateways broker it in the
system browser when `native_pkce` is advertised. Compatibility gateways post
to `/auth/password-login` and store exact-origin Dashboard cookies.
@@ -458,10 +460,33 @@ compatibility cookies separately from Relay pairing credentials.
Relay pairing does not replace dashboard login, and dashboard login does not mint
an API key: it matches the Hermes Desktop remote-gateway path by authenticating
`/api/ws` and `/api/pty` with the Dashboard session plus a single-use ticket from
`/api/auth/ws-ticket`. Android uses that gateway path when it is ready and falls
back to API-server SSE when it is not.
`/api/auth/ws-ticket`. A Gateway conversation keeps that owner when sign-in
expires or the network fails. Direct API is an explicitly selected compatibility
connection; it never silently takes over a Gateway conversation.
:::
### Explicit HTTP risk exception
HTTPS remains the default for public addresses. In **Remote gateway**, the
gateway address editor, or an extra route editor, an HTTP address outside the
recognized private ranges displays a warning and an unchecked acknowledgement.
Confirm it only if you accept the risk of sending credentials and conversation
data without HTTP-layer encryption. The app does not detect a VPN, verify its
route, or stop requests when the VPN disconnects.
The exception is saved for this connection and exact scheme, host, and port.
Changing the address requires fresh acknowledgement; it does not mark the route
encrypted, bypass Dashboard authentication, or grant Relay access. Editing keeps
the saved gateway identity and history references. A changed credential origin
retires that gateway's old Dashboard authentication and requires sign-in again.
Cancelling or a failed validation leaves the old address intact.
A generic 401 means authorization failed, not that `dashboard.public_url` is
necessarily wrong. If public status advertises no authentication but protected
requests fail, check the host's bind/proxy mode, configured provider and external
public URL. For ordinary expired sessions, sign in again and retry the same
conversation. Never delete history or switch to another profile as a workaround.
::: details Manual connection setup (no QR)
**During onboarding:**
+19 -6
View File
@@ -29,20 +29,33 @@ operate the phone screen. [Compare the builds and verify the APK →](./getting-
### 2. Start Hermes
The Dashboard/Gateway must be running and reachable from the phone. This is all
the server-side setup required for standard Chat, sessions, Manage, voice, and
inbound files:
The Dashboard/Gateway must be reachable from the phone and have authentication
configured. `hermes dashboard` alone defaults to the host's loopback interface;
it does not expose a fresh installation to another device. Configure a supported
authentication provider first, following [host setup](./getting-started#_2-point-it-at-hermes).
For direct LAN or VPN access, start the Dashboard on a reachable interface:
```bash
hermes dashboard
hermes dashboard --host 0.0.0.0 --port 9119 --no-open
```
### 3. Add the standard Hermes connection
In Android **Connect**, choose **Find Hermes on LAN**. If discovery cannot find
the host, choose **Enter your Hermes address** and enter the Dashboard URL you
Open the same address in the phone's browser before continuing. For a reverse
proxy forwarding to a loopback Dashboard, configure the external
`dashboard.public_url` and its authentication provider; see [remote access](./remote-access).
Do not use the host's internal session token on the phone.
In Android **Connect**, choose **Hermes nearby**. If discovery cannot find
the host, choose **Remote gateway** and enter the Dashboard URL you
open in a browser, normally `http://<host>:9119`. Sign in when prompted.
HTTPS is recommended. An HTTP address outside the recognized private ranges
requires an explicit risk acknowledgement for that exact address. The app does
not detect or enforce VPN protection; you accept the exposure risk if your VPN
disconnects or routes the traffic elsewhere. This exception belongs only to
this saved gateway and does not pair Relay.
This creates a complete standard connection with no plugin or Relay URL. If the
Relay Dashboard page is already installed, its **Connect mobile app** action can
instead provide the same tokenless Dashboard-address setup by QR; that QR does
+4
View File
@@ -34,6 +34,10 @@ canonical_source: /guide/getting-started
## 2. Hermes を到達可能にする
Dashboard は端末から到達可能で、認証プロバイダーが設定されている必要があります。`hermes dashboard` は既定でループバックだけを使います。まず認証を設定し、直接 LAN/VPN 接続する場合は `hermes dashboard --host 0.0.0.0 --port 9119 --no-open` で起動します。ループバックへ転送するリバースプロキシには外部の `dashboard.public_url` と認証プロバイダーが必要です。内部セッショントークンを端末へコピーしないでください。
**Hermes nearby** または **Remote gateway** を選びます。HTTPS を推奨します。それ以外の HTTP アドレスでは、この接続の正確なホストとポートについてリスクへの同意が必要です。アプリは VPN の保護を検出も強制もしません。VPN 切断時の認証情報や会話の露出リスクは利用者が負います。接続元を変更すると新たな同意とサインインが必要です。キャンセルすると元のアドレスが保持され、履歴と下書きは維持されます。Direct API は明示的な選択であり、Gateway チャットを自動で置き換えません。401 だけでは `dashboard.public_url` の誤設定を断定できません。
Android の標準接続先は `:9119` の Hermes Dashboard/Gateway です。Chat、
セッション、ログイン、Manage、標準 Voice、認証済み受信ファイルを提供します。`hermes dashboard`
で起動し、スマートフォンから到達できるようにします。
+4 -5
View File
@@ -27,14 +27,13 @@ Hermes に画面の読み取り、タップ、文字入力、アプリ操作を
## 2. Hermes を起動する
Hermes Dashboard/Gateway が起動し、スマートフォンから到達できる必要があります。
必要に応じて `hermes dashboard` で起動します。サーバーの準備は
[インストールと設定](/ja/guide/getting-started)を参照してください。
これだけで標準の Chat、セッション、Manage、Voice、受信ファイルを利用できます。
Dashboard は端末から到達可能で、認証プロバイダーが設定されている必要があります。`hermes dashboard` は既定でループバックだけを使います。まず認証を設定し、直接 LAN/VPN 接続する場合は `hermes dashboard --host 0.0.0.0 --port 9119 --no-open` で起動します。ループバックへ転送するリバースプロキシには外部の `dashboard.public_url` と認証プロバイダーが必要です。内部セッショントークンを端末へコピーしないでください。
**Hermes nearby** または **Remote gateway** を選びます。HTTPS を推奨します。それ以外の HTTP アドレスでは、この接続の正確なホストとポートについてリスクへの同意が必要です。アプリは VPN の保護を検出も強制もしません。VPN 切断時の認証情報や会話の露出リスクは利用者が負います。接続元を変更すると新たな同意とサインインが必要です。キャンセルすると元のアドレスが保持され、履歴と下書きは維持されます。Direct API は明示的な選択であり、Gateway チャットを自動で置き換えません。401 だけでは `dashboard.public_url` の誤設定を断定できません。
## 3. 標準 Hermes 接続を追加する {#other-supported-paths}
Android で **Connect** を開き、**Find Hermes on LAN** を使うか、通常は
Android で **Connect** を開き、**Hermes nearby** を使うか、通常は
`http://<host>:9119` となる Dashboard アドレスを手入力します。求められたら
ログインします。これで plugin や Relay URL のない完全な標準接続が作成されます。
+4
View File
@@ -34,6 +34,10 @@ baixe o arquivo `.aab`; ele é destinado ao Google Play.
## 2. Deixe o Hermes acessível
O Dashboard precisa estar acessível no telefone e ter um provedor de autenticação. `hermes dashboard` usa apenas loopback por padrão. Configure primeiro o login e, para LAN/VPN direto, execute `hermes dashboard --host 0.0.0.0 --port 9119 --no-open`. Um proxy reverso para loopback precisa da `dashboard.public_url` externa e de um provedor. Nunca copie o token interno para o telefone.
Escolha **Hermes nearby** ou **Remote gateway**. HTTPS é recomendado. Outros endereços HTTP exigem aceitação do risco para esta conexão, host e porta exatos. O aplicativo não detecta nem impõe proteção VPN; você assume a exposição de credenciais e conversas se a VPN cair. Mudar a origem exige novo consentimento e login. Cancelar mantém o endereço anterior; histórico e rascunhos são preservados. Direct API é uma alternativa explícita, nunca substitui automaticamente o chat Gateway. Um 401 sozinho não comprova erro em `dashboard.public_url`.
O Android usa normalmente o Dashboard/Gateway do Hermes em `:9119`. Ele fornece
Chat, sessões, login, Manage, voz padrão e arquivos recebidos autenticados.
Inicie-o com `hermes dashboard` e
+5 -7
View File
@@ -27,15 +27,13 @@ instale o APK assinado de **Sideload**. As duas versões podem ficar instaladas
## 2. Inicie o Hermes
O Dashboard/Gateway do Hermes precisa estar ativo e acessível pelo celular. Se
necessário, inicie-o com `hermes dashboard`. Consulte
[Instalação e configuração](/pt-BR/guide/getting-started) para preparar o servidor.
Essa é toda a configuração do servidor necessária para Chat, sessões, Manage,
voz e arquivos recebidos padrão.
O Dashboard precisa estar acessível no telefone e ter um provedor de autenticação. `hermes dashboard` usa apenas loopback por padrão. Configure primeiro o login e, para LAN/VPN direto, execute `hermes dashboard --host 0.0.0.0 --port 9119 --no-open`. Um proxy reverso para loopback precisa da `dashboard.public_url` externa e de um provedor. Nunca copie o token interno para o telefone.
Escolha **Hermes nearby** ou **Remote gateway**. HTTPS é recomendado. Outros endereços HTTP exigem aceitação do risco para esta conexão, host e porta exatos. O aplicativo não detecta nem impõe proteção VPN; você assume a exposição de credenciais e conversas se a VPN cair. Mudar a origem exige novo consentimento e login. Cancelar mantém o endereço anterior; histórico e rascunhos são preservados. Direct API é uma alternativa explícita, nunca substitui automaticamente o chat Gateway. Um 401 sozinho não comprova erro em `dashboard.public_url`.
## 3. Adicione a conexão padrão {#other-supported-paths}
No Android, abra **Connect**. Use **Find Hermes on LAN** ou informe manualmente
No Android, abra **Connect**. Use **Hermes nearby** ou informe manualmente
o endereço do Dashboard, normalmente `http://<host>:9119`. Entre quando
solicitado. Isso cria uma conexão padrão completa sem plugin nem URL do Relay.
@@ -54,7 +52,7 @@ Use `--no-ssl` somente em uma LAN ou VPN confiável. Para acesso fora de casa,
[o Tailscale é recomendado](/guide/remote-access).
Depois, abra **Relay → Pair new device** no Web Dashboard e leia o QR de uso
único em **Settings → Connections → Pair Hermes Relay**.
único em **Settings → Gateways → Pair Hermes Relay**.
O servidor de API continua sendo um fallback opcional. Relay não é obrigatório
para upstream, mas é recomendado para Terminal/TUI, notificações, ferramentas
+4
View File
@@ -33,6 +33,10 @@ Hermes Dashboard 已可访问,请使用[快速开始](./quick-start)。
## 2. 让手机可以访问 Hermes
Dashboard 必须能从手机访问并配置身份验证提供方。`hermes dashboard` 默认仅监听回环地址。请先设置登录,再使用 `hermes dashboard --host 0.0.0.0 --port 9119 --no-open` 启动直接 LAN/VPN 访问。转发到回环的反向代理需要外部 `dashboard.public_url` 和身份验证提供方。不要把内部会话令牌复制到手机。
选择 **Hermes nearby** 或 **Remote gateway**。建议使用 HTTPS。其他 HTTP 地址需要为此连接的准确主机和端口明确接受风险。应用不会检测或强制执行 VPN 保护;VPN 断开时的凭据和对话泄露风险由用户承担。更换源后需要重新同意并登录。取消会保留原地址,历史和草稿也会保留。Direct API 必须明确选择,不会自动接管 Gateway 聊天。仅凭 401 不能确定 `dashboard.public_url` 配置错误。
Android 的标准连接是 `:9119` 上的 Hermes Dashboard/Gateway。它提供 Chat、
会话、登录、Manage、标准 Voice 和经过身份验证的入站文件。请使用
`hermes dashboard` 启动,并确保手机可访问。
+4 -4
View File
@@ -25,13 +25,13 @@ canonical_source: /guide/quick-start
## 2. 启动 Hermes
Hermes Dashboard/Gateway 必须运行,并且手机可以访问。如有需要,在主机上运行
`hermes dashboard`。服务器准备步骤见[安装与设置](/zh-CN/guide/getting-started)。
这就是标准 Chat、会话、Manage、Voice 和入站文件所需的全部服务器端设置。
Dashboard 必须能从手机访问并配置身份验证提供方。`hermes dashboard` 默认仅监听回环地址。请先设置登录,再使用 `hermes dashboard --host 0.0.0.0 --port 9119 --no-open` 启动直接 LAN/VPN 访问。转发到回环的反向代理需要外部 `dashboard.public_url` 和身份验证提供方。不要把内部会话令牌复制到手机。
选择 **Hermes nearby** 或 **Remote gateway**。建议使用 HTTPS。其他 HTTP 地址需要为此连接的准确主机和端口明确接受风险。应用不会检测或强制执行 VPN 保护;VPN 断开时的凭据和对话泄露风险由用户承担。更换源后需要重新同意并登录。取消会保留原地址,历史和草稿也会保留。Direct API 必须明确选择,不会自动接管 Gateway 聊天。仅凭 401 不能确定 `dashboard.public_url` 配置错误。
## 3. 添加标准 Hermes 连接 {#other-supported-paths}
在 Android 中打开 **Connect**。使用 **Find Hermes on LAN**,或手动输入
在 Android 中打开 **Connect**。使用 **Hermes nearby**,或手动输入
Dashboard 地址(通常为 `http://<host>:9119`)。按提示登录。这样即可创建不含
plugin 或 Relay URL 的完整标准连接。
+4 -4
View File
@@ -43,7 +43,7 @@
<p>The sideload build is a separate distribution track for users who intentionally install Device Control outside Google Play.</p>
<h2>Data storage</h2>
<p>All data is stored locally on your device in the app's private sandbox.</p>
<p>App settings, credentials, drafts and recovery state are stored locally in the app's private sandbox. Content sent to your configured Hermes server and AI providers follows their storage and retention settings.</p>
<table>
<thead><tr><th>Data</th><th>Storage method</th></tr></thead>
<tbody>
@@ -54,11 +54,11 @@
<tr><td>Pending Android Assistant context</td><td>App-private cache until one turn is accepted, cancellation/session exit, or one-hour stale cleanup; failed preflight retains it for retry</td></tr>
</tbody>
</table>
<p>Chat messages are not cached on your device. They are loaded from your Hermes server on demand and exist only in memory while the app is running.</p>
<p>Your Hermes server owns conversation history. The app retains local drafts, queued messages and bounded in-flight turn checkpoints to recover interrupted conversations; these can include message text and confirmed Clarify answers. Attachment and Android Assistant context caches support viewing, sending and retrying content.</p>
<h2>Network connections</h2>
<p>The app connects only to endpoints you configure: your Hermes Dashboard/Gateway for standard chat, management, voice, and inbound files; your optional API server for chat fallback; your relay server for terminal and TUI relay, Bridge Core status, explicit Relay media enhancements, notification companion, and session management; and your relay voice routes when you use enhanced Voice modes.</p>
<p>No connections are made to Google, Anthropic, or any other third-party service by the app. There is no telemetry, advertising, external crash reporting, DNS prefetching, or background network activity to hosted Hermes-Relay services. Your Hermes server may separately connect to AI providers according to its configuration; that server-side activity is outside the scope of this app.</p>
<p>Chat and voice content goes to endpoints you configure: your Hermes Dashboard/Gateway for standard chat, management, voice, and inbound files; an explicitly selected API-only connection for compatible chat; your relay server for terminal and TUI relay, Bridge Core status, explicit Relay media enhancements, notification companion, and session management; and your relay voice routes when you use enhanced Voice modes. Gateway-owned chats do not silently switch to an API server.</p>
<p>There is no telemetry, advertising or automatic external crash reporting. Your Hermes server may send content to AI providers according to its configuration. Optional update checks and links can contact public distribution and documentation services; these do not upload chat or voice content.</p>
<h2>Voice Overlay</h2>
<p>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.</p>