Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635aaa44e7 | ||
|
|
e088469dbf | ||
|
|
2d202bdeac | ||
|
|
798441c1e1 | ||
|
|
b424678f44 | ||
|
|
d5210bcd5e | ||
|
|
6458a854a8 | ||
|
|
65fe37a2ba | ||
|
|
9ac10cf645 | ||
|
|
bd904e26e4 | ||
|
|
ba5a2c82a5 | ||
|
|
6119f3ba79 | ||
|
|
2bcdca09e9 | ||
|
|
e2e9cc72b7 | ||
|
|
ca555fd617 | ||
|
|
3b5e61e149 |
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}}
|
||||
]
|
||||
}]
|
||||
}
|
||||
Reference in New Issue
Block a user