Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce5f9c7c98 | ||
|
|
a1cc34e649 | ||
|
|
e7f882f8b5 | ||
|
|
971a9a7e39 | ||
|
|
635aaa44e7 | ||
|
|
e088469dbf | ||
|
|
2d202bdeac | ||
|
|
798441c1e1 | ||
|
|
b424678f44 | ||
|
|
d5210bcd5e | ||
|
|
6458a854a8 | ||
|
|
65fe37a2ba | ||
|
|
9ac10cf645 | ||
|
|
bd904e26e4 | ||
|
|
ba5a2c82a5 | ||
|
|
6119f3ba79 | ||
|
|
2bcdca09e9 | ||
|
|
e2e9cc72b7 | ||
|
|
ca555fd617 | ||
|
|
3b5e61e149 | ||
|
|
1d25985aca | ||
|
|
500cc83de6 | ||
|
|
7d3c761c6a | ||
|
|
2e097035fb | ||
|
|
280c20dbca |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.data.setGatewayKeepAlive
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Real ActivityManager/notification lifecycle; no Gateway or personal data. */
|
||||
class GatewayKeepAliveServiceInstrumentedTest {
|
||||
@get:Rule val activity = createAndroidComposeRule<ComponentActivity>()
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private val context get() = instrumentation.targetContext
|
||||
|
||||
@Before fun setup() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
instrumentation.uiAutomation.executeShellCommand(
|
||||
"pm grant ${context.packageName} android.permission.POST_NOTIFICATIONS",
|
||||
).close()
|
||||
}
|
||||
runBlocking { context.setGatewayKeepAlive(false) }
|
||||
}
|
||||
|
||||
@After fun cleanup() {
|
||||
instrumentation.runOnMainSync {
|
||||
GatewayKeepAliveService.stop(context)
|
||||
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||
}
|
||||
await("service shutdown") { serviceState() == null }
|
||||
runBlocking { context.setGatewayKeepAlive(false) }
|
||||
}
|
||||
|
||||
@Test fun immediateStopsAndOverlappingStartsSurviveThePlatformWatchdog() {
|
||||
// All changes happen before Android can dispatch onCreate/onStartCommand.
|
||||
instrumentation.runOnMainSync {
|
||||
repeat(25) {
|
||||
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
|
||||
GatewayKeepAliveService.stop(context)
|
||||
}
|
||||
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
|
||||
}
|
||||
await("foreground promotion") { serviceState()?.foreground == true }
|
||||
instrumentation.runOnMainSync { GatewayKeepAliveService.stop(context) }
|
||||
await("settled shutdown") { serviceState() == null }
|
||||
// Observation window, not a startup workaround: an asynchronous system
|
||||
// foreground-start crash fails the instrumentation process during it.
|
||||
CountDownLatch(1).await(12, TimeUnit.SECONDS)
|
||||
assertTrue(serviceState() == null)
|
||||
}
|
||||
|
||||
@Test fun notificationDisablesAlwaysOnWhileANewerTurnStaysProtected() {
|
||||
runBlocking { context.setGatewayKeepAlive(true) }
|
||||
instrumentation.runOnMainSync {
|
||||
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
await("persistent notification") {
|
||||
manager.activeNotifications.any { it.id == GatewayKeepAliveService.NOTIFICATION_ID }
|
||||
}
|
||||
val action = manager.activeNotifications.single {
|
||||
it.id == GatewayKeepAliveService.NOTIFICATION_ID
|
||||
}.notification.actions.single().actionIntent
|
||||
instrumentation.runOnMainSync {
|
||||
ActiveTurnKeepAliveRegistry.acquire("fixture::profile-a::session")
|
||||
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
|
||||
}
|
||||
action.send()
|
||||
await("persisted notification action") {
|
||||
runBlocking { context.relayDataStore.data.first()[KEY_GATEWAY_KEEP_ALIVE] == false }
|
||||
}
|
||||
instrumentation.runOnMainSync {
|
||||
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
|
||||
}
|
||||
assertTrue(serviceState()?.foreground == true)
|
||||
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
|
||||
await("active-turn notification without always-on action") {
|
||||
manager.activeNotifications.singleOrNull {
|
||||
it.id == GatewayKeepAliveService.NOTIFICATION_ID
|
||||
}?.notification?.let { it.actions.isNullOrEmpty() } == true
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun serviceState(): ActivityManager.RunningServiceInfo? =
|
||||
context.getSystemService(ActivityManager::class.java).getRunningServices(100)
|
||||
.singleOrNull { it.service.className == GatewayKeepAliveService::class.java.name }
|
||||
|
||||
private fun await(description: String, condition: () -> Boolean) {
|
||||
val deadline = SystemClock.uptimeMillis() + 10_000
|
||||
while (!condition() && SystemClock.uptimeMillis() < deadline) {
|
||||
instrumentation.waitForIdleSync()
|
||||
SystemClock.sleep(20)
|
||||
}
|
||||
assertTrue(description, condition())
|
||||
}
|
||||
}
|
||||
+82
@@ -62,9 +62,17 @@ class GatewayExternalFixtureInstrumentedTest {
|
||||
private var gatewayScope: CoroutineScope? = null
|
||||
private var gatewayClient: GatewayChatClient? = null
|
||||
private var viewModel: ChatViewModel? = null
|
||||
private var voiceViewModel: VoiceViewModel? = null
|
||||
private var voicePlayer: com.hermesandroid.relay.audio.VoicePlayer? = null
|
||||
private var voiceSfx: com.hermesandroid.relay.audio.VoiceSfxPlayer? = null
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
compose.runOnUiThread {
|
||||
voiceViewModel?.exitVoiceMode()
|
||||
voicePlayer?.release()
|
||||
voiceSfx?.release()
|
||||
}
|
||||
viewModel?.updateGatewayClient(null)
|
||||
gatewayClient?.shutdown()
|
||||
gatewayScope?.cancel()
|
||||
@@ -250,6 +258,80 @@ class GatewayExternalFixtureInstrumentedTest {
|
||||
assertEquals("gateway", vm.streamingEndpoint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsolicitedVoiceCompletions_surviveActivityPauseWithoutHistorySpeech() {
|
||||
val base = InstrumentationRegistry.getArguments().getString(ARG_FIXTURE_BASE_URL)
|
||||
?.trim()?.trimEnd('/')
|
||||
assumeTrue("Pass the unsolicited_voice_completions fixture URL", !base.isNullOrBlank())
|
||||
requireNotNull(base)
|
||||
val http = OkHttpClient.Builder().callTimeout(10, TimeUnit.SECONDS).build()
|
||||
assertEquals("unsolicited_voice_completions", readFixtureJson(http, "$base/__fixture__/state")["scenario"]?.jsonString())
|
||||
val dashboard = DashboardApiClient(base, http)
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { gatewayScope = it }
|
||||
val gateway = GatewayChatClient(
|
||||
initialDashboardClient = dashboard, okHttpClient = http,
|
||||
callbackDispatcher = { Handler(Looper.getMainLooper()).post(it) }, scope = scope,
|
||||
).also { gatewayClient = it }
|
||||
val handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
|
||||
val spoken = java.util.concurrent.CopyOnWriteArrayList<String>()
|
||||
lateinit var vm: ChatViewModel
|
||||
compose.runOnUiThread {
|
||||
val app = compose.activity.application
|
||||
vm = ChatViewModel().also {
|
||||
it.initialize(null, handler)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setProfileMessageLoaderWithMode { profile, id, mode ->
|
||||
dashboard.getSessionMessages(id, profile, mode)
|
||||
}
|
||||
it.updateGatewayClient(gateway)
|
||||
viewModel = it
|
||||
}
|
||||
val audio = object : com.hermesandroid.relay.network.shared.VoiceAudioClient {
|
||||
override val route = com.hermesandroid.relay.data.VoiceAudioRoute.Standard
|
||||
override suspend fun transcribe(audioFile: java.io.File) = Result.success("")
|
||||
override suspend fun synthesize(text: String): Result<java.io.File> {
|
||||
spoken.add(text)
|
||||
// A short silent WAV exercises the production play/drain path without a provider.
|
||||
val pcm = ByteArray(3200)
|
||||
val header = java.nio.ByteBuffer.allocate(44).order(java.nio.ByteOrder.LITTLE_ENDIAN)
|
||||
.put("RIFF".toByteArray()).putInt(36 + pcm.size).put("WAVEfmt ".toByteArray())
|
||||
.putInt(16).putShort(1).putShort(1).putInt(16000).putInt(32000)
|
||||
.putShort(2).putShort(16).put("data".toByteArray()).putInt(pcm.size).array()
|
||||
val file = java.io.File.createTempFile("fixture-voice", ".wav", app.cacheDir)
|
||||
file.writeBytes(header + pcm)
|
||||
return Result.success(file)
|
||||
}
|
||||
}
|
||||
val player = com.hermesandroid.relay.audio.VoicePlayer(app).also { voicePlayer = it }
|
||||
val sfx = com.hermesandroid.relay.audio.VoiceSfxPlayer(app).also { voiceSfx = it }
|
||||
voiceViewModel = VoiceViewModel(app).also {
|
||||
it.initialize(
|
||||
voiceClient = com.hermesandroid.relay.network.relay.RelayVoiceClient(app, http, { null }, { null }),
|
||||
voiceAudioClient = audio, chatViewModel = vm,
|
||||
recorder = com.hermesandroid.relay.audio.VoiceRecorder(app, scope),
|
||||
player = player, sfxPlayer = sfx,
|
||||
)
|
||||
it.enterVoiceMode()
|
||||
}
|
||||
}
|
||||
compose.setContent {
|
||||
val messages by vm.messages.collectAsStateWithLifecycle()
|
||||
Text(messages.joinToString("\n") { it.content }, Modifier.testTag("voice-fixture-history"))
|
||||
}
|
||||
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
|
||||
compose.runOnUiThread { vm.sendMessage("Start background work.") }
|
||||
compose.waitUntil(10_000) { handler.messages.value.any { it.content == "Work started." } }
|
||||
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.STARTED)
|
||||
compose.waitUntil(15_000) { spoken.size == 3 }
|
||||
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
|
||||
compose.runOnUiThread { voiceViewModel?.onAppResumed() }
|
||||
compose.waitForIdle()
|
||||
assertEquals(listOf("Process finished.", "Watch matched.", "Delegated work finished."), spoken.toList())
|
||||
assertEquals(1, readFixtureJson(http, "$base/__fixture__/evidence")["entries"].let { it as JsonArray }.rpcCount("prompt.submit"))
|
||||
assertTrue(handler.messages.value.any { it.content == "Delegated work finished." })
|
||||
assertEquals("gateway", vm.streamingEndpoint)
|
||||
}
|
||||
|
||||
private fun JsonArray.rpcCount(method: String): Int = count { element ->
|
||||
val entry = element as? JsonObject ?: return@count false
|
||||
entry["kind"]?.jsonString() == "rpc" && entry["method"]?.jsonString() == method
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
v1.17.0 - Voice over other apps and clearer conversations
|
||||
|
||||
Dashboard-only connections now prepare the selected profile before Gateway readiness, fixing a remaining cold-start path that could stay on waking or waiting for Gateway until the app resumed or its network route changed.
|
||||
Use optional voice controls over other apps, with clear permission setup and an immediate Stop action. Answer Clarify batches one question at a time, with progress preserved across reconnects. Enjoy cleaner chat cards, recognizable profile names, and context previews that show what your connection supports. Phone control remains sideload-only.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
|
||||
v1.17.0 - 跨应用语音与更清晰的对话
|
||||
|
||||
可选的语音悬浮控件让你在使用其他应用时继续语音会话,提供清晰的权限说明和随时停止操作。逐题回答 Clarify 批量问题,重连后保留已确认的进度。聊天卡片更简洁,配置文件显示名称更易辨认,上下文预览准确说明当前连接支持发送哪些信息。设备控制仍仅限侧载版本。
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
{
|
||||
"schema": 3,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.17.0",
|
||||
"title": "Voice over other apps and clearer conversations",
|
||||
"date": "2026-09-13",
|
||||
"summary": "Google Play gains optional voice controls over other apps. Clarify questions, profile names, and chat context are easier to follow without losing confirmed answers or saved conversations.",
|
||||
"changes": [
|
||||
{"id": "play-voice-overlay", "kind": "added", "title": "Keep voice controls over other apps", "summary": "Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Stop voice from its controls or persistent notification; screen lock and permission loss end the session.", "highlight": true},
|
||||
{"id": "clarify-batches", "kind": "fixed", "title": "Answer Clarify questions one at a time", "summary": "Multi-question requests support separate choices and custom answers. Confirmed progress survives reconnects, and failed sends keep the current answer available.", "highlight": true},
|
||||
{"id": "chat-card-surfaces", "kind": "improved", "title": "Read cleaner chat cards", "summary": "Standalone response cards lose the extra outer bubble, assistant messages use quieter surfaces, and delivery status sits beside the timestamp."},
|
||||
{"id": "transport-context-preview", "kind": "fixed", "title": "See which context your chat can send", "summary": "Gateway previews mark phone status and turn context as unavailable. Automatic phone-status sharing is labeled for API-only chats, where it remains supported."},
|
||||
{"id": "profile-display-names", "kind": "fixed", "title": "Recognize profiles by their display names", "summary": "Profiles use their Hermes display names, and the resolved server default appears under its agent identity. Explicit profile choices and saved conversations are preserved.", "highlight": true}
|
||||
],
|
||||
"compatibility": [
|
||||
"Voice Overlay is now available in Google Play and sideload builds. Device Control remains sideload-only.",
|
||||
"Standard Chat and voice use upstream Hermes without requiring Hermes-Relay Plugin. Gateway phone-status delivery and automatic Android identification remain unavailable."
|
||||
],
|
||||
"playNotes": "Use optional voice controls over other apps, with clear permission setup and an immediate Stop action. Answer Clarify batches one question at a time, with progress preserved across reconnects. Enjoy cleaner chat cards, recognizable profile names, and context previews that show what your connection supports. Phone control remains sideload-only.",
|
||||
"sections": []
|
||||
},
|
||||
{
|
||||
"version": "1.16.1",
|
||||
"title": "Dashboard-only cold starts recover",
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
v1.16.1 - Dashboard-only cold starts recover
|
||||
v1.17.0 - Voice over other apps and clearer conversations
|
||||
|
||||
Summary
|
||||
* Dashboard-only connections can now prepare the selected profile and open Gateway chat without waiting for a background, resume, or network-route change.
|
||||
* Google Play gains optional voice controls over other apps. Clarify questions, profile names, and chat context are easier to follow without losing confirmed answers or saved conversations.
|
||||
|
||||
Highlights
|
||||
* Open Gateway chat from a Dashboard-only cold start — The selected profile's session directory starts before Gateway readiness, so it cannot wait on the same passive socket that depends on its result.
|
||||
* Keep voice controls over other apps — Start Voice Overlay from Voice Focus after granting microphone, notification, and display-over-other-apps access. Stop voice from its controls or persistent notification; screen lock and permission loss end the session.
|
||||
* Answer Clarify questions one at a time — Multi-question requests support separate choices and custom answers. Confirmed progress survives reconnects, and failed sends keep the current answer available.
|
||||
* Recognize profiles by their display names — Profiles use their Hermes display names, and the resolved server default appears under its agent identity. Explicit profile choices and saved conversations are preserved.
|
||||
|
||||
Improved
|
||||
* Read cleaner chat cards — Standalone response cards lose the extra outer bubble, assistant messages use quieter surfaces, and delivery status sits beside the timestamp.
|
||||
|
||||
Fixed
|
||||
* See which context your chat can send — Gateway previews mark phone status and turn context as unavailable. Automatic phone-status sharing is labeled for API-only chats, where it remains supported.
|
||||
|
||||
Compatibility
|
||||
* Standard Dashboard and Gateway chat continue to use current upstream Hermes without requiring the optional Hermes-Relay Plugin.
|
||||
* Hermes-Relay Plugin 1.11.2 remains the matching optional release for Relay tools.
|
||||
* Voice Overlay is now available in Google Play and sideload builds. Device Control remains sideload-only.
|
||||
* Standard Chat and voice use upstream Hermes without requiring Hermes-Relay Plugin. Gateway phone-status delivery and automatic Android identification remain unavailable.
|
||||
|
||||
+135
-63
@@ -9,18 +9,23 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.setGatewayKeepAlive
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Foreground service that holds the app process up so work the user already
|
||||
@@ -48,16 +53,16 @@ import kotlinx.coroutines.launch
|
||||
*
|
||||
* The service's only job is to hold the process in the foreground. The socket
|
||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||
* idle-close timer while retention is required. On task removal (user swipes the app
|
||||
* away) the ViewModel + socket die with the process, so the service stops
|
||||
* itself rather than leave a notification that lies about being connected.
|
||||
* idle-close timer while retention is required. Task removal releases local
|
||||
* foreground protection; it does not terminate server-owned work or assume
|
||||
* that removing a task kills the application process.
|
||||
*
|
||||
* # Android 15 watchdog
|
||||
* # Foreground-start obligation (Android 8+)
|
||||
*
|
||||
* On target SDK 35 any intent to a service that declares a foregroundServiceType
|
||||
* must call `startForeground` within 5s — so [onStartCommand] always does that
|
||||
* first, before branching on the action. Shutdown goes through [stop]
|
||||
* (`stopService`) to bypass [onStartCommand] entirely.
|
||||
* An accepted startForegroundService must promote promptly, even if demand
|
||||
* disappears before delivery. Never stopService a pending start: Android 12
|
||||
* also treats teardown before promotion as a foreground-start failure.
|
||||
* Main-thread demand is coalesced until onStartCommand acknowledges the start.
|
||||
*/
|
||||
class GatewayKeepAliveService : Service() {
|
||||
companion object {
|
||||
@@ -67,47 +72,76 @@ class GatewayKeepAliveService : Service() {
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
|
||||
private const val EXTRA_PERSISTENT = "persistent"
|
||||
private const val EXTRA_ACTIVE_TURNS = "active_turns"
|
||||
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
|
||||
@Volatile private var runningInstance: GatewayKeepAliveService? = null
|
||||
private const val EXTRA_START_TOKEN = "start_token"
|
||||
private var runningInstance: GatewayKeepAliveService? = null
|
||||
private var pendingStart: String? = null
|
||||
private var desiredPersistent = false
|
||||
private var desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
|
||||
private var wasForeground = false
|
||||
private var taskRemoved = false
|
||||
@Volatile private var persistentToken: String? = null
|
||||
// Preference writes must survive service teardown, but never process death.
|
||||
private val preferenceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
@MainThread
|
||||
fun update(
|
||||
context: Context,
|
||||
persistent: Boolean,
|
||||
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
|
||||
appForeground: Boolean = true,
|
||||
) {
|
||||
if (!persistent && !activeTurns.required) {
|
||||
stop(context)
|
||||
return
|
||||
checkMainThread()
|
||||
if (appForeground && !wasForeground) taskRemoved = false
|
||||
wasForeground = appForeground
|
||||
if (persistent != desiredPersistent) {
|
||||
persistentToken = if (persistent) UUID.randomUUID().toString() else null
|
||||
}
|
||||
runningInstance?.let { service ->
|
||||
service.applyState(persistent, activeTurns)
|
||||
service.startForegroundNotification()
|
||||
desiredPersistent = persistent
|
||||
desiredTurns = activeTurns
|
||||
// Keep the accepted start alive until Android delivers its command.
|
||||
if (pendingStart != null) return
|
||||
runningInstance?.let {
|
||||
it.reconcile()
|
||||
return
|
||||
}
|
||||
if (taskRemoved || !appForeground || (!persistent && !activeTurns.required)) return
|
||||
val token = UUID.randomUUID().toString()
|
||||
pendingStart = token
|
||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||
.setAction(ACTION_REFRESH)
|
||||
.putExtra(EXTRA_PERSISTENT, persistent)
|
||||
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
|
||||
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
.putExtra(EXTRA_START_TOKEN, token)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
pendingStart = null
|
||||
Log.w(TAG, "Foreground service launch rejected; retaining server-owned work", e)
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun stop(context: Context) {
|
||||
// stopService() bypasses onStartCommand, so a "please shut down"
|
||||
// never trips the Android 15 foreground-start watchdog.
|
||||
context.applicationContext.stopService(
|
||||
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
|
||||
)
|
||||
update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(), wasForeground)
|
||||
}
|
||||
|
||||
private fun checkMainThread() {
|
||||
check(Looper.myLooper() == Looper.getMainLooper())
|
||||
}
|
||||
|
||||
internal fun resetForTest() {
|
||||
runningInstance = null
|
||||
pendingStart = null
|
||||
desiredPersistent = false
|
||||
desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
|
||||
persistentToken = null
|
||||
wasForeground = false
|
||||
taskRemoved = false
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var persistent = false
|
||||
private var activeTurns = 0
|
||||
private var waitingSessions = 0
|
||||
@@ -116,45 +150,63 @@ class GatewayKeepAliveService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
runningInstance = this
|
||||
// No datastore, socket, coroutine or other owner work ahead of promotion.
|
||||
// A cold stale notification action has no accepted foreground start.
|
||||
if (pendingStart != null) {
|
||||
applyState(desiredPersistent, desiredTurns)
|
||||
startForegroundNotification()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val token = intent?.getStringExtra(EXTRA_START_TOKEN)
|
||||
if (intent?.action == ACTION_REFRESH) {
|
||||
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
|
||||
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
|
||||
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
|
||||
.coerceIn(0, activeTurns)
|
||||
}
|
||||
startForegroundNotification()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
persistent = false
|
||||
if (activeTurns == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
} else {
|
||||
startForegroundNotification()
|
||||
applyState(desiredPersistent, desiredTurns)
|
||||
// Also promote reused service instances before acknowledging the start.
|
||||
// A delivered start still owes promotion if process-local demand
|
||||
// was lost or its token is stale. Never replay its old demand.
|
||||
if (startForegroundNotification()) {
|
||||
if (token == pendingStart) pendingStart = null
|
||||
if (pendingStart == null) {
|
||||
runningInstance = this
|
||||
reconcile()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
} else if (intent?.action == ACTION_STOP &&
|
||||
intent.data?.lastPathSegment == persistentToken && persistentToken != null
|
||||
) {
|
||||
val actionToken = persistentToken
|
||||
val context = applicationContext
|
||||
preferenceScope.launch {
|
||||
try {
|
||||
context.relayDataStore.edit { preferences ->
|
||||
// Recheck inside the serialized edit; an old action must
|
||||
// not undo a subsequent disable/re-enable cycle.
|
||||
if (persistentToken == actionToken) preferences[KEY_GATEWAY_KEEP_ALIVE] = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not disable persistent connection", e)
|
||||
}
|
||||
}
|
||||
// The preference collector reconciles current active-turn demand
|
||||
// after persistence. Never stop from the notification's old snapshot.
|
||||
}
|
||||
if (runningInstance !== this && pendingStart == null) stopSelfResult(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
// The socket lives in the ViewModel, which dies when the task is
|
||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||
ActiveTurnKeepAliveRegistry.releaseAll()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
taskRemoved = true
|
||||
// Leases belong to chat owners. Keep them intact so a surviving
|
||||
// process can protect unfinished turns again when the user returns.
|
||||
// A queued new start still owes Android promotion before retirement.
|
||||
if (pendingStart == null) retire()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (runningInstance === this) runningInstance = null
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -164,7 +216,23 @@ class GatewayKeepAliveService : Service() {
|
||||
// this foreground service (and its Gateway socket) alive. Re-post the
|
||||
// existing notification so its localized title/body follow the new
|
||||
// application resources without restarting either owner.
|
||||
startForegroundNotification()
|
||||
if (runningInstance === this) reconcile()
|
||||
}
|
||||
|
||||
private fun reconcile() {
|
||||
if (taskRemoved || (!desiredPersistent && !desiredTurns.required)) {
|
||||
retire()
|
||||
} else {
|
||||
applyState(desiredPersistent, desiredTurns)
|
||||
startForegroundNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private fun retire() {
|
||||
if (runningInstance === this) runningInstance = null
|
||||
// Let Android remove the foreground notification with service teardown.
|
||||
// Do not demote an instance while another start may be queued for it.
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
@@ -181,10 +249,10 @@ class GatewayKeepAliveService : Service() {
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
// check is finicky about correlating the runtime type arg with the manifest.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
private fun startForegroundNotification(): Boolean {
|
||||
try {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
@@ -194,9 +262,12 @@ class GatewayKeepAliveService : Service() {
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
|
||||
stopSelf()
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Foreground notification failed; retaining server-owned work", e)
|
||||
pendingStart = null
|
||||
retire()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +279,7 @@ class GatewayKeepAliveService : Service() {
|
||||
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
|
||||
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
.setData(Uri.parse("hermes-relay://keep-alive/$persistentToken"))
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
val (title, body) = when {
|
||||
|
||||
@@ -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
|
||||
|
||||
+331
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-5
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.3",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.11.2",
|
||||
"version": "1.11.3",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: hermes-relay
|
||||
# Temporary v1 shim for Hermes installers that reject manifests the runtime supports; see docs/project/TODO.md.
|
||||
manifest_version: 1
|
||||
api_version: 1
|
||||
version: 1.11.2
|
||||
version: 1.11.3
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
license: MIT
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.11.2"
|
||||
__version__ = "1.11.3"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
+37
@@ -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"}}
|
||||
]
|
||||
}]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user