Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 11:55:18 +00:00
22 changed files with 1356 additions and 116 deletions
+3
View File
@@ -188,6 +188,9 @@ jobs:
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
--tests com.hermesandroid.relay.data.AppLanguageTest \
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
--tests com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest \
--tests com.hermesandroid.relay.viewmodel.VoiceInboundCompletionTest \
--tests com.hermesandroid.relay.voice.VoiceViewModelBargeInTest \
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
+5
View File
@@ -6,6 +6,11 @@ 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
+2 -2
View File
@@ -458,8 +458,8 @@ dependencies {
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
// Renders real composables on the JVM at an exact canvas — no device, no
// status bar, no clipping. See StoreScreenshotTest.
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.73.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.74.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.74.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
@@ -0,0 +1,111 @@
package com.hermesandroid.relay.network.upstream
import android.app.ActivityManager
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.SystemClock
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.test.platform.app.InstrumentationRegistry
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.data.relayDataStore
import com.hermesandroid.relay.data.setGatewayKeepAlive
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** Real ActivityManager/notification lifecycle; no Gateway or personal data. */
class GatewayKeepAliveServiceInstrumentedTest {
@get:Rule val activity = createAndroidComposeRule<ComponentActivity>()
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context get() = instrumentation.targetContext
@Before fun setup() {
if (Build.VERSION.SDK_INT >= 33) {
instrumentation.uiAutomation.executeShellCommand(
"pm grant ${context.packageName} android.permission.POST_NOTIFICATIONS",
).close()
}
runBlocking { context.setGatewayKeepAlive(false) }
}
@After fun cleanup() {
instrumentation.runOnMainSync {
GatewayKeepAliveService.stop(context)
ActiveTurnKeepAliveRegistry.releaseAll()
}
await("service shutdown") { serviceState() == null }
runBlocking { context.setGatewayKeepAlive(false) }
}
@Test fun immediateStopsAndOverlappingStartsSurviveThePlatformWatchdog() {
// All changes happen before Android can dispatch onCreate/onStartCommand.
instrumentation.runOnMainSync {
repeat(25) {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
GatewayKeepAliveService.stop(context)
}
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
}
await("foreground promotion") { serviceState()?.foreground == true }
instrumentation.runOnMainSync { GatewayKeepAliveService.stop(context) }
await("settled shutdown") { serviceState() == null }
// Observation window, not a startup workaround: an asynchronous system
// foreground-start crash fails the instrumentation process during it.
CountDownLatch(1).await(12, TimeUnit.SECONDS)
assertTrue(serviceState() == null)
}
@Test fun notificationDisablesAlwaysOnWhileANewerTurnStaysProtected() {
runBlocking { context.setGatewayKeepAlive(true) }
instrumentation.runOnMainSync {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
}
val manager = context.getSystemService(NotificationManager::class.java)
await("persistent notification") {
manager.activeNotifications.any { it.id == GatewayKeepAliveService.NOTIFICATION_ID }
}
val action = manager.activeNotifications.single {
it.id == GatewayKeepAliveService.NOTIFICATION_ID
}.notification.actions.single().actionIntent
instrumentation.runOnMainSync {
ActiveTurnKeepAliveRegistry.acquire("fixture::profile-a::session")
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
}
action.send()
await("persisted notification action") {
runBlocking { context.relayDataStore.data.first()[KEY_GATEWAY_KEEP_ALIVE] == false }
}
instrumentation.runOnMainSync {
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
}
assertTrue(serviceState()?.foreground == true)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
await("active-turn notification without always-on action") {
manager.activeNotifications.singleOrNull {
it.id == GatewayKeepAliveService.NOTIFICATION_ID
}?.notification?.let { it.actions.isNullOrEmpty() } == true
}
}
@Suppress("DEPRECATION")
private fun serviceState(): ActivityManager.RunningServiceInfo? =
context.getSystemService(ActivityManager::class.java).getRunningServices(100)
.singleOrNull { it.service.className == GatewayKeepAliveService::class.java.name }
private fun await(description: String, condition: () -> Boolean) {
val deadline = SystemClock.uptimeMillis() + 10_000
while (!condition() && SystemClock.uptimeMillis() < deadline) {
instrumentation.waitForIdleSync()
SystemClock.sleep(20)
}
assertTrue(description, condition())
}
}
@@ -62,9 +62,17 @@ class GatewayExternalFixtureInstrumentedTest {
private var gatewayScope: CoroutineScope? = null
private var gatewayClient: GatewayChatClient? = null
private var viewModel: ChatViewModel? = null
private var voiceViewModel: VoiceViewModel? = null
private var voicePlayer: com.hermesandroid.relay.audio.VoicePlayer? = null
private var voiceSfx: com.hermesandroid.relay.audio.VoiceSfxPlayer? = null
@After
fun tearDown() {
compose.runOnUiThread {
voiceViewModel?.exitVoiceMode()
voicePlayer?.release()
voiceSfx?.release()
}
viewModel?.updateGatewayClient(null)
gatewayClient?.shutdown()
gatewayScope?.cancel()
@@ -250,6 +258,80 @@ class GatewayExternalFixtureInstrumentedTest {
assertEquals("gateway", vm.streamingEndpoint)
}
@Test
fun unsolicitedVoiceCompletions_surviveActivityPauseWithoutHistorySpeech() {
val base = InstrumentationRegistry.getArguments().getString(ARG_FIXTURE_BASE_URL)
?.trim()?.trimEnd('/')
assumeTrue("Pass the unsolicited_voice_completions fixture URL", !base.isNullOrBlank())
requireNotNull(base)
val http = OkHttpClient.Builder().callTimeout(10, TimeUnit.SECONDS).build()
assertEquals("unsolicited_voice_completions", readFixtureJson(http, "$base/__fixture__/state")["scenario"]?.jsonString())
val dashboard = DashboardApiClient(base, http)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO).also { gatewayScope = it }
val gateway = GatewayChatClient(
initialDashboardClient = dashboard, okHttpClient = http,
callbackDispatcher = { Handler(Looper.getMainLooper()).post(it) }, scope = scope,
).also { gatewayClient = it }
val handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
val spoken = java.util.concurrent.CopyOnWriteArrayList<String>()
lateinit var vm: ChatViewModel
compose.runOnUiThread {
val app = compose.activity.application
vm = ChatViewModel().also {
it.initialize(null, handler)
it.streamingEndpoint = "gateway"
it.setProfileMessageLoaderWithMode { profile, id, mode ->
dashboard.getSessionMessages(id, profile, mode)
}
it.updateGatewayClient(gateway)
viewModel = it
}
val audio = object : com.hermesandroid.relay.network.shared.VoiceAudioClient {
override val route = com.hermesandroid.relay.data.VoiceAudioRoute.Standard
override suspend fun transcribe(audioFile: java.io.File) = Result.success("")
override suspend fun synthesize(text: String): Result<java.io.File> {
spoken.add(text)
// A short silent WAV exercises the production play/drain path without a provider.
val pcm = ByteArray(3200)
val header = java.nio.ByteBuffer.allocate(44).order(java.nio.ByteOrder.LITTLE_ENDIAN)
.put("RIFF".toByteArray()).putInt(36 + pcm.size).put("WAVEfmt ".toByteArray())
.putInt(16).putShort(1).putShort(1).putInt(16000).putInt(32000)
.putShort(2).putShort(16).put("data".toByteArray()).putInt(pcm.size).array()
val file = java.io.File.createTempFile("fixture-voice", ".wav", app.cacheDir)
file.writeBytes(header + pcm)
return Result.success(file)
}
}
val player = com.hermesandroid.relay.audio.VoicePlayer(app).also { voicePlayer = it }
val sfx = com.hermesandroid.relay.audio.VoiceSfxPlayer(app).also { voiceSfx = it }
voiceViewModel = VoiceViewModel(app).also {
it.initialize(
voiceClient = com.hermesandroid.relay.network.relay.RelayVoiceClient(app, http, { null }, { null }),
voiceAudioClient = audio, chatViewModel = vm,
recorder = com.hermesandroid.relay.audio.VoiceRecorder(app, scope),
player = player, sfxPlayer = sfx,
)
it.enterVoiceMode()
}
}
compose.setContent {
val messages by vm.messages.collectAsStateWithLifecycle()
Text(messages.joinToString("\n") { it.content }, Modifier.testTag("voice-fixture-history"))
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
compose.runOnUiThread { vm.sendMessage("Start background work.") }
compose.waitUntil(10_000) { handler.messages.value.any { it.content == "Work started." } }
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.STARTED)
compose.waitUntil(15_000) { spoken.size == 3 }
compose.activityRule.scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
compose.runOnUiThread { voiceViewModel?.onAppResumed() }
compose.waitForIdle()
assertEquals(listOf("Process finished.", "Watch matched.", "Delegated work finished."), spoken.toList())
assertEquals(1, readFixtureJson(http, "$base/__fixture__/evidence")["entries"].let { it as JsonArray }.rpcCount("prompt.submit"))
assertTrue(handler.messages.value.any { it.content == "Delegated work finished." })
assertEquals("gateway", vm.streamingEndpoint)
}
private fun JsonArray.rpcCount(method: String): Int = count { element ->
val entry = element as? JsonObject ?: return@count false
entry["kind"]?.jsonString() == "rpc" && entry["method"]?.jsonString() == method
@@ -9,18 +9,23 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.content.res.Configuration
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.os.Looper
import android.util.Log
import androidx.annotation.MainThread
import androidx.core.app.NotificationCompat
import androidx.datastore.preferences.core.edit
import com.hermesandroid.relay.MainActivity
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.setGatewayKeepAlive
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.data.relayDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Foreground service that holds the app process up so work the user already
@@ -48,16 +53,16 @@ import kotlinx.coroutines.launch
*
* The service's only job is to hold the process in the foreground. The socket
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
* idle-close timer while retention is required. On task removal (user swipes the app
* away) the ViewModel + socket die with the process, so the service stops
* itself rather than leave a notification that lies about being connected.
* idle-close timer while retention is required. Task removal releases local
* foreground protection; it does not terminate server-owned work or assume
* that removing a task kills the application process.
*
* # Android 15 watchdog
* # Foreground-start obligation (Android 8+)
*
* On target SDK 35 any intent to a service that declares a foregroundServiceType
* must call `startForeground` within 5s — so [onStartCommand] always does that
* first, before branching on the action. Shutdown goes through [stop]
* (`stopService`) to bypass [onStartCommand] entirely.
* An accepted startForegroundService must promote promptly, even if demand
* disappears before delivery. Never stopService a pending start: Android 12
* also treats teardown before promotion as a foreground-start failure.
* Main-thread demand is coalesced until onStartCommand acknowledges the start.
*/
class GatewayKeepAliveService : Service() {
companion object {
@@ -67,47 +72,76 @@ class GatewayKeepAliveService : Service() {
const val NOTIFICATION_ID = 4713
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
private const val ACTION_REFRESH = "com.hermesandroid.relay.gateway.KEEPALIVE_REFRESH"
private const val EXTRA_PERSISTENT = "persistent"
private const val EXTRA_ACTIVE_TURNS = "active_turns"
private const val EXTRA_WAITING_SESSIONS = "waiting_sessions"
@Volatile private var runningInstance: GatewayKeepAliveService? = null
private const val EXTRA_START_TOKEN = "start_token"
private var runningInstance: GatewayKeepAliveService? = null
private var pendingStart: String? = null
private var desiredPersistent = false
private var desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
private var wasForeground = false
private var taskRemoved = false
@Volatile private var persistentToken: String? = null
// Preference writes must survive service teardown, but never process death.
private val preferenceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@MainThread
fun update(
context: Context,
persistent: Boolean,
activeTurns: ActiveTurnKeepAliveRegistry.Snapshot,
appForeground: Boolean = true,
) {
if (!persistent && !activeTurns.required) {
stop(context)
return
checkMainThread()
if (appForeground && !wasForeground) taskRemoved = false
wasForeground = appForeground
if (persistent != desiredPersistent) {
persistentToken = if (persistent) UUID.randomUUID().toString() else null
}
runningInstance?.let { service ->
service.applyState(persistent, activeTurns)
service.startForegroundNotification()
desiredPersistent = persistent
desiredTurns = activeTurns
// Keep the accepted start alive until Android delivers its command.
if (pendingStart != null) return
runningInstance?.let {
it.reconcile()
return
}
if (taskRemoved || !appForeground || (!persistent && !activeTurns.required)) return
val token = UUID.randomUUID().toString()
pendingStart = token
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
.setAction(ACTION_REFRESH)
.putExtra(EXTRA_PERSISTENT, persistent)
.putExtra(EXTRA_ACTIVE_TURNS, activeTurns.activeTurnCount)
.putExtra(EXTRA_WAITING_SESSIONS, activeTurns.waitingSessionCount)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
context.applicationContext.startService(intent)
.putExtra(EXTRA_START_TOKEN, token)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.applicationContext.startForegroundService(intent)
} else {
context.applicationContext.startService(intent)
}
} catch (e: Exception) {
pendingStart = null
Log.w(TAG, "Foreground service launch rejected; retaining server-owned work", e)
}
}
@MainThread
fun stop(context: Context) {
// stopService() bypasses onStartCommand, so a "please shut down"
// never trips the Android 15 foreground-start watchdog.
context.applicationContext.stopService(
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
)
update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(), wasForeground)
}
private fun checkMainThread() {
check(Looper.myLooper() == Looper.getMainLooper())
}
internal fun resetForTest() {
runningInstance = null
pendingStart = null
desiredPersistent = false
desiredTurns = ActiveTurnKeepAliveRegistry.Snapshot()
persistentToken = null
wasForeground = false
taskRemoved = false
}
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var persistent = false
private var activeTurns = 0
private var waitingSessions = 0
@@ -116,45 +150,63 @@ class GatewayKeepAliveService : Service() {
override fun onCreate() {
super.onCreate()
runningInstance = this
// No datastore, socket, coroutine or other owner work ahead of promotion.
// A cold stale notification action has no accepted foreground start.
if (pendingStart != null) {
applyState(desiredPersistent, desiredTurns)
startForegroundNotification()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val token = intent?.getStringExtra(EXTRA_START_TOKEN)
if (intent?.action == ACTION_REFRESH) {
persistent = intent.getBooleanExtra(EXTRA_PERSISTENT, false)
activeTurns = intent.getIntExtra(EXTRA_ACTIVE_TURNS, 0).coerceAtLeast(0)
waitingSessions = intent.getIntExtra(EXTRA_WAITING_SESSIONS, 0)
.coerceIn(0, activeTurns)
}
startForegroundNotification()
if (intent?.action == ACTION_STOP) {
Log.i(TAG, "ACTION_STOP → user disabled continuous background connection")
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
persistent = false
if (activeTurns == 0) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
startForegroundNotification()
applyState(desiredPersistent, desiredTurns)
// Also promote reused service instances before acknowledging the start.
// A delivered start still owes promotion if process-local demand
// was lost or its token is stale. Never replay its old demand.
if (startForegroundNotification()) {
if (token == pendingStart) pendingStart = null
if (pendingStart == null) {
runningInstance = this
reconcile()
}
}
return START_NOT_STICKY
} else if (intent?.action == ACTION_STOP &&
intent.data?.lastPathSegment == persistentToken && persistentToken != null
) {
val actionToken = persistentToken
val context = applicationContext
preferenceScope.launch {
try {
context.relayDataStore.edit { preferences ->
// Recheck inside the serialized edit; an old action must
// not undo a subsequent disable/re-enable cycle.
if (persistentToken == actionToken) preferences[KEY_GATEWAY_KEEP_ALIVE] = false
}
} catch (e: Exception) {
Log.w(TAG, "Could not disable persistent connection", e)
}
}
// The preference collector reconciles current active-turn demand
// after persistence. Never stop from the notification's old snapshot.
}
if (runningInstance !== this && pendingStart == null) stopSelfResult(startId)
return START_NOT_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
// The socket lives in the ViewModel, which dies when the task is
// removed — keeping the notification would be a lie. Stop cleanly.
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
ActiveTurnKeepAliveRegistry.releaseAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
taskRemoved = true
// Leases belong to chat owners. Keep them intact so a surviving
// process can protect unfinished turns again when the user returns.
// A queued new start still owes Android promotion before retirement.
if (pendingStart == null) retire()
}
override fun onDestroy() {
if (runningInstance === this) runningInstance = null
scope.cancel()
super.onDestroy()
}
@@ -164,7 +216,23 @@ class GatewayKeepAliveService : Service() {
// this foreground service (and its Gateway socket) alive. Re-post the
// existing notification so its localized title/body follow the new
// application resources without restarting either owner.
startForegroundNotification()
if (runningInstance === this) reconcile()
}
private fun reconcile() {
if (taskRemoved || (!desiredPersistent && !desiredTurns.required)) {
retire()
} else {
applyState(desiredPersistent, desiredTurns)
startForegroundNotification()
}
}
private fun retire() {
if (runningInstance === this) runningInstance = null
// Let Android remove the foreground notification with service teardown.
// Do not demote an instance while another start may be queued for it.
stopSelf()
}
private fun applyState(
@@ -181,10 +249,10 @@ class GatewayKeepAliveService : Service() {
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
// check is finicky about correlating the runtime type arg with the manifest.
@SuppressLint("ForegroundServiceType")
private fun startForegroundNotification() {
ensureChannel()
val notification = buildNotification()
private fun startForegroundNotification(): Boolean {
try {
ensureChannel()
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
@@ -194,9 +262,12 @@ class GatewayKeepAliveService : Service() {
} else {
startForeground(NOTIFICATION_ID, notification)
}
} catch (t: Throwable) {
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
stopSelf()
return true
} catch (e: Exception) {
Log.w(TAG, "Foreground notification failed; retaining server-owned work", e)
pendingStart = null
retire()
return false
}
}
@@ -208,6 +279,7 @@ class GatewayKeepAliveService : Service() {
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
.setData(Uri.parse("hermes-relay://keep-alive/$persistentToken"))
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
val (title, body) = when {
@@ -780,6 +780,9 @@ class ChatViewModel : ViewModel() {
/** Callback to persist session ID — set by RelayApp */
var onSessionChanged: ((String?) -> Unit)? = null
/** Capture a voice-session receipt at live admission, never during history replay. */
internal var gatewayInboundSpeechReceiver: (() -> ((String) -> Unit)?)? = null
var onFreshDraftSelected: ((String?, SessionTransport) -> Unit)? = null
/**
@@ -3070,6 +3073,7 @@ class ChatViewModel : ViewModel() {
var boundHandle: ActiveTurnHandle? = null
var inputTokens: Int? = null
var outputTokens: Int? = null
var speechReceiver: ((String) -> Unit)? = null
fun ownsTranscriptSession(): Boolean =
chatHandler === handler && handler.currentSessionId.value == storedSessionId
@@ -3148,9 +3152,9 @@ class ChatViewModel : ViewModel() {
onTurnComplete = {
if (acceptsEvent()) handler.onTurnComplete(messageId)
},
// Server-initiated turns already take the bounded durable-history
// reconcile below on every completion.
onReconcileRequired = { },
// Recovery can settle a partial live bubble before durable history
// arrives. That history repairs Chat, but is not a speech receipt.
onReconcileRequired = { speechReceiver = null },
onComplete = {
val canWriteTranscript = acceptsEvent()
val expectedText = handler.messages.value
@@ -3168,7 +3172,9 @@ class ChatViewModel : ViewModel() {
} else {
finalizeTurnSideEffects(handler, messageId)
AppAnalytics.onStreamComplete(inputTokens, outputTokens)
speechReceiver?.invoke(expectedText.orEmpty())
}
speechReceiver = null
scheduleGatewayHistoryReconcile(
storedSessionId = storedSessionId,
expectedAssistantText = expectedText,
@@ -3271,6 +3277,9 @@ class ChatViewModel : ViewModel() {
baselineAssistantCount = handler.messages.value.count {
it.role == MessageRole.ASSISTANT && !it.clientOnly
}
if (queuedRecovery == null) {
speechReceiver = gatewayInboundSpeechReceiver?.invoke()
}
boundHandle = handle
accepted = true
activeStream = handle
@@ -2870,12 +2870,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// this Home-Assistant-class persistent-connection use case). Mirrors
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
viewModelScope.launch {
combine(gatewayKeepAlive, ActiveTurnKeepAliveRegistry.snapshot) { persistent, turns ->
persistent to turns
}.distinctUntilChanged().collect { (persistent, turns) ->
combine(
gatewayKeepAlive,
ActiveTurnKeepAliveRegistry.snapshot,
AppForegroundTracker.isForeground,
) { persistent, turns, foreground ->
Triple(persistent, turns, foreground)
}.distinctUntilChanged().collect { (persistent, turns, foreground) ->
upstreamTransport.applyGatewayKeepAlive(persistent || turns.required)
val ctx = getApplication<Application>()
runCatching { GatewayKeepAliveService.update(ctx, persistent, turns) }
GatewayKeepAliveService.update(ctx, persistent, turns, foreground)
}
}
}
@@ -900,6 +900,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* we don't re-process older turns when the history list updates. */
private var assistantSpeechCursor: AssistantSpeechCursor? = null
private var voiceTurnSessionFence: VoiceTurnSessionFence? = null
private var inboundSpeechGeneration = 0L
private var inboundSpeechOwner: Pair<ConversationBinding, String?>? = null
private var inboundSpeechObserver: Job? = null
private val pendingInboundSpeech = ArrayDeque<Pair<() -> Boolean, String>>()
private var inboundSpeechPlaying = false
private var sentenceBuffer: StringBuilder = StringBuilder()
private val realtimeSpeechCoalescer = BalancedRealtimeTtsCoalescer()
private val brokeredToolSpeechKeys = mutableSetOf<String>()
@@ -1199,9 +1204,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
) {
cancelStandardSpeechStream("voice dependencies rewired")
retireInboundSpeech()
this.chatViewModel?.gatewayInboundSpeechReceiver = null
this.voiceClient = voiceClient
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
this.chatViewModel = chatViewModel
chatViewModel.gatewayInboundSpeechReceiver = ::captureInboundSpeechReceiver
this.recorder = recorder
this.player = player
this.realtimePcmPlayer = realtimePcmPlayer
@@ -1537,6 +1545,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
*/
private fun applyVoiceSettingsSnapshot(settings: com.hermesandroid.relay.data.VoiceSettings) {
val nextEngineMode = VoiceEngineMode.fromStorage(settings.engineMode)
if (voiceEngineMode != nextEngineMode) {
if (inboundSpeechPlaying) interruptSpeaking(cancelActiveTurn = false)
else retireInboundSpeech()
}
val finalAnswerPolicyChanged = finalAnswerOnly != settings.finalAnswerOnly
val realtimeSelectionChanged =
realtimeModel != settings.realtimeModel || realtimeVoice != settings.realtimeVoice
@@ -1675,6 +1687,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
backgroundRun = if (orphanedRun != null) null else it.backgroundRun,
)
}
if (freshEntry) bindInboundSpeechOwner()
prewarmRealtimeSession()
}
@@ -1885,6 +1898,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
fun exitVoiceMode() {
retireInboundSpeech()
cancelPendingListeningStart()
// Idempotence guard — added 2026-04-21 after logcat showed the voice-
// exit chime playing on every Add-connection tap.
@@ -2291,6 +2305,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* listening turn; until then, idle queue-drain callbacks are ignored.
*/
fun pauseContinuousMode() {
retireInboundSpeech()
cancelPendingListeningStart()
continuousLoopArmed = false
continuousListeningPaused = _uiState.value.interactionMode == InteractionMode.Continuous
@@ -2427,6 +2442,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* new turn on the next mic tap).
*/
fun interruptSpeaking(cancelActiveTurn: Boolean = true): Job? {
retireInboundSpeech()
cancelPendingListeningStart()
Log.i(
TAG,
@@ -3584,6 +3600,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
voiceTurnSessionFence?.bindSubmittedUser(submittedUserUiKey)
if (inboundSpeechOwner == null) bindInboundSpeechOwner()
beginBargeInTurnIfEnabled()
startStreamObserver(chatVm)
}
@@ -4738,6 +4755,99 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
standardSpeechStreamBargeInStarted.set(false)
}
/** The voice overlay owns live completions only for the conversation it entered. */
private fun bindInboundSpeechOwner() {
val chat = chatViewModel ?: return
retireInboundSpeech()
inboundSpeechOwner = chat.conversationBinding.value to chat.currentSessionId.value
inboundSpeechObserver = viewModelScope.launch {
combine(chat.conversationBinding, chat.currentSessionId, _uiState) { binding, id, state ->
Triple(binding, id, state)
}.collect { (binding, id, _) ->
val owner = inboundSpeechOwner ?: return@collect
if (owner != (binding to id)) {
// The first voice submission may create/adopt a durable session.
if (owner.second == null && owner.first.contextKey == binding.contextKey &&
voiceTurnSessionFence?.accepts(id, chat.messages.value) == true
) {
inboundSpeechOwner = binding to id
} else {
val wasPlaying = inboundSpeechPlaying
retireInboundSpeech()
if (wasPlaying) interruptSpeaking(cancelActiveTurn = false)
return@collect
}
}
drainInboundSpeech()
}
}
}
private fun retireInboundSpeech() {
inboundSpeechGeneration++
inboundSpeechOwner = null
inboundSpeechObserver?.cancel()
inboundSpeechObserver = null
pendingInboundSpeech.clear()
inboundSpeechPlaying = false
}
/** Called on Main before Chat installs the new live assistant placeholder. */
private fun captureInboundSpeechReceiver(): ((String) -> Unit)? {
val chat = chatViewModel ?: return null
val owner = inboundSpeechOwner ?: return null
val generation = inboundSpeechGeneration
fun current(): Boolean =
generation == inboundSpeechGeneration && _uiState.value.voiceMode &&
voiceEngineMode == VoiceEngineMode.HermesVoiceOutput &&
inboundSpeechOwner == owner &&
owner == (chat.conversationBinding.value to chat.currentSessionId.value)
if (owner.second == null || !current()) return null
// A fast unsolicited start can overtake combine's final local-turn snapshot.
// Consume that final snapshot before the new placeholder exists so the two
// speech paths cannot narrate the same assistant bubble.
if (streamObserverJob?.isActive == true && !chat.isStreaming.value) {
assistantSpeechCursor?.let { cursor ->
consumeAssistantSpeech(cursor.poll(chat.messages.value), runActive = false)
}
streamObserverJob?.cancel()
}
var consumed = false
return { text ->
if (!consumed) {
consumed = true
if (current() && sanitizeForTts(text).isNotBlank()) {
pendingInboundSpeech.addLast(::current to text)
drainInboundSpeech()
}
}
}
}
/** Wait for a capture/earlier reply to settle; use the configured output renderer. */
private fun drainInboundSpeech(): Boolean {
if (pendingInboundSpeech.isEmpty()) return false
if (_uiState.value.state != VoiceState.Idle || isMicCaptureActive() ||
streamObserverJob?.isActive == true ||
chatViewModel?.isStreaming?.value == true ||
!agentAudioCompletionDecision().finishNow
) return false
while (pendingInboundSpeech.isNotEmpty()) {
val (current, text) = pendingInboundSpeech.removeAt(0)
if (!current()) continue
cancelPendingListeningStart()
streamComplete = true
inboundSpeechPlaying = true
resetTtsTurnStats()
clearSpokenChunksState()
speakSettledFinalAnswer(text)
scheduleAgentAudioCompletionCheck()
return true
}
return false
}
/**
* Observe every assistant bubble created by the active Hermes run. A tool
* turn can finalize one bubble while the run is still active and later
@@ -4770,40 +4880,43 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return@collect
}
val batch = cursor.poll(messages)
if (finalAnswerOnly) {
if (batch.deltas.isNotEmpty()) {
onVisualStreamDelta(batch.aggregateText)
}
} else {
batch.deltas.forEach { update ->
if (update.startsNewBubble) {
beginAssistantSpeechBubble()
}
onStreamDelta(update.text, batch.aggregateText)
}
}
// Tool state can change without text growth.
if (!finalAnswerOnly) {
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
}
if (!runActive && batch.hasTurnAssistant) {
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
if (finalAnswerOnly) {
speakSettledFinalAnswer(batch.finalAnswerText)
} else if (!finishStandardSpeechStream()) {
flushRemainingBuffer()
}
streamObserverJob?.cancel()
scheduleAgentAudioCompletionCheck()
}
consumeAssistantSpeech(cursor.poll(messages), runActive)
}
}
}
private fun consumeAssistantSpeech(batch: AssistantSpeechBatch, runActive: Boolean) {
if (finalAnswerOnly) {
if (batch.deltas.isNotEmpty()) {
onVisualStreamDelta(batch.aggregateText)
}
} else {
batch.deltas.forEach { update ->
if (update.startsNewBubble) {
beginAssistantSpeechBubble()
}
onStreamDelta(update.text, batch.aggregateText)
}
}
// Tool state can change without text growth.
if (!finalAnswerOnly) {
batch.assistantMessages.forEach(::observeHermesToolLoopForSpeech)
}
if (!runActive && batch.hasTurnAssistant) {
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
if (finalAnswerOnly) {
speakSettledFinalAnswer(batch.finalAnswerText)
} else if (!finishStandardSpeechStream()) {
flushRemainingBuffer()
}
streamObserverJob?.cancel()
scheduleAgentAudioCompletionCheck()
}
}
private fun onVisualStreamDelta(fullContent: String) {
_uiState.update {
it.copy(
@@ -5800,6 +5913,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
private fun finishAgentAudioOutput() {
inboundSpeechPlaying = false
continuousResumeJob = null
_responseSpeechActive.value = false
stopBargeInListener()
@@ -5825,6 +5939,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { it.copy(amplitude = 0f, outputAudioActive = false) }
}
if (drainInboundSpeech()) return
if (_uiState.value.interactionMode == InteractionMode.Continuous &&
continuousLoopArmed &&
_uiState.value.state == VoiceState.Idle
@@ -6686,6 +6801,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
override fun onCleared() {
retireInboundSpeech()
chatViewModel?.gatewayInboundSpeechReceiver = null
super.onCleared()
voicePreviewJob?.cancel()
voicePreviewJob = null
@@ -0,0 +1,331 @@
package com.hermesandroid.relay.network.upstream
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.app.NotificationManager
import android.os.Build
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.preferencesOf
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.data.relayDataStore
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, sdk = [31, 35])
@OptIn(ExperimentalCoroutinesApi::class)
class GatewayKeepAliveServiceTest {
private val context = mockk<Context>(relaxed = true)
private val start = slot<Intent>()
private var service: GatewayKeepAliveService? = null
private val preferences = TestPreferences()
init {
every { context.applicationContext } returns context
every { context.startForegroundService(capture(start)) } returns
ComponentName("test", GatewayKeepAliveService::class.java.name)
}
@Before fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
GatewayKeepAliveService.resetForTest()
mockkStatic("com.hermesandroid.relay.data.DataStoreProviderKt")
every { any<Context>().relayDataStore } returns preferences
}
@After fun cleanup() {
preferences.gate?.complete(Unit)
service?.onDestroy()
ActiveTurnKeepAliveRegistry.resetForTest()
GatewayKeepAliveService.resetForTest()
unmockkAll()
Dispatchers.resetMain()
}
private fun create(): GatewayKeepAliveService =
Robolectric.buildService(GatewayKeepAliveService::class.java).create().get()
.also { service = it }
@Test fun immediateStopWaitsForForegroundPromotion() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
GatewayKeepAliveService.stop(context)
verify(exactly = 0) { context.stopService(any()) }
val instance = create()
assertNotNull(shadowOf(instance).lastForegroundNotification)
instance.onStartCommand(start.captured, 0, 1)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun creationPromotesBeforePublishingTheInstance() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
assertNotNull(shadowOf(create()).lastForegroundNotification)
}
@Test fun queuedStartCannotOverwriteNewerDemand() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val oldStart = start.captured
val instance = create()
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
instance.onStartCommand(oldStart, 0, 1)
val notification = shadowOf(instance).lastForegroundNotification!!
assertEquals("Hermes is waiting for input", notification.extras.getString("android.title"))
assertTrue(notification.actions.isNullOrEmpty())
}
@Test fun overlappingStartsAreCoalescedAndLatestDemandWins() {
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
GatewayKeepAliveService.stop(context)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2))
verify(exactly = 1) { context.startForegroundService(any()) }
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
assertFalse(shadowOf(instance).isStoppedBySelf)
assertEquals("Hermes is finishing 2 turns", notificationTitle(instance))
}
@Test fun siblingSettlementKeepsTheRemainingSessionProtected() {
ActiveTurnKeepAliveRegistry.acquire("connection::profile-a::session")
ActiveTurnKeepAliveRegistry.acquire("connection::profile-b::session")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
ActiveTurnKeepAliveRegistry.release("connection::profile-a::session")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
assertEquals("Hermes is finishing a turn", notificationTitle(instance))
assertFalse(shadowOf(instance).isStoppedBySelf)
ActiveTurnKeepAliveRegistry.release("connection::profile-b::session")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun retiringInstanceIsNotReusedAndItsDestructionCannotClearReplacement() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val old = create()
old.onStartCommand(start.captured, 0, 1)
GatewayKeepAliveService.stop(context)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
verify(exactly = 2) { context.startForegroundService(any()) }
val replacement = create()
replacement.onStartCommand(start.captured, 0, 2)
old.onDestroy()
old.onConfigurationChanged(Configuration())
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2))
assertEquals("Hermes is finishing 2 turns", notificationTitle(replacement))
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun androidCanDeliverANewStartToTheRetiringInstance() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
GatewayKeepAliveService.stop(context)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
instance.onStartCommand(start.captured, 0, 2)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2))
assertEquals("Hermes is finishing 2 turns", notificationTitle(instance))
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun backgroundDemandWaitsForVisibilityAndExistingProtectionSurvivesBackgrounding() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(), false)
verify(exactly = 0) { context.startForegroundService(any()) }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(), true)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(1), false)
assertEquals("Hermes is finishing a turn", notificationTitle(instance))
assertFalse(shadowOf(instance).isStoppedBySelf)
}
@Test fun rejectedLaunchCanRetryOnNextForegroundWithoutReleasingTurnOwnership() {
ActiveTurnKeepAliveRegistry.acquire("connection::profile::session")
every { context.startForegroundService(any()) } throws IllegalStateException("background start")
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
every { context.startForegroundService(capture(start)) } returns
ComponentName("test", GatewayKeepAliveService::class.java.name)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value, false)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.snapshot.value, true)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
assertEquals("Hermes is finishing a turn", notificationTitle(instance))
}
@Test fun promotionFailureRetiresTheInstanceAndAllowsAFreshLaunch() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = spyk(create()).also { service = it }
if (Build.VERSION.SDK_INT >= 34) {
every { instance.startForeground(any(), any(), any()) } throws SecurityException("denied")
} else {
every { instance.startForeground(any(), any()) } throws SecurityException("denied")
}
instance.onStartCommand(start.captured, 0, 1)
verify { instance.stopSelf() }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun channelFailureIsContainedBeforePublishingAnOwner() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = spyk(Robolectric.buildService(GatewayKeepAliveService::class.java).get())
.also { service = it }
every { instance.getSystemService(NotificationManager::class.java) } throws IllegalStateException("channel unavailable")
instance.onCreate()
verify { instance.stopSelf() }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun taskRemovalPreservesLeasesAndDoesNotRestartUntilTheNextVisibleLifecycle() {
ActiveTurnKeepAliveRegistry.acquire("connection::profile::session")
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
instance.onTaskRemoved(null)
assertTrue(shadowOf(instance).isStoppedBySelf)
assertTrue(ActiveTurnKeepAliveRegistry.snapshot.value.required)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value, false)
verify(exactly = 1) { context.startForegroundService(any()) }
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.snapshot.value, true)
verify(exactly = 2) { context.startForegroundService(any()) }
}
@Test fun taskRemovalDuringStartupStillAcknowledgesPromotion() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onTaskRemoved(null)
assertFalse(shadowOf(instance).isStoppedBySelf)
instance.onStartCommand(start.captured, 0, 1)
assertNotNull(shadowOf(instance).lastForegroundNotification)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun processLossDoesNotReplayOldDemandButStillPromotesADeliveredStart() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val oldStart = start.captured
GatewayKeepAliveService.resetForTest()
val instance = create()
assertNull(shadowOf(instance).lastForegroundNotification)
instance.onStartCommand(oldStart, 0, 1)
assertNotNull(shadowOf(instance).lastForegroundNotification)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun staleStartCannotAcknowledgeANewerPendingStart() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val oldStart = start.captured
GatewayKeepAliveService.resetForTest()
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
val newStart = start.captured
val instance = create()
instance.onStartCommand(oldStart, 0, 1)
GatewayKeepAliveService.stop(context)
assertFalse(shadowOf(instance).isStoppedBySelf)
instance.onStartCommand(newStart, 0, 2)
assertTrue(shadowOf(instance).isStoppedBySelf)
}
@Test fun notificationOnlyDisablesIdleRetentionAndUsesLatestTurnDemand() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
val action = stopAction(instance)
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
instance.onStartCommand(action, 0, 2)
assertEquals(false, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
assertFalse(shadowOf(instance).isStoppedBySelf)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(2, 1))
assertEquals("Hermes is waiting for input", notificationTitle(instance))
assertTrue(shadowOf(instance).lastForegroundNotification!!.actions.isNullOrEmpty())
}
@Test fun notificationWriteSurvivesServiceDestruction() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
preferences.gate = CompletableDeferred()
instance.onStartCommand(stopAction(instance), 0, 2)
instance.onTaskRemoved(null)
instance.onDestroy()
preferences.gate!!.complete(Unit)
assertEquals(false, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
}
@Test fun queuedNotificationEditCannotDisableAReenabledPreference() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(1))
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
val oldAction = stopAction(instance)
preferences.gate = CompletableDeferred()
instance.onStartCommand(oldAction, 0, 2)
GatewayKeepAliveService.update(context, false, ActiveTurnKeepAliveRegistry.Snapshot(1))
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot(1))
preferences.gate!!.complete(Unit)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
instance.onStartCommand(oldAction, 0, 3)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
}
@Test fun failedPreferenceWriteKeepsTruthfulNotificationAndProtection() {
GatewayKeepAliveService.update(context, true, ActiveTurnKeepAliveRegistry.Snapshot())
val instance = create()
instance.onStartCommand(start.captured, 0, 1)
preferences.fail = true
instance.onStartCommand(stopAction(instance), 0, 2)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
assertFalse(shadowOf(instance).isStoppedBySelf)
assertEquals(1, shadowOf(instance).lastForegroundNotification!!.actions.size)
}
@Test fun coldOldNotificationAndNullRestartDoNotEnableIdleRetention() {
val instance = create()
assertEquals(android.app.Service.START_NOT_STICKY, instance.onStartCommand(null, 0, 1))
instance.onStartCommand(Intent().setAction(GatewayKeepAliveService.ACTION_STOP), 0, 2)
assertNull(shadowOf(instance).lastForegroundNotification)
assertTrue(shadowOf(instance).isStoppedBySelf)
assertEquals(true, preferences.data.value[KEY_GATEWAY_KEEP_ALIVE])
}
private fun notificationTitle(instance: GatewayKeepAliveService) =
shadowOf(instance).lastForegroundNotification!!.extras.getString("android.title")
private fun stopAction(instance: GatewayKeepAliveService): Intent =
shadowOf(shadowOf(instance).lastForegroundNotification!!.actions.single().actionIntent).savedIntent
private class TestPreferences : DataStore<Preferences> {
override val data = MutableStateFlow(preferencesOf(KEY_GATEWAY_KEEP_ALIVE to true))
var gate: CompletableDeferred<Unit>? = null
var fail = false
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
gate?.await()
if (fail) throw java.io.IOException("write failed")
return transform(data.value).also { data.value = it }
}
}
}
@@ -642,7 +642,7 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { loadedProfile == owner.name }
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
assertEquals(owner.name, gatewayClient.sessionProfileProvider())
assertEquals("X-bot", handler.activeAgentName)
assertEquals("x-bot", handler.activeAgentName)
assertEquals("x-bot-session", persistedSession)
viewModel.switchProfileContext(
@@ -694,7 +694,7 @@ class ChatViewModelGatewayInboundTurnTest {
)
assertEquals(alpha, selected)
assertEquals(alpha.name, viewModel.conversationBinding.value.profileName)
assertEquals("Alpha", handler.activeAgentName)
assertEquals("alpha", handler.activeAgentName)
viewModel.openProfileSession(
profileName = beta.name,
@@ -706,7 +706,7 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals(beta, selected)
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
assertEquals(beta.name, gatewayClient.sessionProfileProvider())
assertEquals("Beta", handler.activeAgentName)
assertEquals("beta", handler.activeAgentName)
assertEquals("beta-session", handler.currentSessionId.value)
}
@@ -1462,7 +1462,7 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("default", viewModel.conversationBinding.value.profileName)
assertEquals("default", gatewayClient.sessionProfileProvider())
assertEquals(null, handler.currentSessionId.value)
assertEquals("Hermes", handler.activeAgentName)
assertEquals("default", handler.activeAgentName)
assertEquals("cleared", persistedSession)
}
@@ -2000,7 +2000,7 @@ class ChatViewModelGatewayInboundTurnTest {
})
}
viewModel.setDashboardConfigLoader { Result.success(config) }
shadowOf(Looper.getMainLooper()).idle()
awaitCondition { viewModel.personalityNames.value == listOf("private-a") }
assertEquals(listOf("private-a"), viewModel.personalityNames.value)
viewModel.resetConnectionCatalogs()
@@ -2013,6 +2013,12 @@ class ChatViewModelGatewayInboundTurnTest {
@Test
fun unsolicitedGatewayCompletionAppearsAsOneAssistantTurnAndSettles() {
val spoken = mutableListOf<String>()
var admissions = 0
viewModel.gatewayInboundSpeechReceiver = {
admissions++
{ text -> spoken.add(text); Unit }
}
// Upstream's process-completion poller currently emits this adjacent
// duplicate pair; it must still create exactly one placeholder.
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
@@ -2048,6 +2054,13 @@ class ChatViewModelGatewayInboundTurnTest {
}
assertFalse(handler.messages.value.single().isStreaming)
assertFalse(gatewayHarness.rpcLog.any { it.first == "prompt.submit" })
assertEquals(1, admissions)
assertEquals(listOf(BACKGROUND_ANSWER), spoken)
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("text", BACKGROUND_ANSWER)
}, "live-resumed"))
shadowOf(Looper.getMainLooper()).idle()
assertEquals(listOf(BACKGROUND_ANSWER), spoken)
}
@Test
@@ -2077,6 +2090,42 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(activeOwner.content.isBlank())
}
@Test
fun inboundSpeechIgnoresForeignUnscopedAndFailedTurns() {
val spoken = mutableListOf<String>()
viewModel.gatewayInboundSpeechReceiver = { { text -> spoken.add(text); Unit } }
for (session in listOf("foreign-session", null)) {
serverWs.send(gatewayHarness.eventFrame("message.start", null, session))
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("text", "Foreign answer")
}, session))
}
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("status", "error")
put("text", "Failed answer")
put("error", "Synthetic failure")
}, "live-resumed"))
awaitCondition { handler.messages.value.any { "Error" in it.badges } }
assertTrue(spoken.isEmpty())
}
@Test
fun inboundSpeechUsesFinalAnswerAfterToolInterim() {
val spoken = mutableListOf<String>()
viewModel.gatewayInboundSpeechReceiver = { { text -> spoken.add(text); Unit } }
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.interim", buildJsonObject {
put("text", "Checking the completed work.")
put("already_streamed", false)
}, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject {
put("text", "The completed work passed.")
}, "live-resumed"))
awaitCondition { spoken.isNotEmpty() }
assertEquals(listOf("The completed work passed."), spoken)
}
@Test
fun queuedMainDispatchAdmitsBackgroundStartAfterLocalCompletion() {
viewModel.sendMessage("Local gateway turn")
@@ -2499,6 +2548,12 @@ class ChatViewModelGatewayInboundTurnTest {
gatewayHarness.awaitRpc("approval.respond")
awaitCondition { !handler.isStreaming.value }
awaitCondition { checkpointStore.checkpoint == null }
// The RPC log records request receipt, before its acknowledgement is
// dispatched back to Main. Checkpoint retirement is independent too.
awaitCondition {
handler.messages.value.singleOrNull { it.id == "ask-approval-1" }
?.cardDispatches?.isNotEmpty() == true
}
assertEquals(
"once",
handler.messages.value.single { it.id == "ask-approval-1" }
@@ -0,0 +1,269 @@
package com.hermesandroid.relay.viewmodel
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.audio.VoicePlayer
import com.hermesandroid.relay.audio.VoiceRecorder
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.data.VoiceEngineMode
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import com.hermesandroid.relay.network.upstream.ChatHandler
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class VoiceInboundCompletionTest {
private val dispatcher = StandardTestDispatcher()
private lateinit var chat: ChatViewModel
private lateinit var handler: ChatHandler
private lateinit var voice: VoiceViewModel
private lateinit var recorder: VoiceRecorder
private lateinit var player: VoicePlayer
private val synthesis = mutableListOf<String>()
@Before
fun setup() {
Dispatchers.setMain(dispatcher)
handler = ChatHandler().also { it.setSessionId("session-a") }
chat = ChatViewModel().also {
it.initialize(null, handler)
it.streamingEndpoint = "gateway"
}
recorder = mockk(relaxed = true) {
every { amplitude } returns MutableStateFlow(0f)
every { isRecording() } returns false
}
player = mockk(relaxed = true) {
every { amplitude } returns MutableStateFlow(0f)
}
val app = ApplicationProvider.getApplicationContext<Application>()
val audio = object : VoiceAudioClient {
override val route = VoiceAudioRoute.Standard
override suspend fun transcribe(audioFile: File) = Result.success("")
override suspend fun synthesize(text: String): Result<File> {
synthesis.add(text)
return Result.success(File.createTempFile("inbound-voice", ".wav", app.cacheDir))
}
}
voice = VoiceViewModel(app).also {
it.initialize(
voiceClient = mockk(relaxed = true), voiceAudioClient = audio,
chatViewModel = chat, recorder = recorder, player = player,
sfxPlayer = mockk(relaxed = true),
)
it.enterVoiceMode()
}
}
@After
fun teardown() {
voice.exitVoiceMode()
Dispatchers.resetMain()
}
private fun admission(): (String) -> Unit = requireNotNull(chat.gatewayInboundSpeechReceiver?.invoke())
@Test
fun settledCompletionUsesConfiguredTtsExactlyOnce() = runTest(dispatcher) {
runCurrent()
val receipt = admission()
receipt("The timer finished.")
receipt("The timer finished.")
runCurrent()
assertEquals(listOf("The timer finished."), synthesis)
verify(exactly = 1) { player.play(any()) }
}
@Test
fun fastInboundStartCannotBeConsumedByPreviousVoiceObserver() = runTest(dispatcher) {
runCurrent()
// Exercise the production observer with the Main dispatcher held between
// the old terminal and the next inbound admission, as OkHttp can do.
VoiceViewModel::class.java.getDeclaredMethod("startStreamObserver", ChatViewModel::class.java)
.apply { isAccessible = true }.invoke(voice, chat)
handler.addPlaceholderMessage(ChatMessage(
id = "ordinary", role = MessageRole.ASSISTANT, content = "Work started.",
timestamp = 1L, isStreaming = true,
))
handler.onStreamComplete("ordinary")
val receipt = admission()
handler.addPlaceholderMessage(ChatMessage(
id = "inbound", role = MessageRole.ASSISTANT, content = "Work finished.",
timestamp = 2L, isStreaming = true,
))
handler.onStreamComplete("inbound")
receipt("Work finished.")
runCurrent()
assertEquals(listOf("Work started.", "Work finished."), synthesis)
}
@Test
fun completionWaitsForEarlierSpeechAndPreservesOrder() = runTest(dispatcher) {
runCurrent()
voice.seedSpeakingStateForTest(listOf("Earlier answer"), 0)
admission()("Process finished.")
admission()("Delegated work finished.")
runCurrent()
assertTrue(synthesis.isEmpty())
voice.finishAgentAudioOutputForTest()
runCurrent()
assertEquals(listOf("Process finished.", "Delegated work finished."), synthesis)
}
@Test
fun activeCaptureIsNeverCancelledForCompletionSpeech() = runTest(dispatcher) {
runCurrent()
every { recorder.isRecording() } returns true
admission()("Watch matched.")
runCurrent()
assertTrue(synthesis.isEmpty())
verify(exactly = 0) { recorder.cancel() }
every { recorder.isRecording() } returns false
voice.finishAgentAudioOutputForTest()
runCurrent()
assertEquals(listOf("Watch matched."), synthesis)
}
@Test
fun stopInvalidatesAdmittedAndQueuedCompletions() = runTest(dispatcher) {
runCurrent()
val late = admission()
voice.seedSpeakingStateForTest(emptyList(), 0)
admission()("Queued answer.")
voice.interruptSpeaking()
late("Late answer.")
runCurrent()
assertTrue(synthesis.isEmpty())
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
}
@Test
fun exitAndReentryRejectsOldReceiptButAcceptsNewTurn() = runTest(dispatcher) {
runCurrent()
val old = admission()
voice.exitVoiceMode()
voice.enterVoiceMode()
old("Old answer.")
admission()("New answer.")
runCurrent()
assertEquals(listOf("New answer."), synthesis)
}
@Test
fun sessionSwitchRejectsOldReceiptAndDoesNotAdoptNewSession() = runTest(dispatcher) {
runCurrent()
val old = admission()
handler.setSessionId("session-b")
old("Wrong session.")
runCurrent()
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
handler.setSessionId("session-a")
old("Stale return.")
runCurrent()
assertTrue(synthesis.isEmpty())
}
@Test
fun sameSessionIdInAnotherProfileCannotSpeak() = runTest(dispatcher) {
runCurrent()
val old = admission()
chat.switchProfileContext("connection-b::profile-b", "session-a")
old("Wrong profile.")
runCurrent()
assertTrue(synthesis.isEmpty())
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
}
@Test
fun realtimeEngineDoesNotConsumeGatewaySpeech() = runTest(dispatcher) {
runCurrent()
val old = admission()
voice.setVoiceEngineModeForTest(VoiceEngineMode.RealtimeAgent)
old("Wrong engine.")
assertNull(chat.gatewayInboundSpeechReceiver?.invoke())
runCurrent()
assertTrue(synthesis.isEmpty())
}
@Test
fun switchingEnginesBackCannotReviveAnOldReceipt() = runTest(dispatcher) {
runCurrent()
val old = admission()
val applySettings = VoiceViewModel::class.java.getDeclaredMethod(
"applyVoiceSettingsSnapshot", com.hermesandroid.relay.data.VoiceSettings::class.java,
).apply { isAccessible = true }
applySettings.invoke(voice, com.hermesandroid.relay.data.VoiceSettings(
engineMode = VoiceEngineMode.RealtimeAgent.storageValue,
))
applySettings.invoke(voice, com.hermesandroid.relay.data.VoiceSettings())
old("Stale engine receipt.")
runCurrent()
assertTrue(synthesis.isEmpty())
}
@Test
fun newVoiceConversationAdoptsOnlyItsSubmittedSession() = runTest(dispatcher) {
runCurrent()
voice.exitVoiceMode()
handler.setSessionId(null)
voice.enterVoiceMode()
val fence = VoiceTurnSessionFence(null).also { it.bindSubmittedUser("voice-user") }
VoiceViewModel::class.java.getDeclaredField("voiceTurnSessionFence")
.apply { isAccessible = true }.set(voice, fence)
handler.addUserMessage(ChatMessage(
id = "voice-user", role = MessageRole.USER, content = "Start work.", timestamp = 1L,
))
handler.setSessionId("new-voice-session")
runCurrent()
admission()("New conversation completion.")
runCurrent()
assertEquals(listOf("New conversation completion."), synthesis)
}
@Test
fun conversationChangeCancelsPendingSynthesisWithoutCancellingChat() = runTest(dispatcher) {
runCurrent()
voice.stopTtsConsumerForTest()
admission()("Old profile output.")
chat.switchProfileContext("connection-b::profile-b", "session-b")
runCurrent()
assertTrue(voice.drainTtsQueueForTest().isEmpty())
assertTrue(synthesis.isEmpty())
verify(atLeast = 1) { player.stop() }
}
@Test
fun historyAndForegroundReplayNeverCreateSpeech() = runTest(dispatcher) {
runCurrent()
handler.addPlaceholderMessage(ChatMessage(
id = "history-a", role = MessageRole.ASSISTANT, content = "Historical answer.",
timestamp = 1L, isStreaming = false,
))
voice.onAppResumed()
runCurrent()
assertTrue(synthesis.isEmpty())
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
plugins {
id("com.android.application") version "9.4.0" apply false
id("com.android.library") version "9.4.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.20" apply false
}
@@ -0,0 +1,63 @@
# Gateway foreground-service startup (#603)
## Confirmed defect and limits
Issue #603 reports `ForegroundServiceDidNotStartInTimeException` naming
`GatewayKeepAliveService` on Android 12/API 31. The report contains no preceding
lifecycle log and no comments. It does not establish an OEM-specific cause or
implicate the voice overlay.
Before this fix, demand could call `startForegroundService()` and then
`stopService()` before Android delivered `onCreate` or `onStartCommand`.
Android 12's `ActiveServices.bringDownServiceLocked` explicitly schedules a
foreground-service crash when teardown encounters `fgRequired`. Promotion in
`onStartCommand` alone therefore did not protect immediate cancellation.
Three focused API 31 regressions failed against the previous implementation:
immediate stop called `stopService` with an outstanding start; creation had no
foreground notification; and a queued persistent-only start overwrote newer
active/waiting-turn notification state. These establish client defects, not the
precise callback sequence on the reporting device.
## Lifecycle audit
| Path | Resulting contract |
| --- | --- |
| Controller | Main-thread collection combines the persistent preference, scoped turn leases, and process visibility. Socket-retention demand remains separate from service-launch eligibility. |
| First start | One pending token; channel creation, notification construction, and promotion happen synchronously in `onCreate`, without coroutine, datastore, or network work first. |
| Start then stop / overlapping demands | Coalesce current demand until the start command promotes and acknowledges its obligation. No `stopService` cancellation of pending starts. |
| Running updates | Apply current demand only to the live owner. Clear that owner before requesting teardown; a late old `onDestroy` cannot clear a replacement. |
| Delivered stale start / process loss | Promote a delivered foreground start, but never restore its old demand. A cold stale notification action does not restore idle retention. `START_NOT_STICKY` remains in effect. |
| Settlement / session switch | Existing connection/profile/session leases settle independently. This service sends no interrupt, resume, activate, or transport-routing command. |
| Always-on notification action | Identity includes the current enable cycle. Preference persistence outlives service teardown and rechecks its token inside the edit. Current turn demand determines eventual shutdown. Write failure leaves the preference and notification truthful. |
| Background eligibility | Do not request a new service from a known background lifecycle. Existing protection continues. Launch rejection is logged; a later visible transition retries remaining demand. |
| Task removal | Release local foreground protection without deleting chat-owned leases or assuming the process dies. Suppress restart until a new visible lifecycle transition. |
| Startup failure | Channel/build/promotion exceptions retire the unusable instance and clear pending launch state. They never cancel a server-owned turn. Platform-level asynchronous failures cannot be converted into successful foreground protection. |
| Configuration change | Only the current live owner refreshes the notification. |
| Notification/type policy | Existing low-importance channel, immutable intents, non-exported service, and both-flavor `specialUse` declaration remain. API 34+ uses the declared type/permission; API 31 uses the two-argument promotion. No permissions or dependencies added. |
The Gateway protocol, recovery/history contract, and session owner are unchanged,
so no new Gateway fixture scenario or upstream-conformance requirement is
introduced. The additional instrumentation exercises Android ActivityManager
and notification PendingIntent delivery without a server.
## Verification scope
`GatewayKeepAliveServiceTest` exercises API 31 and 35, including startup,
cancellation, failure, stale generations, notification persistence, task removal,
and scoped sibling settlement. It and `ActiveTurnKeepAliveRegistryTest` are in
both-flavor focused verification. `GatewayKeepAliveServiceInstrumentedTest`
targets the Standard Phone API 36 lane and observes the real platform watchdog
after rapid starts/stops, plus notification actions crossed by new turn demand.
Exact execution results belong to the PR's verification section.
Huawei firmware, physical-device behavior, and unrelated main-thread stalls
remain unverified. The change does not claim that every possible foreground-start
timeout has the same cause.
## Android sources
- [Android 12 ActiveServices](https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android12-release/services/core/java/com/android/server/am/ActiveServices.java): `bringDownServiceLocked`, `setServiceForegroundInnerLocked`, and `serviceForegroundTimeout`.
- [Foreground-service troubleshooting](https://developer.android.com/develop/background-work/services/fgs/troubleshooting): startup timeout versus background-start rejection.
- [Launching a foreground service](https://developer.android.com/develop/background-work/services/fgs/launch): prompt promotion, notification priority, and API 34 type prerequisites.
- [Background-start restrictions](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start): API 31 restrictions and user-interaction exceptions.
+30
View File
@@ -68,6 +68,7 @@ the upstream contract identifiers it depends on.
|---|---|
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
| `unsolicited_voice_completions` | One submitted turn followed by live same-session process, watch, and delegation answers, including duplicate start/terminal frames; Standard Voice receives each admitted answer once |
| `clarify_legacy` | Top-level single question and unkeyed `clarify.respond` |
| `clarify_normalized_single` | One normalized `questions[]` entry still requires its exact `qid` |
| `clarify_batch` | Independent qid responses, partial acknowledgement, and answered-question replay on reconnect |
@@ -179,6 +180,35 @@ redacted.
## Current-upstream conformance
Standard Voice receives successful unsolicited assistant answers from live Chat
admission, with a receipt captured before the new assistant placeholder exists.
The receipt belongs to the active voice generation and conversation binding;
history reads, passive Desktop observation, unmatched terminal recovery, and
queued-checkpoint restoration do not create speech receipts. Stop, voice exit,
engine changes, and conversation changes invalidate pending receipts. An active
microphone capture or earlier spoken answer finishes before queued speech starts.
The existing Continuous microphone release barrier still owns rearming.
Process completion/watch notifications and async delegation wakes enter upstream's
ordinary prompt runner (`tui_gateway/session_notifications.py` and `prompt_turn.py`
in current split upstream sources). The resulting assistant answer uses the same
live admission contract, regardless of its trigger. Raw process output, child
previews, and `background.complete` side-agent events are not assistant answers
and do not independently trigger narration. Reconnect history remains silent;
new live turns after reconnect can receive new receipts for the same owner.
`VoiceInboundCompletionTest` exercises the voice receipt and configured synthesis
path; `ChatViewModelGatewayInboundTurnTest` exercises real WebSocket admission.
The `unsolicited_voice_completions` manifest certifies the upstream terminal
contract without making provider or physical-audio claims.
For emulator lifecycle coverage, start that fixture on host loopback and run
`GatewayExternalFixtureInstrumentedTest#unsolicitedVoiceCompletions_surviveActivityPauseWithoutHistorySpeech`
on `standardPhoneApi36`, passing its emulator-accessible URL through
`gatewayFixtureBaseUrl`. The test uses production Chat/Voice view models and a
synthetic Standard audio client returning silent WAVs; it asserts three synthesis
requests across Activity pause/resume, with no provider calls or microphone capture.
Run against a clean checkout of `NousResearch/hermes-agent`:
```powershell
+6
View File
@@ -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
+10
View File
@@ -1,5 +1,15 @@
# 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.
+17 -1
View File
@@ -126,6 +126,22 @@ authentication, unsupported-protocol, and access-policy failures stop automatic
retry. After a socket has reached Ready once, ordinary network loss remains a
non-terminal reconnect episode while Chat is visible.
Gateway background protection follows process-local, connection/profile/session
turn leases. Idle retention remains opt-in through Persistent connection. An
accepted Android foreground-service start is promoted before shutdown, including
when a turn finishes before the start callback arrives. Overlapping demand uses
the latest state; old start commands never restore old turn counts. New service
launches wait for a visible application lifecycle, while an existing foreground
service continues protecting active work after backgrounding. A rejected launch
is logged and may retry on a later foreground transition.
The notification's **Turn off always-on** action persists that preference before
the collector reconciles current turn leases. It does not interrupt Hermes work.
Task removal drops local foreground protection without clearing chat-owned
leases or assuming process termination; if the process survives, returning to
the app can protect unfinished turns again. The service is not sticky and does
not restart idle retention from stale notification actions after process death.
The session drawer is a Dashboard REST consumer, not a Gateway-socket view.
Profile-scoped session browsing and stored transcript reads remain available
whenever the authenticated Dashboard route is available, including while the
@@ -1199,7 +1215,7 @@ utilities.
transfers ownership so assistant-process cleanup cannot cancel the main-app flow.
While keyguard is active, the surface keeps only generic phase and retry copy;
transcript, response, route-specific errors, and screen context remain hidden.
- Stable voice integrates with `ChatViewModel` by **observing** `messages: StateFlow`; transcribed text goes through normal `chatVm.sendMessage(text)` so voice utterances appear as regular user messages in chat history. Experimental Realtime Agent creates a mirrored chat turn and applies broker events directly so tool state, transcript text, assistant deltas, and final responses appear without leaving voice mode.
- Stable voice observes the submitted run through `messages: StateFlow`; transcribed text uses the normal Chat pipeline. While voice remains active, successful live unsolicited Gateway turns in that exact conversation also deliver their settled answer once through the configured voice output. Delivery waits for current capture/playback; Stop, exit, engine changes, and conversation changes invalidate pending speech. History/reconnect replay and passive observation never create speech. Experimental Realtime Agent retains its separate mirrored chat turn and broker events.
- `VoiceModeOverlay` — full-screen UI with the MorphingSphere at 60% height in `voiceMode=true`, transcribed + response text, mic button supporting Tap / Hold / Continuous interaction modes.
- The optional `SYSTEM_ALERT_WINDOW` Voice control is user-invoked from an
active in-app turn. It starts as a wide compact bar, expands for transcript,
+5 -5
View File
@@ -2,14 +2,14 @@
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"
@@ -28,7 +28,7 @@ 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"
+5 -1
View File
@@ -21,9 +21,13 @@ import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
FOCUSED_TESTS = (
"com.hermesandroid.relay.network.upstream.GatewayKeepAliveServiceTest",
"com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistryTest",
"com.hermesandroid.relay.viewmodel.InjectedContextTest",
"com.hermesandroid.relay.screenshots.InjectedContextSheetTest",
"com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest.injectedContextPreviewMatchesBareGatewayPayload",
"com.hermesandroid.relay.viewmodel.ChatViewModelGatewayInboundTurnTest",
"com.hermesandroid.relay.viewmodel.VoiceInboundCompletionTest",
"com.hermesandroid.relay.voice.VoiceViewModelBargeInTest",
"com.hermesandroid.relay.voice.VoiceOverlayLifecycleTest",
"com.hermesandroid.relay.voice.VoiceOverlayForegroundServiceTest",
"com.hermesandroid.relay.voice.VoiceOverlayPresentationTest",
@@ -218,6 +218,21 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("child_session_id", receipt["display_metadata"])
self.assertEqual("Delegation complete.", history[3]["content"])
async def test_unsolicited_voice_turns_follow_one_submit(self) -> None:
_, base_url = await self.start("unsolicited_voice_completions")
ws, _ = await self.connect(base_url)
await self.rpc(ws, 1, "prompt.submit", {"text": "fixture"})
frames = await self.frames_until(ws, lambda f: (
f.get("params", {}).get("type") == "message.complete"
and f.get("params", {}).get("payload", {}).get("text") == "Delegated work finished."
))
answers = [f["params"]["payload"]["text"] for f in frames
if f.get("params", {}).get("type") == "message.complete"]
self.assertEqual([
"Work started.", "Process finished.", "Process finished.",
"Watch matched.", "Delegated work finished.",
], answers)
async def test_ownership_rejection_is_terminal_without_persisted_turn(self) -> None:
fixture, base_url = await self.start("ownership_rejection")
ws, _ = await self.connect(base_url)
@@ -537,6 +552,7 @@ class ScenarioTestCase(unittest.TestCase):
"cross_client_observation",
"initial_history_bind",
"ordinary_turn",
"unsolicited_voice_completions",
"ownership_rejection",
"rapid_tools_interims",
"subagent_child_preview",
@@ -0,0 +1,37 @@
{
"name": "unsolicited_voice_completions",
"live_session_id": "fixture-live-1",
"stored_session_id": "20260821_120000_fixture",
"profile": "default",
"contract_requirements": ["gateway.message_complete"],
"turns": [{
"steps": [
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Work started.", "status": "complete"}},
{"op": "sleep", "milliseconds": 500},
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.delta", "payload": {"text": "Process finished."}},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}, {"id": 3, "role": "assistant", "content": "Process finished.", "timestamp": 3.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Process finished.", "status": "complete"}},
{"op": "event", "type": "message.complete", "payload": {"text": "Process finished.", "status": "complete"}},
{"op": "sleep", "milliseconds": 500},
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}, {"id": 3, "role": "assistant", "content": "Process finished.", "timestamp": 3.0}, {"id": 4, "role": "assistant", "content": "Watch matched.", "timestamp": 4.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Watch matched.", "status": "complete"}},
{"op": "sleep", "milliseconds": 500},
{"op": "set_running", "value": true},
{"op": "event", "type": "message.start"},
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Start background work.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Work started.", "timestamp": 2.0}, {"id": 3, "role": "assistant", "content": "Process finished.", "timestamp": 3.0}, {"id": 4, "role": "assistant", "content": "Watch matched.", "timestamp": 4.0}, {"id": 5, "role": "assistant", "content": "Delegated work finished.", "timestamp": 5.0}]},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Delegated work finished.", "status": "complete"}}
]
}]
}