Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578c074797 |
+1
-1
@@ -17,8 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android Continuous voice waits for barge-in microphone teardown before listening again.** Multi-turn hands-free conversations no longer lose the microphone after a response finishes with barge-in enabled. (#464)
|
||||
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
|
||||
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -1300,7 +1300,12 @@ and whether the agent is waiting on the user.
|
||||
permissions; exercise compact, expanded, collapsed, and full-Voice handoff
|
||||
states, background tap-through, rotation and insets, cancel/back, microphone
|
||||
denial, network failure, process kill/recreation, and wake→voice→wake
|
||||
resumption. Measure idle battery drain because third-party assistants do not
|
||||
resumption. For background and keyguard capture, record `AudioRecord`, AppOps,
|
||||
and foreground-service state: the user-installed app owns capture outside the
|
||||
separate session process, so confirm whether the selected Assistant role is
|
||||
sufficient on each target OS or whether activation needs an explicit,
|
||||
activation-scoped microphone foreground-service lease. Measure idle battery
|
||||
drain because third-party assistants do not
|
||||
receive Google's dedicated low-power hotword hardware.
|
||||
|
||||
- **Audio quality guardrails** — normalize output volume across realtime and
|
||||
|
||||
@@ -39,14 +39,38 @@ enum class AssistantSessionPhase {
|
||||
Closed,
|
||||
}
|
||||
|
||||
enum class AssistantSessionNotice {
|
||||
NoSpeech,
|
||||
}
|
||||
|
||||
data class AssistantSessionSnapshot(
|
||||
val phase: AssistantSessionPhase = AssistantSessionPhase.Launching,
|
||||
val transcript: String? = null,
|
||||
val response: String = "",
|
||||
val notice: AssistantSessionNotice? = null,
|
||||
val error: String? = null,
|
||||
val screenContextSupported: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun assistantSnapshotForPresentation(
|
||||
snapshot: AssistantSessionSnapshot,
|
||||
locked: Boolean,
|
||||
): AssistantSessionSnapshot = if (locked) {
|
||||
snapshot.copy(
|
||||
transcript = null,
|
||||
response = "",
|
||||
error = null,
|
||||
screenContextSupported = false,
|
||||
)
|
||||
} else {
|
||||
snapshot
|
||||
}
|
||||
|
||||
internal fun assistantSnapshotMatchesActivation(
|
||||
expectedActivationId: String?,
|
||||
receivedActivationId: String?,
|
||||
): Boolean = expectedActivationId != null && expectedActivationId == receivedActivationId
|
||||
|
||||
object AssistantRole {
|
||||
fun status(context: Context): AssistantRoleStatus {
|
||||
val component = ComponentName(context, HermesVoiceInteractionService::class.java)
|
||||
@@ -209,6 +233,7 @@ object AssistantSessionProtocol {
|
||||
onFailure = { failure ->
|
||||
publish(
|
||||
application,
|
||||
activation.id,
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = failure.message ?: "Hermes voice could not start",
|
||||
@@ -219,13 +244,19 @@ object AssistantSessionProtocol {
|
||||
return true
|
||||
}
|
||||
|
||||
fun publish(context: Context, snapshot: AssistantSessionSnapshot) {
|
||||
fun publish(
|
||||
context: Context,
|
||||
activationId: String,
|
||||
snapshot: AssistantSessionSnapshot,
|
||||
) {
|
||||
context.sendBroadcast(
|
||||
Intent(context, AssistantSessionStateReceiver::class.java).apply {
|
||||
action = ACTION_STATUS
|
||||
putExtra(EXTRA_ACTIVATION_ID, activationId)
|
||||
putExtra(EXTRA_PHASE, snapshot.phase.name)
|
||||
putExtra(EXTRA_TRANSCRIPT, snapshot.transcript)
|
||||
putExtra(EXTRA_RESPONSE, snapshot.response)
|
||||
putExtra(EXTRA_NOTICE, snapshot.notice?.name)
|
||||
putExtra(EXTRA_ERROR, snapshot.error)
|
||||
putExtra(EXTRA_SCREEN_CONTEXT_SUPPORTED, snapshot.screenContextSupported)
|
||||
}
|
||||
@@ -238,10 +269,6 @@ object AssistantSessionProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
fun publish(context: Context, state: VoiceUiState) {
|
||||
publish(context, snapshotFromVoiceState(state))
|
||||
}
|
||||
|
||||
internal fun snapshotFromVoiceState(state: VoiceUiState): AssistantSessionSnapshot {
|
||||
val phase = when {
|
||||
!state.voiceMode -> AssistantSessionPhase.Closed
|
||||
@@ -256,7 +283,10 @@ object AssistantSessionProtocol {
|
||||
phase = phase,
|
||||
transcript = state.transcribedText?.take(MAX_SESSION_TEXT_CHARS),
|
||||
response = state.responseText.take(MAX_SESSION_TEXT_CHARS),
|
||||
error = state.error?.take(MAX_SESSION_ERROR_CHARS),
|
||||
notice = state.assistantNotice,
|
||||
error = state.error
|
||||
?.takeIf { phase == AssistantSessionPhase.Error }
|
||||
?.take(MAX_SESSION_ERROR_CHARS),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -350,6 +380,9 @@ object AssistantSessionProtocol {
|
||||
phase = phase,
|
||||
transcript = intent.getStringExtra(EXTRA_TRANSCRIPT),
|
||||
response = intent.getStringExtra(EXTRA_RESPONSE).orEmpty(),
|
||||
notice = intent.getStringExtra(EXTRA_NOTICE)?.let { raw ->
|
||||
runCatching { AssistantSessionNotice.valueOf(raw) }.getOrNull()
|
||||
},
|
||||
error = intent.getStringExtra(EXTRA_ERROR),
|
||||
screenContextSupported = intent.getBooleanExtra(
|
||||
EXTRA_SCREEN_CONTEXT_SUPPORTED,
|
||||
@@ -360,24 +393,33 @@ object AssistantSessionProtocol {
|
||||
|
||||
private const val MAX_SESSION_TEXT_CHARS = 4_000
|
||||
private const val MAX_SESSION_ERROR_CHARS = 1_000
|
||||
private const val EXTRA_NOTICE = "notice"
|
||||
}
|
||||
|
||||
object AssistantSessionState {
|
||||
private val _snapshot = MutableStateFlow(AssistantSessionSnapshot())
|
||||
val snapshot: StateFlow<AssistantSessionSnapshot> = _snapshot.asStateFlow()
|
||||
@Volatile private var activationId: String? = null
|
||||
|
||||
internal fun update(snapshot: AssistantSessionSnapshot) {
|
||||
internal fun update(receivedActivationId: String?, snapshot: AssistantSessionSnapshot) {
|
||||
if (!assistantSnapshotMatchesActivation(activationId, receivedActivationId)) return
|
||||
_snapshot.value = snapshot
|
||||
}
|
||||
|
||||
internal fun reset() {
|
||||
internal fun reset(activationId: String) {
|
||||
this.activationId = activationId
|
||||
_snapshot.value = AssistantSessionSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantSessionStateReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AssistantSessionState.update(AssistantSessionProtocol.readSnapshot(intent))
|
||||
AssistantSessionState.update(
|
||||
receivedActivationId = intent.getStringExtra(
|
||||
AssistantSessionProtocol.EXTRA_ACTIVATION_ID
|
||||
),
|
||||
snapshot = AssistantSessionProtocol.readSnapshot(intent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +465,7 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
|
||||
onFailure = { failure ->
|
||||
AssistantSessionProtocol.publish(
|
||||
application,
|
||||
id,
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = failure.message ?: "Hermes voice could not start",
|
||||
@@ -430,6 +473,7 @@ class AssistantSessionLifecycleReceiver : BroadcastReceiver() {
|
||||
)
|
||||
},
|
||||
)
|
||||
application.runtime.republishAssistantSnapshot(id)
|
||||
return
|
||||
}
|
||||
if (AssistantSessionProtocol.isStartAction(intent.action)) {
|
||||
|
||||
+73
-3
@@ -3,6 +3,11 @@ package com.hermesandroid.relay.assistant
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.app.KeyguardManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import android.service.voice.VoiceInteractionSession
|
||||
import android.service.voice.VoiceInteractionSessionService
|
||||
@@ -67,6 +72,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
@@ -108,6 +114,11 @@ internal fun shouldCancelVoiceWhenSessionUiEnds(
|
||||
presentation: AssistantSessionPresentation,
|
||||
): Boolean = presentation == AssistantSessionPresentation.Overlay
|
||||
|
||||
internal fun assistantPresentationLocked(
|
||||
currentKeyguardLocked: Boolean?,
|
||||
fallbackLocked: Boolean,
|
||||
): Boolean = currentKeyguardLocked ?: fallbackLocked
|
||||
|
||||
private class HermesVoiceInteractionSession(
|
||||
private val service: HermesVoiceInteractionSessionService,
|
||||
) : VoiceInteractionSession(service) {
|
||||
@@ -118,12 +129,19 @@ private class HermesVoiceInteractionSession(
|
||||
private var surfaceExpanded by mutableStateOf(false)
|
||||
private var activationId: String? = null
|
||||
private var manualMic = false
|
||||
private var keyguardLocked by mutableStateOf(false)
|
||||
private var expectScreenContext: Boolean? = null
|
||||
private var pendingSemantic = AssistantSemanticContext()
|
||||
private var pendingScreenshot: ByteArray? = null
|
||||
private var screenContextUi by mutableStateOf(AssistantScreenContextUi())
|
||||
private val contextStore = assistantContextStore(service)
|
||||
private var heartbeatJob: Job? = null
|
||||
private var keyguardReceiverRegistered = false
|
||||
private val keyguardReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
refreshKeyguardState()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
@@ -139,6 +157,17 @@ private class HermesVoiceInteractionSession(
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ContextCompat.registerReceiver(
|
||||
service,
|
||||
keyguardReceiver,
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
},
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
keyguardReceiverRegistered = true
|
||||
window.window?.apply {
|
||||
setBackgroundDrawable(ColorDrawable(android.graphics.Color.TRANSPARENT))
|
||||
clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
|
||||
@@ -155,6 +184,7 @@ private class HermesVoiceInteractionSession(
|
||||
PersistedHermesRelayTheme {
|
||||
AssistantSessionSurface(
|
||||
expanded = surfaceExpanded,
|
||||
locked = keyguardLocked,
|
||||
screenContext = screenContextUi,
|
||||
onExpandedChange = { surfaceExpanded = it },
|
||||
onCancel = { finishSession(cancelVoice = true) },
|
||||
@@ -183,11 +213,22 @@ private class HermesVoiceInteractionSession(
|
||||
|
||||
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||
super.onShow(args, showFlags)
|
||||
if (args?.getBoolean(HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD, false) == true) {
|
||||
refreshKeyguardState(
|
||||
fallbackLocked = args?.getBoolean(
|
||||
HermesVoiceInteractionService.EXTRA_FROM_KEYGUARD,
|
||||
false,
|
||||
) == true,
|
||||
)
|
||||
if (keyguardLocked) {
|
||||
window.window?.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
} else {
|
||||
window.window?.clearFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||
)
|
||||
}
|
||||
setUiEnabled(true)
|
||||
val startsNewLifecycle = presentation == AssistantSessionPresentation.Inactive
|
||||
@@ -195,10 +236,10 @@ private class HermesVoiceInteractionSession(
|
||||
if (!startsNewLifecycle) return
|
||||
|
||||
surfaceExpanded = false
|
||||
AssistantSessionState.reset()
|
||||
screenContextUi = AssistantScreenContextUi()
|
||||
activationId = args?.getString(AssistantSessionProtocol.EXTRA_ACTIVATION_ID)
|
||||
?: UUID.randomUUID().toString()
|
||||
AssistantSessionState.reset(activationId!!)
|
||||
manualMic = args?.getBoolean(AssistantSessionProtocol.EXTRA_MANUAL_MIC, false) ?: false
|
||||
expectScreenContext = args?.getBoolean(
|
||||
AssistantSessionProtocol.EXTRA_EXPECT_SCREEN_CONTEXT,
|
||||
@@ -300,6 +341,10 @@ private class HermesVoiceInteractionSession(
|
||||
pendingSemantic = AssistantSemanticContext()
|
||||
pendingScreenshot = null
|
||||
screenContextUi = AssistantScreenContextUi()
|
||||
if (keyguardReceiverRegistered) {
|
||||
runCatching { service.unregisterReceiver(keyguardReceiver) }
|
||||
keyguardReceiverRegistered = false
|
||||
}
|
||||
viewOwner.stop()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
@@ -319,6 +364,7 @@ private class HermesVoiceInteractionSession(
|
||||
)
|
||||
}.onFailure {
|
||||
AssistantSessionState.update(
|
||||
activationId,
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = it.message ?: "Hermes could not open the voice session.",
|
||||
@@ -337,6 +383,7 @@ private class HermesVoiceInteractionSession(
|
||||
setUiEnabled(false)
|
||||
}.onFailure {
|
||||
AssistantSessionState.update(
|
||||
activationId,
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
error = it.message ?: "Hermes could not open full voice.",
|
||||
@@ -377,6 +424,14 @@ private class HermesVoiceInteractionSession(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshKeyguardState(fallbackLocked: Boolean = keyguardLocked) {
|
||||
keyguardLocked = assistantPresentationLocked(
|
||||
currentKeyguardLocked = service.getSystemService(KeyguardManager::class.java)
|
||||
?.isKeyguardLocked,
|
||||
fallbackLocked = fallbackLocked,
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresApi(android.os.Build.VERSION_CODES.Q)
|
||||
private fun stageAssistState(state: AssistState) {
|
||||
stageAssistData(state.assistStructure, state.assistContent)
|
||||
@@ -463,6 +518,7 @@ private class AssistantSessionViewOwner :
|
||||
@Composable
|
||||
private fun AssistantSessionSurface(
|
||||
expanded: Boolean,
|
||||
locked: Boolean,
|
||||
screenContext: AssistantScreenContextUi,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
@@ -471,7 +527,8 @@ private fun AssistantSessionSurface(
|
||||
onOpenFullVoice: () -> Unit,
|
||||
onSurfaceBoundsChanged: (android.graphics.Rect) -> Unit,
|
||||
) {
|
||||
val snapshot by AssistantSessionState.snapshot.collectAsState()
|
||||
val rawSnapshot by AssistantSessionState.snapshot.collectAsState()
|
||||
val snapshot = assistantSnapshotForPresentation(rawSnapshot, locked)
|
||||
val status = assistantStatus(snapshot.phase)
|
||||
val transmittedScreenContext = if (snapshot.screenContextSupported) {
|
||||
screenContext
|
||||
@@ -650,6 +707,13 @@ private fun ExpandedAssistantSurface(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
snapshot.notice?.let { notice ->
|
||||
Text(
|
||||
text = assistantNoticeText(notice),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
snapshot.error?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
@@ -896,5 +960,11 @@ private fun assistantStatus(phase: AssistantSessionPhase): String = when (phase)
|
||||
private fun compactAssistantText(snapshot: AssistantSessionSnapshot): String =
|
||||
snapshot.transcript?.takeIf { it.isNotBlank() }
|
||||
?: snapshot.response.takeIf { it.isNotBlank() }
|
||||
?: snapshot.notice?.let { assistantNoticeText(it) }
|
||||
?: snapshot.error?.takeIf { it.isNotBlank() }
|
||||
?: assistantStatus(snapshot.phase)
|
||||
|
||||
@Composable
|
||||
private fun assistantNoticeText(notice: AssistantSessionNotice): String = when (notice) {
|
||||
AssistantSessionNotice.NoSpeech -> stringResource(R.string.voice_no_speech_try_again)
|
||||
}
|
||||
|
||||
@@ -240,6 +240,25 @@ class HermesProcessRuntime internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun republishAssistantSnapshot(activationId: String) {
|
||||
val snapshot = synchronized(activationLock) {
|
||||
if (currentActivationId != activationId ||
|
||||
_initializationState.value != HermesRuntimeInitializationState.Ready
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
binder.assistantSnapshot.value
|
||||
}
|
||||
} ?: return
|
||||
if (snapshot.phase != com.hermesandroid.relay.assistant.AssistantSessionPhase.Closed) {
|
||||
com.hermesandroid.relay.assistant.AssistantSessionProtocol.publish(
|
||||
application,
|
||||
activationId,
|
||||
snapshot,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun recordAssistantHeartbeat(
|
||||
activationId: String,
|
||||
nowElapsedMs: Long = SystemClock.elapsedRealtime(),
|
||||
|
||||
@@ -376,7 +376,9 @@ internal class HermesRuntimeBinder(
|
||||
if (!AssistantAppSessionState.active.value) return@collect
|
||||
if (state.voiceMode) AssistantAppSessionState.markVoiceStarted()
|
||||
if (state.voiceMode || AssistantAppSessionState.hasVoiceStarted()) {
|
||||
AssistantSessionProtocol.publish(application, snapshot)
|
||||
state.assistantActivationId?.let { activationId ->
|
||||
AssistantSessionProtocol.publish(application, activationId, snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,10 +257,7 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
if ("cannot create audiorecord" in msg ||
|
||||
"audiorecord failed to initialize" in msg ||
|
||||
"microphone is in use by another voice feature" in msg
|
||||
) {
|
||||
if ("cannot create audiorecord" in msg || "audiorecord failed to initialize" in msg) {
|
||||
return HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_mic_unavailable) ?: "Microphone unavailable",
|
||||
body = ctx?.getString(R.string.error_classify_mic_unavailable_body)
|
||||
|
||||
@@ -52,6 +52,7 @@ import com.hermesandroid.relay.voice.VoiceCommandInterpreter
|
||||
import com.hermesandroid.relay.voice.SpokenInterruptionLatch
|
||||
import com.hermesandroid.relay.voice.voiceInterfaceContextPrompt
|
||||
import com.hermesandroid.relay.assistant.assistantContextStore
|
||||
import com.hermesandroid.relay.assistant.AssistantSessionNotice
|
||||
import com.hermesandroid.relay.assistant.buildAssistantVoiceTurnPayload
|
||||
// === PHASE3-voice-intents: voice→bridge intent routing ===
|
||||
import com.hermesandroid.relay.voice.IntentResult
|
||||
@@ -90,7 +91,6 @@ import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
|
||||
@@ -127,6 +127,25 @@ internal fun voiceSubmissionRetryState(state: VoiceUiState): VoiceUiState = stat
|
||||
error = null,
|
||||
)
|
||||
|
||||
internal fun voiceNoSpeechState(state: VoiceUiState): VoiceUiState = state.copy(
|
||||
state = VoiceState.Idle,
|
||||
amplitude = 0f,
|
||||
outputAudioActive = false,
|
||||
transcribedText = null,
|
||||
error = null,
|
||||
assistantNotice = AssistantSessionNotice.NoSpeech,
|
||||
)
|
||||
|
||||
internal fun voiceCaptureCancellationState(
|
||||
state: VoiceUiState,
|
||||
notice: AssistantSessionNotice? = null,
|
||||
): VoiceUiState = state.copy(
|
||||
state = VoiceState.Idle,
|
||||
amplitude = 0f,
|
||||
outputAudioActive = false,
|
||||
assistantNotice = notice,
|
||||
)
|
||||
|
||||
internal data class AssistantContextTurnDisposition(
|
||||
val retireForLaterTurns: Boolean,
|
||||
val consumeOnTransportAcceptance: Boolean,
|
||||
@@ -329,6 +348,10 @@ data class VoiceUiState(
|
||||
val responseText: String = "",
|
||||
/** Human-readable error surfaced in the overlay. */
|
||||
val error: String? = null,
|
||||
/** Content-free retry status safe for the system Assistant surface. */
|
||||
val assistantNotice: AssistantSessionNotice? = null,
|
||||
/** Stable owner for cross-process Assistant status; null for ordinary voice. */
|
||||
val assistantActivationId: String? = null,
|
||||
/** Currently-selected interaction mode. */
|
||||
val interactionMode: InteractionMode = InteractionMode.TapToTalk,
|
||||
/**
|
||||
@@ -441,6 +464,7 @@ internal fun voiceSessionExitState(state: VoiceUiState): VoiceUiState =
|
||||
transcribedText = null,
|
||||
responseText = "",
|
||||
error = null,
|
||||
assistantNotice = null,
|
||||
destructiveCountdown = null,
|
||||
hermesConfirmation = null,
|
||||
handoffStatus = null,
|
||||
@@ -1038,14 +1062,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var bargeInListener: BargeInListener? = null
|
||||
private var bargeInListenerJob: Job? = null
|
||||
private var bargeInVadEngine: VadEngine? = null
|
||||
/**
|
||||
* The most recent asynchronous AudioRecord shutdown still releasing the
|
||||
* process-wide BargeIn microphone lease. Teardown is intentionally
|
||||
* idempotent, so completion paths may call [stopBargeInListener] after the
|
||||
* listener reference has already been cleared. Retaining this fence makes
|
||||
* every subsequent VoiceCapture start join the same ownership handoff.
|
||||
*/
|
||||
private val pendingBargeInReaderRelease = AtomicReference<Job?>(null)
|
||||
private val bargeInTurnEpoch = AtomicLong(0L)
|
||||
@Volatile private var activeBargeInTurnEpoch: Long = 0L
|
||||
|
||||
@@ -1382,11 +1398,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
// A mode change supersedes any capture that is still waiting for the
|
||||
// previous microphone owner to release. The selected mode below may
|
||||
// start a fresh Continuous capture with its own generation.
|
||||
cancelPendingListeningStart()
|
||||
|
||||
if (mode != InteractionMode.Continuous) {
|
||||
continuousLoopArmed = false
|
||||
continuousListeningPaused = false
|
||||
@@ -1644,6 +1655,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
state = VoiceState.Idle,
|
||||
outputAudioActive = false,
|
||||
error = null,
|
||||
assistantActivationId = activationId,
|
||||
hermesConfirmation = null,
|
||||
backgroundRun = if (orphanedRun != null) null else it.backgroundRun,
|
||||
)
|
||||
@@ -1858,7 +1870,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
fun exitVoiceMode() {
|
||||
cancelPendingListeningStart()
|
||||
// Idempotence guard — added 2026-04-21 after logcat showed the voice-
|
||||
// exit chime playing on every Add-connection tap.
|
||||
//
|
||||
@@ -2010,10 +2021,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fun startListening() {
|
||||
startListening(requireContinuousLoop = false)
|
||||
}
|
||||
|
||||
private fun startListening(requireContinuousLoop: Boolean) {
|
||||
// A direct mic tap starts a normal capture. Only the recorder opened by
|
||||
// onBargeInDetected may carry response-interruption command context.
|
||||
responseInterruptedForVoiceCommand = false
|
||||
@@ -2022,11 +2029,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Recorder not initialized")
|
||||
return
|
||||
}
|
||||
if (requireContinuousLoop && !canStartContinuousCapture()) return
|
||||
// A direct/new capture request supersedes a stale handoff waiter. It
|
||||
// will join the same retained microphone-release fence under a fresh
|
||||
// epoch below instead of being silently dropped.
|
||||
cancelPendingListeningStart()
|
||||
if (pendingListeningStartJob?.isActive == true) return
|
||||
if (rec.isRecording()) return
|
||||
if (_uiState.value.state == VoiceState.Listening) {
|
||||
// Listening is reserved for a live AudioRecord. Reconcile a stale
|
||||
@@ -2060,9 +2063,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try { realtimePcmPlayer?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
|
||||
if (microphoneRelease == null || microphoneRelease.isCompleted) {
|
||||
if (!requireContinuousLoop || canStartContinuousCapture()) {
|
||||
startVoiceCapture(rec)
|
||||
}
|
||||
startVoiceCapture(rec)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2070,9 +2071,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val pendingStart = viewModelScope.launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
microphoneRelease.join()
|
||||
if (listeningStartEpoch == startEpoch &&
|
||||
(!requireContinuousLoop || canStartContinuousCapture())
|
||||
) {
|
||||
if (listeningStartEpoch == startEpoch) {
|
||||
startVoiceCapture(rec)
|
||||
}
|
||||
} finally {
|
||||
@@ -2085,15 +2084,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
pendingStart.start()
|
||||
}
|
||||
|
||||
private fun canStartContinuousCapture(): Boolean {
|
||||
val state = _uiState.value
|
||||
return state.voiceMode &&
|
||||
state.interactionMode == InteractionMode.Continuous &&
|
||||
state.state == VoiceState.Idle &&
|
||||
continuousLoopArmed &&
|
||||
!continuousListeningPaused
|
||||
}
|
||||
|
||||
private fun startVoiceCapture(rec: VoiceRecorder) {
|
||||
try {
|
||||
rec.startRecording()
|
||||
@@ -2103,6 +2093,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
state = VoiceState.Listening,
|
||||
outputAudioActive = false,
|
||||
error = null,
|
||||
assistantNotice = null,
|
||||
responseText = "",
|
||||
// v0.4.1 — fresh turn, drop any stale JIT permission chip
|
||||
// from the previous dispatch.
|
||||
@@ -2212,7 +2203,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private fun shouldDiscardVoiceCaptureBeforeStop(durationMs: Long): Boolean =
|
||||
durationMs < MIN_VOICE_CAPTURE_DURATION_MS
|
||||
|
||||
private fun cancelListeningWithoutProcessing(title: String, detail: String? = null) {
|
||||
private fun cancelListeningWithoutProcessing(
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
notice: AssistantSessionNotice? = null,
|
||||
) {
|
||||
responseInterruptedForVoiceCommand = false
|
||||
silenceWatchdogJob?.cancel()
|
||||
silenceWatchdogJob = null
|
||||
@@ -2224,9 +2219,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
title = title,
|
||||
detail = detail,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(state = VoiceState.Idle, amplitude = 0f, outputAudioActive = false)
|
||||
}
|
||||
_uiState.update { voiceCaptureCancellationState(it, notice) }
|
||||
}
|
||||
|
||||
/** Reconcile microphone state after the Activity returns to foreground. */
|
||||
@@ -2254,7 +2247,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* listening turn; until then, idle queue-drain callbacks are ignored.
|
||||
*/
|
||||
fun pauseContinuousMode() {
|
||||
cancelPendingListeningStart()
|
||||
continuousLoopArmed = false
|
||||
continuousListeningPaused = _uiState.value.interactionMode == InteractionMode.Continuous
|
||||
continuousResumeJob?.cancel()
|
||||
@@ -2364,6 +2356,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
cancelListeningWithoutProcessing(
|
||||
title = getApplication<Application>().getString(R.string.voice_status_no_speech),
|
||||
detail = "No speech within ${IDLE_NO_SPEECH_MS / 1000}s",
|
||||
notice = AssistantSessionNotice.NoSpeech,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
@@ -5797,7 +5790,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
continuousLoopArmed &&
|
||||
_uiState.value.state == VoiceState.Idle
|
||||
) {
|
||||
startListening(requireContinuousLoop = true)
|
||||
startListening()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5862,29 +5855,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!activeResponseOwnsBargeIn()) {
|
||||
Log.i(TAG, "Barge-in listener skipped; no active voice response owns the microphone")
|
||||
return
|
||||
}
|
||||
|
||||
val pendingReaderRelease = pendingBargeInReaderRelease.get()?.takeUnless { it.isCompleted }
|
||||
if (pendingReaderRelease != null) {
|
||||
// A late playback/realtime callback may request the next turn's
|
||||
// listener while the previous AudioRecord is still unwinding.
|
||||
// Join the same ownership fence as VoiceCapture, then re-check the
|
||||
// turn epoch so stale generations cannot reopen the microphone.
|
||||
activeBargeInTurnEpoch = epoch
|
||||
viewModelScope.launch {
|
||||
pendingReaderRelease.join()
|
||||
if (activeBargeInTurnEpoch == epoch &&
|
||||
bargeInListener == null &&
|
||||
activeResponseOwnsBargeIn()
|
||||
) {
|
||||
startBargeInListenerIfEnabled(epoch = epoch)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val vad = try {
|
||||
vadFactory().also { it.setSensitivity(prefs.sensitivity) }
|
||||
@@ -5933,12 +5903,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeResponseOwnsBargeIn(): Boolean {
|
||||
val state = _uiState.value
|
||||
return state.voiceMode &&
|
||||
(state.state == VoiceState.Thinking || state.state == VoiceState.Speaking)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the active [BargeInListener], cancel its event subscribers,
|
||||
* unduck the player (in case a ducking watchdog hadn't yet restored
|
||||
@@ -5972,13 +5936,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try { realtimePcmPlayer?.unduck() } catch (_: Throwable) { /* ignore */ }
|
||||
isDucked = false
|
||||
}
|
||||
if (stoppedReaderJob != null) {
|
||||
pendingBargeInReaderRelease.set(stoppedReaderJob)
|
||||
stoppedReaderJob.invokeOnCompletion {
|
||||
pendingBargeInReaderRelease.compareAndSet(stoppedReaderJob, null)
|
||||
}
|
||||
}
|
||||
return pendingBargeInReaderRelease.get()?.takeUnless { it.isCompleted }
|
||||
return stoppedReaderJob
|
||||
}
|
||||
|
||||
private fun markBargeInPlaybackStarted(graceMs: Long) {
|
||||
@@ -6030,8 +5988,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
internal fun onBargeInDetected() {
|
||||
if (isBargeInStartupGuardActive()) return
|
||||
val interruptedMode = _uiState.value.interactionMode
|
||||
val interruptedEngine = voiceEngineMode
|
||||
val interruptedSpokenReply = _uiState.value.outputAudioActive
|
||||
if (interruptedSpokenReply) spokenInterruptionLatch.mark()
|
||||
duckingWatchdog?.cancel(); duckingWatchdog = null
|
||||
@@ -6065,63 +6021,18 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = "",
|
||||
)
|
||||
}
|
||||
val captureEpoch = ++listeningStartEpoch
|
||||
val pendingStart = viewModelScope.launch(start = CoroutineStart.LAZY) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
microphoneRelease?.join()
|
||||
if (!canStartBargeInCapture(captureEpoch, interruptedMode, interruptedEngine)) {
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
return@launch
|
||||
}
|
||||
val rec = recorder ?: error("Recorder not initialized")
|
||||
if (!rec.isRecording()) {
|
||||
val rec = recorder
|
||||
if (rec != null && !rec.isRecording()) {
|
||||
rec.startRecording()
|
||||
}
|
||||
if (canStartBargeInCapture(captureEpoch, interruptedMode, interruptedEngine)) {
|
||||
scheduleResumeWatchdog()
|
||||
} else {
|
||||
try { rec.cancel() } catch (_: Throwable) { /* ignore */ }
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
}
|
||||
} catch (t: CancellationException) {
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
throw t
|
||||
scheduleResumeWatchdog()
|
||||
} catch (t: Throwable) {
|
||||
if (listeningStartEpoch == captureEpoch) {
|
||||
responseInterruptedForVoiceCommand = false
|
||||
Log.w(TAG, "barge-in microphone handoff failed: ${t.message}")
|
||||
surfaceError(t, context = "record")
|
||||
}
|
||||
} finally {
|
||||
if (listeningStartEpoch == captureEpoch) {
|
||||
pendingListeningStartJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingListeningStartJob = pendingStart
|
||||
pendingStart.start()
|
||||
}
|
||||
|
||||
private fun canStartBargeInCapture(
|
||||
captureEpoch: Long,
|
||||
interruptedMode: InteractionMode,
|
||||
interruptedEngine: VoiceEngineMode,
|
||||
): Boolean {
|
||||
val state = _uiState.value
|
||||
return listeningStartEpoch == captureEpoch &&
|
||||
state.voiceMode &&
|
||||
state.state == VoiceState.Listening &&
|
||||
state.interactionMode == interruptedMode &&
|
||||
voiceEngineMode == interruptedEngine &&
|
||||
responseInterruptedForVoiceCommand
|
||||
}
|
||||
|
||||
private fun abandonBargeInCaptureIfCurrent(captureEpoch: Long) {
|
||||
if (listeningStartEpoch != captureEpoch) return
|
||||
responseInterruptedForVoiceCommand = false
|
||||
if (_uiState.value.state == VoiceState.Listening && recorder?.isRecording() != true) {
|
||||
_uiState.update {
|
||||
it.copy(state = VoiceState.Idle, amplitude = 0f, outputAudioActive = false)
|
||||
responseInterruptedForVoiceCommand = false
|
||||
Log.w(TAG, "barge-in microphone handoff failed: ${t.message}")
|
||||
surfaceError(t, context = "record")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6433,22 +6344,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
startBargeInListenerIfEnabled()
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun stopBargeInListenerForTest(): Job? = stopBargeInListener()
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun finishAgentAudioOutputForTest() {
|
||||
finishAgentAudioOutput()
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun setVoiceEngineModeForTest(mode: VoiceEngineMode) {
|
||||
voiceEngineMode = mode
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun beginBargeInTurnForTest() {
|
||||
_uiState.update { it.copy(voiceMode = true, state = VoiceState.Thinking) }
|
||||
_uiState.update { it.copy(state = VoiceState.Thinking) }
|
||||
beginBargeInTurnIfEnabled()
|
||||
}
|
||||
|
||||
@@ -6629,13 +6527,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
detail = detail,
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Idle,
|
||||
amplitude = 0f,
|
||||
outputAudioActive = false,
|
||||
error = null,
|
||||
transcribedText = null,
|
||||
)
|
||||
voiceNoSpeechState(it)
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
+116
@@ -65,6 +65,122 @@ class AssistantSessionProtocolTest {
|
||||
assertEquals("Microphone unavailable", error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idleNoSpeech_isAVisibleRetryNotice() {
|
||||
val snapshot = AssistantSessionProtocol.snapshotFromVoiceState(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
assistantNotice = AssistantSessionNotice.NoSpeech,
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(AssistantSessionPhase.Idle, snapshot.phase)
|
||||
assertEquals(AssistantSessionNotice.NoSpeech, snapshot.notice)
|
||||
assertNull(snapshot.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lockedPresentation_redactsConversationButKeepsGenericNotice() {
|
||||
val presented = assistantSnapshotForPresentation(
|
||||
AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Error,
|
||||
transcript = "private request",
|
||||
response = "private response",
|
||||
notice = AssistantSessionNotice.NoSpeech,
|
||||
error = "private route detail",
|
||||
),
|
||||
locked = true,
|
||||
)
|
||||
|
||||
assertNull(presented.transcript)
|
||||
assertEquals("", presented.response)
|
||||
assertNull(presented.error)
|
||||
assertEquals(AssistantSessionNotice.NoSpeech, presented.notice)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlockedPresentation_restoresConversationContent() {
|
||||
val snapshot = AssistantSessionSnapshot(
|
||||
phase = AssistantSessionPhase.Speaking,
|
||||
transcript = "request",
|
||||
response = "response",
|
||||
)
|
||||
|
||||
assertEquals(snapshot, assistantSnapshotForPresentation(snapshot, locked = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveKeyguardState_overridesLaunchFallbackInBothDirections() {
|
||||
assertTrue(
|
||||
assistantPresentationLocked(
|
||||
currentKeyguardLocked = true,
|
||||
fallbackLocked = false,
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
assistantPresentationLocked(
|
||||
currentKeyguardLocked = false,
|
||||
fallbackLocked = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusSnapshots_areFencedToTheCurrentActivation() {
|
||||
assertTrue(assistantSnapshotMatchesActivation("activation-b", "activation-b"))
|
||||
assertFalse(assistantSnapshotMatchesActivation("activation-b", "activation-a"))
|
||||
assertFalse(assistantSnapshotMatchesActivation("activation-b", null))
|
||||
assertFalse(assistantSnapshotMatchesActivation(null, "activation-b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceStateRetainsItsOwningActivationAcrossLaterRuntimeChanges() {
|
||||
val activationA = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Listening,
|
||||
assistantActivationId = "activation-a",
|
||||
)
|
||||
val currentRuntimeActivation = "activation-b"
|
||||
|
||||
assertEquals("activation-a", activationA.assistantActivationId)
|
||||
assertFalse(
|
||||
assistantSnapshotMatchesActivation(
|
||||
expectedActivationId = currentRuntimeActivation,
|
||||
receivedActivationId = activationA.assistantActivationId,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalVoiceStateRetainsActivationForClosedPublication() {
|
||||
val exited = com.hermesandroid.relay.viewmodel.voiceSessionExitState(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Speaking,
|
||||
assistantActivationId = "activation-a",
|
||||
)
|
||||
)
|
||||
|
||||
assertFalse(exited.voiceMode)
|
||||
assertEquals("activation-a", exited.assistantActivationId)
|
||||
assertEquals(
|
||||
AssistantSessionPhase.Closed,
|
||||
AssistantSessionProtocol.snapshotFromVoiceState(exited).phase,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subsequentOrdinaryVoiceEntryClearsPreviousAssistantOwner() {
|
||||
val ordinaryEntry = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
assistantActivationId = null,
|
||||
)
|
||||
|
||||
assertNull(ordinaryEntry.assistantActivationId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedSessionMarker_expiresAfterBoundedRecoveryWindow() {
|
||||
val now = 2_000_000L
|
||||
|
||||
@@ -152,15 +152,4 @@ class RelayErrorClassifierTest {
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun microphoneOwnershipConflictMapsToRetryableMicUnavailableHint() {
|
||||
val err = classifyError(
|
||||
IllegalStateException("Microphone is in use by another voice feature"),
|
||||
context = "record",
|
||||
)
|
||||
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.assistant.AssistantSessionNotice
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -20,4 +23,35 @@ class VoiceCaptureGuardTest {
|
||||
fun acceptsSettledShortUtterance() {
|
||||
assertFalse(shouldDiscardVoiceCapture(durationMs = 420L, pcmBytes = 12_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSpeechReturnsToRetryableIdleWithDurableFeedback() {
|
||||
val state = voiceNoSpeechState(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Transcribing,
|
||||
transcribedText = "stale",
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(VoiceState.Idle, state.state)
|
||||
assertEquals(AssistantSessionNotice.NoSpeech, state.assistantNotice)
|
||||
assertNull(state.error)
|
||||
assertNull(state.transcribedText)
|
||||
assertTrue(state.voiceMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unrelatedCaptureCancellationDoesNotClaimNoSpeech() {
|
||||
val state = voiceCaptureCancellationState(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Listening,
|
||||
assistantNotice = AssistantSessionNotice.NoSpeech,
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(VoiceState.Idle, state.state)
|
||||
assertNull(state.assistantNotice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,8 @@ import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||
import com.hermesandroid.relay.data.BargeInPreferences
|
||||
import com.hermesandroid.relay.data.BargeInPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import io.mockk.every
|
||||
@@ -275,360 +273,6 @@ class VoiceViewModelBargeInTest {
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous completion waits for queue-drain barge-in release before capture`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
// The play worker tears down barge-in before the shared completion
|
||||
// finalizer runs. Repeated teardown calls must retain that first
|
||||
// asynchronous release instead of attempting VoiceCapture immediately.
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous completion repeats serialized handoff across turns`() = runTest {
|
||||
val firstRelease = Job()
|
||||
val secondRelease = Job()
|
||||
var stopCalls = 0
|
||||
every { bargeInListener.stop() } answers {
|
||||
when (stopCalls++) {
|
||||
0 -> firstRelease
|
||||
1 -> secondRelease
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("First."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
firstRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Second."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
|
||||
secondRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 2) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barge-in disabled keeps continuous completion immediate`() = runTest {
|
||||
val vm = buildViewModel(
|
||||
BargeInPreferences(
|
||||
enabled = false,
|
||||
sensitivity = BargeInSensitivity.Off,
|
||||
),
|
||||
)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit cancels continuous capture waiting for microphone release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.exitVoiceMode()
|
||||
vm.startBargeInListenerForTest()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
assertTrue(!vm.uiState.value.voiceMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause cancels continuous capture waiting for microphone release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.pauseContinuousMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid mode change starts only the newly armed continuous capture`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.setInteractionMode(InteractionMode.TapToTalk)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
runCurrent()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next barge-in generation waits for prior reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("First."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 2) { bargeInListener.start(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barge-in capture waits for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.exitVoiceMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertTrue(!vm.uiState.value.voiceMode)
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous pause invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.pauseContinuousMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interaction mode switch invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.setInteractionMode(InteractionMode.HoldToTalk)
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(InteractionMode.HoldToTalk, vm.uiState.value.interactionMode)
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engine switch invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.setVoiceEngineModeForTest(VoiceEngineMode.RealtimeAgent)
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer manual capture supersedes barge-in release waiter`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader release completion starts valid barge-in capture only once`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
assertTrue(readerRelease.complete())
|
||||
runCurrent()
|
||||
assertTrue(!readerRelease.complete())
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed barge-in recorder acquisition does not arm resume watchdog`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
every { recorder.startRecording() } throws
|
||||
IllegalStateException("Microphone is in use by another voice feature")
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking.", "Tail."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
advanceTimeBy(700)
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient recorder ownership failures remain explicitly retryable`() = runTest {
|
||||
var attempts = 0
|
||||
every { recorder.startRecording() } answers {
|
||||
attempts++
|
||||
if (attempts <= 2) {
|
||||
throw IllegalStateException("Microphone is in use by another voice feature")
|
||||
}
|
||||
java.io.File("voice-retry-test.wav")
|
||||
}
|
||||
val vm = buildViewModel(
|
||||
BargeInPreferences(
|
||||
enabled = false,
|
||||
sensitivity = BargeInSensitivity.Off,
|
||||
),
|
||||
)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
verify(exactly = 3) { recorder.startRecording() }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Test 2 — resume with resumeAfterInterruption=true + silence
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -2495,6 +2495,8 @@ boundary.
|
||||
- Activation heartbeats let the main runtime clean up after assistant-process loss.
|
||||
Finish and show-failure paths clear pending/watchdog state, while Full Voice
|
||||
explicitly transfers ownership before the session overlay stops heartbeats.
|
||||
A recreated session process requests the current activation-fenced voice
|
||||
snapshot rather than treating its empty local state as authoritative.
|
||||
- Connection, chat, and voice runtime ownership is application-lifetime in the
|
||||
main process rather than Activity-owned. The assistant service may initialize
|
||||
that graph and start a turn while no Activity exists; the app UI later binds
|
||||
@@ -2508,6 +2510,9 @@ boundary.
|
||||
|
||||
- Background and locked-screen invocation is mediated by Android's selected
|
||||
assistant UI/session rather than an ordinary background Activity launch.
|
||||
- Locked assistant UI exposes only generic phase and retry status. Transcript,
|
||||
response, route-specific errors, diagnostics, and screen context remain hidden
|
||||
until the device is unlocked; no-speech retry copy is deliberately content-free.
|
||||
- Users can leave Hermes selected for gesture/power-button invocation while
|
||||
turning continuous KWS off, or remove Hermes through Android's Assistant
|
||||
settings.
|
||||
|
||||
+6
-1
@@ -1015,7 +1015,10 @@ utilities.
|
||||
application-lifetime owner, allowing assistant activation to start cold
|
||||
without constructing or foregrounding `MainActivity`; full Voice later binds
|
||||
that same runtime. Cancel, error, app/process recreation, and session finish
|
||||
use the same scoped protocol. The
|
||||
use the same scoped protocol. Recreated session UI requests an
|
||||
activation-fenced snapshot from the app runtime, and capture/no-speech exits
|
||||
retain a generic retry notice instead of collapsing to an unexplained Ready
|
||||
state. The
|
||||
wake recorder is released before the established voice recorder opens, and
|
||||
assistant listening resumes only after the session exits. This mode is
|
||||
mutually exclusive with the experimental notification-based foreground
|
||||
@@ -1046,6 +1049,8 @@ utilities.
|
||||
Realtime Agent sessions do not claim inclusion. The mic control follows the
|
||||
active voice state, close remains separate, and **Open full voice** explicitly
|
||||
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.
|
||||
- `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
|
||||
|
||||
Reference in New Issue
Block a user