Compare commits

..
Author SHA1 Message Date
Bailey Dixon ce5f9c7c98 fix(android): restore Sherpa ONNX compatibility 2026-09-23 09:16:33 -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
35 changed files with 1446 additions and 155 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 \
+13 -1
View File
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Fixed
- 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 +22,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())
}
}
@@ -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.
@@ -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 {
@@ -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
@@ -2870,12 +2870,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)
}
}
}
@@ -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
@@ -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.
+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
+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.
+17 -1
View File
@@ -126,6 +126,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 +1215,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",
@@ -218,6 +218,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 +552,7 @@ class ScenarioTestCase(unittest.TestCase):
"cross_client_observation",
"initial_history_bind",
"ordinary_turn",
"unsolicited_voice_completions",
"ownership_rejection",
"rapid_tools_interims",
"subagent_child_preview",
@@ -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"}}
]
}]
}
+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>