fix(android): refresh retained voice and chat text

This commit is contained in:
Vincent Koc
2026-07-12 09:29:29 +02:00
parent 1cf227cf19
commit 928d1935a6
5 changed files with 163 additions and 113 deletions

View File

@@ -6,7 +6,10 @@ import ai.openclaw.app.gateway.GatewayRequestNotEnqueued
import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveOptionalNativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.parseGatewayModels
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
import ai.openclaw.app.ui.chat.thinkingSupportedForSelection
@@ -120,8 +123,8 @@ class ChatController internal constructor(
private val _historyLoading = MutableStateFlow(false)
val historyLoading: StateFlow<Boolean> = _historyLoading.asStateFlow()
private val _errorText = MutableStateFlow<String?>(null)
val errorText: StateFlow<String?> = _errorText.asStateFlow()
private val _errorText = MutableStateFlow<NativeText?>(null)
val errorText: StateFlow<String?> = _errorText.resolveOptionalNativeText()
private val _healthOk = MutableStateFlow(false)
val healthOk: StateFlow<Boolean> = _healthOk.asStateFlow()
@@ -209,6 +212,14 @@ class ChatController internal constructor(
private fun updateErrorText(
message: String?,
historyGeneration: Long? = null,
) {
_errorText.value = message?.let(::verbatimText)
historyLoadErrorGeneration = historyGeneration
}
private fun updateLocalizedErrorText(
message: NativeText?,
historyGeneration: Long? = null,
) {
_errorText.value = message
historyLoadErrorGeneration = historyGeneration
@@ -702,7 +713,7 @@ class ChatController internal constructor(
val parentKey = normalizeRequestedSessionKey(_sessionKey.value)
if (parentKey.isEmpty()) return false
if (_pendingRunCount.value > 0) {
updateErrorText(nativeString("Wait for the current response to finish before starting a new chat."))
updateLocalizedErrorText(nativeText("Wait for the current response to finish before starting a new chat."))
return false
}
if (!newChatCreateInFlight.compareAndSet(false, true)) {
@@ -799,7 +810,7 @@ class ChatController internal constructor(
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
updateErrorText(err.message ?: nativeString("Could not update model."))
updateLocalizedErrorText(err.message?.let(::verbatimText) ?: nativeText("Could not update model."))
false
}
}
@@ -947,7 +958,7 @@ class ChatController internal constructor(
when (val outbox = commandOutbox) {
null -> {
if (!_healthOk.value) {
updateErrorText(nativeString("Gateway health not OK; cannot send"))
updateLocalizedErrorText(nativeText("Gateway health not OK; cannot send"))
return false
}
null
@@ -1078,7 +1089,7 @@ class ChatController internal constructor(
// Terminal timeout/error means the gateway did not accept a runnable turn.
// Surface failed acceptance instead of letting a cleared composer look successful.
unresolvedRepliesByRunId.remove(actualRunId)
updateErrorText(nativeString("Chat failed before the run started; try again."))
updateLocalizedErrorText(nativeText("Chat failed before the run started; try again."))
// The parked row owns the input; restoring the draft would duplicate it.
journaled != null
}
@@ -1821,7 +1832,7 @@ class ChatController internal constructor(
attachments: List<OutgoingAttachment>,
): ChatOutboxItem? {
if (outboxScope == null) {
updateErrorText(nativeString("Gateway health not OK; cannot send"))
updateLocalizedErrorText(nativeText("Gateway health not OK; cannot send"))
return null
}
val payloads =
@@ -1836,7 +1847,7 @@ class ChatController internal constructor(
)
}
} catch (_: IllegalArgumentException) {
updateErrorText(nativeString("Could not stage an attachment for sending."))
updateLocalizedErrorText(nativeText("Could not stage an attachment for sending."))
return null
}
// Slash commands are connection-gated: they may auto-send only inside the connection epoch
@@ -1856,7 +1867,7 @@ class ChatController internal constructor(
} catch (err: CancellationException) {
throw err
} catch (_: Throwable) {
updateErrorText(nativeString("Could not queue message for later delivery."))
updateLocalizedErrorText(nativeText("Could not queue message for later delivery."))
return null
}
return when (result) {
@@ -1866,19 +1877,19 @@ class ChatController internal constructor(
result.item
}
ChatOutboxEnqueueResult.QueueFull -> {
updateErrorText(nativeString("Offline queue is full (\$OUTBOX_MAX_QUEUED messages); delete queued items first.", OUTBOX_MAX_QUEUED))
updateLocalizedErrorText(nativeText("Offline queue is full (\$OUTBOX_MAX_QUEUED messages); delete queued items first.", OUTBOX_MAX_QUEUED))
null
}
ChatOutboxEnqueueResult.AttachmentsTooLarge -> {
updateErrorText(nativeString("Attachments are too large to queue for one message; remove some and try again."))
updateLocalizedErrorText(nativeText("Attachments are too large to queue for one message; remove some and try again."))
null
}
ChatOutboxEnqueueResult.StorageFull -> {
updateErrorText(nativeString("Offline attachment storage is full; delete queued items first."))
updateLocalizedErrorText(nativeText("Offline attachment storage is full; delete queued items first."))
null
}
ChatOutboxEnqueueResult.Unavailable -> {
updateErrorText(nativeString("Gateway health not OK; cannot send"))
updateLocalizedErrorText(nativeText("Gateway health not OK; cannot send"))
null
}
}
@@ -2484,7 +2495,13 @@ class ChatController internal constructor(
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
updateErrorText(if (state == "error") payload["errorMessage"].asStringOrNull() ?: nativeString("Chat failed") else null)
updateLocalizedErrorText(
if (state == "error") {
payload["errorMessage"].asStringOrNull()?.let(::verbatimText) ?: nativeText("Chat failed")
} else {
null
},
)
}
refreshCurrentHistoryBestEffort(updateSessionInfo = true)
return
@@ -2498,7 +2515,7 @@ class ChatController internal constructor(
return
}
if (state == "error") {
updateErrorText(payload["errorMessage"].asStringOrNull() ?: nativeString("Chat failed"))
updateLocalizedErrorText(payload["errorMessage"].asStringOrNull()?.let(::verbatimText) ?: nativeText("Chat failed"))
}
if (runId != null) {
clearPendingRun(runId)
@@ -2601,7 +2618,7 @@ class ChatController internal constructor(
}
}
"error" -> {
updateErrorText(nativeString("Event stream interrupted; try refreshing."))
updateLocalizedErrorText(nativeText("Event stream interrupted; try refreshing."))
clearPendingRuns()
pendingToolCallsById.clear()
publishPendingToolCalls()
@@ -2681,7 +2698,7 @@ class ChatController internal constructor(
unresolvedRepliesByRunId.remove(runId)
terminalWithoutReplyRunIds.remove(runId)
timedOutRunIds.add(runId)
updateErrorText(nativeString("Timed out waiting for a reply; try again or refresh."))
updateLocalizedErrorText(nativeText("Timed out waiting for a reply; try again or refresh."))
// The optimistic bubble is gone, so the journaled row must stay visible for review;
// history proof still retires it later if the turn did persist.
parkUnconfirmedDurableSend(runId)
@@ -2873,7 +2890,7 @@ class ChatController internal constructor(
unresolvedRunIds.forEach(::removeOptimisticMessage)
unresolvedRunIds.forEach(unresolvedRepliesByRunId::remove)
unresolvedRunIds.forEach(terminalWithoutReplyRunIds::remove)
updateErrorText(nativeString("Timed out confirming the sent message; refresh to check delivery."))
updateLocalizedErrorText(nativeText("Timed out confirming the sent message; refresh to check delivery."))
// Ownership expired without proof; keep the journaled copies visible for manual review.
for (unresolvedRunId in unresolvedRunIds) {
parkUnconfirmedDurableSend(unresolvedRunId)

View File

@@ -546,7 +546,12 @@ private fun TalkTranscript(
items(entries.takeLast(6), key = { it.id }) { entry ->
TalkTranscriptCard(
label = if (entry.role == VoiceConversationRole.User) nativeString("You") else nativeString("OpenClaw"),
text = if (entry.isStreaming && entry.text.isBlank()) nativeString("Listening response...") else entry.text,
text =
if (entry.isStreaming && entry.text.isBlank()) {
nativeString("Listening response...")
} else {
entry.localizedSource?.let(::nativeString) ?: entry.text
},
muted = entry.isStreaming,
)
}
@@ -1047,7 +1052,7 @@ private fun VoiceTurnCard(entry: VoiceConversationEntry) {
if (entry.isStreaming && entry.text.isBlank()) {
nativeString("Listening...")
} else {
entry.text
entry.localizedSource?.let(::nativeString) ?: entry.text
},
style = ClawTheme.type.body,
color = ClawTheme.colors.text,

View File

@@ -1,7 +1,9 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -39,6 +41,7 @@ data class VoiceConversationEntry(
val role: VoiceConversationRole,
val text: String,
val isStreaming: Boolean = false,
val localizedSource: String? = null,
)
internal data class GatewayTranscriptionSession(
@@ -88,8 +91,8 @@ internal class MicCaptureManager(
private val _isListening = MutableStateFlow(false)
val isListening: StateFlow<Boolean> = _isListening
private val _statusText = MutableStateFlow("Mic off")
val statusText: StateFlow<String> = _statusText
private val _statusText = MutableStateFlow<NativeText>(nativeText("Mic off"))
val statusText: StateFlow<String> = _statusText.resolveNativeText()
private val _liveTranscript = MutableStateFlow<String?>(null)
val liveTranscript: StateFlow<String?> = _liveTranscript
@@ -175,7 +178,7 @@ internal class MicCaptureManager(
}
}
if (pausedForTts) {
_statusText.value = if (_isSending.value) nativeString("Speaking · waiting for reply") else nativeString("Speaking…")
_statusText.value = if (_isSending.value) nativeText("Speaking · waiting for reply") else nativeText("Speaking…")
return
}
transcriptionDrainJob?.cancel()
@@ -226,7 +229,7 @@ internal class MicCaptureManager(
_isListening.value = false
_inputLevel.value = 0f
_liveTranscript.value = null
_statusText.value = if (_isSending.value) nativeString("Speaking · waiting for reply") else nativeString("Speaking…")
_statusText.value = if (_isSending.value) nativeText("Speaking · waiting for reply") else nativeText("Speaking…")
true
}
if (!shouldPause) return
@@ -245,10 +248,10 @@ internal class MicCaptureManager(
if (!resume) {
_statusText.value =
when {
_micEnabled.value && _isSending.value -> nativeString("Listening · sending queued voice")
_micEnabled.value -> nativeString("Listening")
_isSending.value -> nativeString("Mic off · sending…")
else -> nativeString("Mic off")
_micEnabled.value && _isSending.value -> nativeText("Listening · sending queued voice")
_micEnabled.value -> nativeText("Listening")
_isSending.value -> nativeText("Mic off · sending…")
else -> nativeText("Mic off")
}
}
resume
@@ -305,7 +308,7 @@ internal class MicCaptureManager(
_isSending.value = false
stopRequested = true
stopTranscription(preserveStatus = true)
_statusText.value = if (_micEnabled.value) nativeString("Mic on · waiting for gateway") else nativeString("Mic off")
_statusText.value = if (_micEnabled.value) nativeText("Mic on · waiting for gateway") else nativeText("Mic off")
}
internal fun submitTranscribedMessage(text: String) {
@@ -370,7 +373,12 @@ internal class MicCaptureManager(
completePendingTurn()
}
"aborted" -> {
upsertPendingAssistant(text = nativeString("Response aborted"), isStreaming = false)
val abortedText = nativeText("Response aborted")
upsertPendingAssistant(
text = abortedText.resolveNativeText(),
isStreaming = false,
localizedSource = abortedText.source,
)
completePendingTurn()
}
}
@@ -379,12 +387,12 @@ internal class MicCaptureManager(
private fun start() {
stopRequested = false
if (!hasMicPermission()) {
_statusText.value = nativeString("Microphone permission required")
_statusText.value = nativeText("Microphone permission required")
_micEnabled.value = false
return
}
if (!gatewayConnected) {
_statusText.value = nativeString("Mic on · waiting for gateway")
_statusText.value = nativeText("Mic on · waiting for gateway")
return
}
if (transcriptionSession != null || transcriptionStartJob?.isActive == true) return
@@ -409,7 +417,7 @@ internal class MicCaptureManager(
return@launch
}
val message = err.message ?: err::class.simpleName.orEmpty()
_statusText.value = nativeString("Transcription unavailable: \$message", message)
_statusText.value = nativeText("Transcription unavailable: \$message", message)
_micEnabled.value = false
stopTranscription(preserveStatus = true)
} finally {
@@ -448,7 +456,7 @@ internal class MicCaptureManager(
_isListening.value = false
_inputLevel.value = 0f
if (!preserveStatus) {
_statusText.value = if (_isSending.value) nativeString("Mic off · sending…") else nativeString("Mic off")
_statusText.value = if (_isSending.value) nativeText("Mic off · sending…") else nativeText("Mic off")
} else {
_statusText.value = status
}
@@ -499,9 +507,9 @@ internal class MicCaptureManager(
if (_isSending.value) return
if (!hasQueuedMessages()) {
if (_micEnabled.value) {
_statusText.value = nativeString("Listening")
_statusText.value = nativeText("Listening")
} else {
_statusText.value = nativeString("Mic off")
_statusText.value = nativeText("Mic off")
}
return
}
@@ -514,7 +522,7 @@ internal class MicCaptureManager(
_isSending.value = true
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
_statusText.value = if (_micEnabled.value) nativeString("Listening · sending queued voice") else nativeString("Sending queued voice")
_statusText.value = if (_micEnabled.value) nativeText("Listening · sending queued voice") else nativeText("Sending queued voice")
val sendGeneration = gatewayGeneration
sendJob =
@@ -539,7 +547,7 @@ internal class MicCaptureManager(
}
ack.isTerminalFailure -> {
completePendingTurn()
_statusText.value = nativeString("Send failed: Chat failed before the run started; try again.")
_statusText.value = nativeText("Send failed: Chat failed before the run started; try again.")
}
runId == null -> {
completePendingTurn()
@@ -561,7 +569,7 @@ internal class MicCaptureManager(
if (!gatewayConnected) {
queuedWaitingStatus()
} else {
"Send failed: ${err.message ?: err::class.simpleName}"
nativeText("Send failed: \$message", err.message ?: err::class.simpleName.orEmpty())
}
} finally {
if (sendGeneration == gatewayGeneration) {
@@ -582,7 +590,7 @@ internal class MicCaptureManager(
_isSending.value = false
_statusText.value =
if (gatewayConnected) {
"Voice reply timed out; retrying queued turn"
nativeText("Voice reply timed out; retrying queued turn")
} else {
queuedWaitingStatus()
}
@@ -602,17 +610,26 @@ internal class MicCaptureManager(
sendQueuedIfIdle()
}
private fun queuedWaitingStatus(): String = nativeString("\${queuedMessageCount()} queued · waiting for gateway", queuedMessageCount())
private fun queuedWaitingStatus(): NativeText = nativeText("\${queuedMessageCount()} queued · waiting for gateway", queuedMessageCount())
private fun appendConversation(
role: VoiceConversationRole,
text: String,
isStreaming: Boolean = false,
localizedSource: String? = null,
): String {
val id = UUID.randomUUID().toString()
_conversation.value =
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
.takeLast(maxConversationEntries)
(
_conversation.value +
VoiceConversationEntry(
id = id,
role = role,
text = text,
isStreaming = isStreaming,
localizedSource = localizedSource,
)
).takeLast(maxConversationEntries)
return id
}
@@ -620,6 +637,7 @@ internal class MicCaptureManager(
id: String,
text: String?,
isStreaming: Boolean,
localizedSource: String? = null,
) {
val current = _conversation.value
if (current.isEmpty()) return
@@ -633,15 +651,16 @@ internal class MicCaptureManager(
val entry = current[targetIndex]
val updatedText = text ?: entry.text
if (updatedText == entry.text && entry.isStreaming == isStreaming) return
if (updatedText == entry.text && entry.isStreaming == isStreaming && entry.localizedSource == localizedSource) return
val updated = current.toMutableList()
updated[targetIndex] = entry.copy(text = updatedText, isStreaming = isStreaming)
updated[targetIndex] = entry.copy(text = updatedText, isStreaming = isStreaming, localizedSource = localizedSource)
_conversation.value = updated
}
private fun upsertPendingAssistant(
text: String,
isStreaming: Boolean,
localizedSource: String? = null,
) {
val currentId = pendingAssistantEntryId
if (currentId == null) {
@@ -650,10 +669,16 @@ internal class MicCaptureManager(
role = VoiceConversationRole.Assistant,
text = text,
isStreaming = isStreaming,
localizedSource = localizedSource,
)
return
}
updateConversationEntry(id = currentId, text = text, isStreaming = isStreaming)
updateConversationEntry(
id = currentId,
text = text,
isStreaming = isStreaming,
localizedSource = localizedSource,
)
}
private fun playAssistantReplyAsync(text: String) {
@@ -775,16 +800,16 @@ internal class MicCaptureManager(
message: String,
) {
if (transcriptionSession != session) return
_statusText.value = nativeString("Transcription failed: \$message", message)
_statusText.value = nativeText("Transcription failed: \$message", message)
_micEnabled.value = false
stopTranscription(preserveStatus = true)
}
private fun listeningStatus(): String =
private fun listeningStatus(): NativeText =
when {
_isSending.value -> nativeString("Listening · sending queued voice")
hasQueuedMessages() -> nativeString("Listening · \${queuedMessageCount()} queued", queuedMessageCount())
else -> nativeString("Listening")
_isSending.value -> nativeText("Listening · sending queued voice")
hasQueuedMessages() -> nativeText("Listening · \${queuedMessageCount()} queued", queuedMessageCount())
else -> nativeText("Listening")
}
private fun pcm16ToPcmu(pcm16: ByteArray): ByteArray {

View File

@@ -4,7 +4,9 @@ import ai.openclaw.app.gateway.ChatSendAck
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -196,8 +198,8 @@ class TalkModeManager internal constructor(
private val _speechActive = MutableStateFlow(false)
val speechActive: StateFlow<Boolean> = _speechActive
private val _statusText = MutableStateFlow("Off")
val statusText: StateFlow<String> = _statusText
private val _statusText = MutableStateFlow<NativeText>(nativeText("Off"))
val statusText: StateFlow<String> = _statusText.resolveNativeText()
// Typed "waiting on the agent" signal for the waveform's Thinking phase, so
// UI never has to parse status strings. Every status change flows through
@@ -206,7 +208,7 @@ class TalkModeManager internal constructor(
val awaitingAgent: StateFlow<Boolean> = _awaitingAgent
private fun setStatus(
text: String,
text: NativeText,
awaitingAgent: Boolean = false,
) {
_statusText.value = text
@@ -451,7 +453,7 @@ class TalkModeManager internal constructor(
throw IllegalStateException("PTT_BUSY: previous push-to-talk turn is still finishing")
}
if (!isConnected()) {
setStatus("Gateway not connected")
setStatus(nativeText("Gateway not connected"))
throw IllegalStateException("UNAVAILABLE: Gateway not connected")
}
@@ -459,11 +461,11 @@ class TalkModeManager internal constructor(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) {
setStatus("Microphone permission required")
setStatus(nativeText("Microphone permission required"))
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
setStatus("Speech recognizer unavailable")
setStatus(nativeText("Speech recognizer unavailable"))
throw IllegalStateException("UNAVAILABLE: Speech recognizer unavailable")
}
@@ -524,10 +526,10 @@ class TalkModeManager internal constructor(
pttCompletion = null
completion?.cancel()
resumeRealtimeCaptureAfterPushToTalk(captureId)
setStatus(if (_isEnabled.value) nativeString("Listening") else nativeString("Ready"))
setStatus(if (_isEnabled.value) nativeText("Listening") else nativeText("Ready"))
throw err
}
setStatus("Listening (PTT)")
setStatus(nativeText("Listening (PTT)"))
if (autoStopAfterMs != null) {
pttAutoStopEnabled = true
// Install one-shot jobs before yielding to lifecycle changes. Otherwise a
@@ -565,7 +567,7 @@ class TalkModeManager internal constructor(
val transcript = cleared.transcript
if (transcript.isEmpty()) {
setStatus(if (_isEnabled.value) nativeString("Listening") else nativeString("Ready"))
setStatus(if (_isEnabled.value) nativeText("Listening") else nativeText("Ready"))
resumeRealtimeCaptureAfterPushToTalk(captureId)
return@withContext finishPushToTalk(
TalkPttStopPayload(captureId = captureId, transcript = null, status = "empty"),
@@ -574,7 +576,7 @@ class TalkModeManager internal constructor(
}
if (!isConnected()) {
setStatus("Gateway not connected")
setStatus(nativeText("Gateway not connected"))
resumeRealtimeCaptureAfterPushToTalk(captureId)
return@withContext finishPushToTalk(
TalkPttStopPayload(captureId = captureId, transcript = transcript, status = "offline"),
@@ -582,7 +584,7 @@ class TalkModeManager internal constructor(
)
}
setStatus("Thinking…", awaitingAgent = true)
setStatus(nativeText("Thinking…"), awaitingAgent = true)
lateinit var finishingJob: Job
finishingJob =
// Gateway-scoped so a switch drops the stale finalize; the NonCancellable
@@ -624,7 +626,7 @@ class TalkModeManager internal constructor(
val cleared =
clearPushToTalkRecognition(captureId)
?: return@withContext TalkPttStopPayload(captureId = captureId, transcript = null, status = "idle")
setStatus(if (_isEnabled.value) nativeString("Listening") else nativeString("Ready"))
setStatus(if (_isEnabled.value) nativeText("Listening") else nativeText("Ready"))
resumeRealtimeCaptureAfterPushToTalk(captureId)
finishPushToTalk(
TalkPttStopPayload(captureId = captureId, transcript = null, status = "cancelled"),
@@ -833,7 +835,7 @@ class TalkModeManager internal constructor(
startRealtimeRelay(generation)
} catch (err: Throwable) {
if (err is CancellationException) return@launch
setStatus("Start failed: ${err.message ?: err::class.simpleName}")
setStatus(nativeText("Start failed: \$message", err.message ?: err::class.simpleName.orEmpty()))
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
stopRealtimeRelay(closeSession = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
@@ -862,7 +864,7 @@ class TalkModeManager internal constructor(
lastTranscript = ""
lastHeardAtMs = null
_isListening.value = false
setStatus("Off")
setStatus(nativeText("Off"))
stopRealtimeRelay()
stopSpeaking()
pendingRunId = null
@@ -885,7 +887,7 @@ class TalkModeManager internal constructor(
withTimeout(timeoutMs) {
while (true) {
realtimeSessionId?.let { return@withTimeout it }
val status = _statusText.value
val status = _statusText.value.resolveNativeText()
if (!_isEnabled.value && status != "Off") {
throw IllegalStateException(status)
}
@@ -896,7 +898,7 @@ class TalkModeManager internal constructor(
private suspend fun startRealtimeRelay(generation: Long) {
if (!isConnected()) {
setStatus("Gateway not connected")
setStatus(nativeText("Gateway not connected"))
Log.w(tag, "realtime start: gateway not connected")
disableRealtimeModeAndNotifyOwner()
return
@@ -906,7 +908,7 @@ class TalkModeManager internal constructor(
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) {
setStatus("Microphone permission required")
setStatus(nativeText("Microphone permission required"))
Log.w(tag, "realtime start: microphone permission required")
disableRealtimeModeAndNotifyOwner()
return
@@ -923,7 +925,7 @@ class TalkModeManager internal constructor(
}
}
setStatus("Connecting…", awaitingAgent = true)
setStatus(nativeText("Connecting…"), awaitingAgent = true)
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
@@ -956,7 +958,7 @@ class TalkModeManager internal constructor(
} else {
realtimeOutputSuppressed = false
_isListening.value = true
setStatus("Listening")
setStatus(nativeText("Listening"))
startRealtimeCaptureLocked(sessionId)
false
}
@@ -980,16 +982,16 @@ class TalkModeManager internal constructor(
message: String,
) {
if (realtimeSessionId != sessionId) return
setStatus("Talk failed: $message")
setStatus(nativeText("Talk failed: \$message", message))
stopRealtimeRelay(cancelCapture = false, cancelAppend = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
private fun realtimeCloseStatusText(reason: String?): String =
private fun realtimeCloseStatusText(reason: String?): NativeText =
when (reason) {
null, "completed" -> nativeString("Off")
"error" -> nativeString("Talk failed: Realtime provider closed unexpectedly.")
else -> nativeString("Talk failed: Realtime provider closed: \$reason", reason)
null, "completed" -> nativeText("Off")
"error" -> nativeText("Talk failed: Realtime provider closed unexpectedly.")
else -> nativeText("Talk failed: Realtime provider closed: \$reason", reason)
}
/** Caller holds [realtimeCapturePauseLock] so PTT cannot miss newly installed jobs. */
@@ -1077,7 +1079,7 @@ class TalkModeManager internal constructor(
"ready" -> {
if (isRealtimeCapturePaused()) return
_isListening.value = true
setStatus("Listening")
setStatus(nativeText("Listening"))
}
"inputAudio" -> {
synchronized(realtimeCapturePauseLock) {
@@ -1129,7 +1131,7 @@ class TalkModeManager internal constructor(
_lastAssistantText.value = assistantText.trim()
}
if (isFinal && role == "user") {
setStatus("Thinking…", awaitingAgent = true)
setStatus(nativeText("Thinking…"), awaitingAgent = true)
} else if (isFinal && role == "assistant") {
scheduleRealtimePlaybackIdle()
}
@@ -1147,14 +1149,14 @@ class TalkModeManager internal constructor(
"toolResult" -> Unit
"error" -> {
val message = obj["message"].asStringOrNull() ?: "realtime talk error"
setStatus("Talk failed: $message")
setStatus(nativeText("Talk failed: \$message", message))
Log.w(tag, "realtime error: $message")
}
"close" -> {
val closeReason = obj["reason"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty)
val currentStatus = _statusText.value
val closeStatus =
if (currentStatus.startsWith("Talk failed:")) currentStatus else realtimeCloseStatusText(closeReason)
if (currentStatus.resolveNativeText().startsWith("Talk failed:")) currentStatus else realtimeCloseStatusText(closeReason)
Log.d(tag, "realtime close reason=$closeReason")
stopRealtimeRelay(closeSession = false, preserveStatus = true)
if (_isEnabled.value) {
@@ -1267,7 +1269,7 @@ class TalkModeManager internal constructor(
_outputLevel.value =
TalkAudioLevel.smoothed(_outputLevel.value ?: 0f, TalkAudioLevel.pcm16Level(bytes, writtenBytes))
_isSpeaking.value = true
setStatus("Speaking…")
setStatus(nativeText("Speaking…"))
val durationMs = ((writtenBytes / 2.0) / realtimeSampleRateHz * 1000.0).toLong()
val now = SystemClock.elapsedRealtime()
realtimePlaybackEndsAtMs = maxOf(now, realtimePlaybackEndsAtMs) + durationMs
@@ -1291,7 +1293,7 @@ class TalkModeManager internal constructor(
playbackIdle
}
if (idle && _isEnabled.value && realtimeSessionId != null) {
setStatus("Listening")
setStatus(nativeText("Listening"))
}
}
}
@@ -1321,7 +1323,7 @@ class TalkModeManager internal constructor(
_isSpeaking.value = false
_outputLevel.value = null
if (_isEnabled.value) {
setStatus("Listening")
setStatus(nativeText("Listening"))
}
}
@@ -1438,7 +1440,7 @@ class TalkModeManager internal constructor(
}
realtimeCapturePause = null
_isListening.value = true
setStatus("Listening")
setStatus(nativeText("Listening"))
startRealtimeCaptureLocked(sessionId)
RealtimeCaptureResume.Resumed
}
@@ -1447,7 +1449,7 @@ class TalkModeManager internal constructor(
RealtimeCaptureResume.Resumed -> return
RealtimeCaptureResume.Restart -> start()
RealtimeCaptureResume.Disconnected -> {
setStatus("Gateway not connected")
setStatus(nativeText("Gateway not connected"))
stopRealtimeRelay(preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
@@ -1498,7 +1500,7 @@ class TalkModeManager internal constructor(
if (!runId.isNullOrBlank()) {
when (val registration = registerRealtimeToolRun(runId, callId, relaySessionId)) {
RealtimeToolRegistration.SessionEnded -> return@launch
RealtimeToolRegistration.AwaitingCompletion -> setStatus("Thinking…", awaitingAgent = true)
RealtimeToolRegistration.AwaitingCompletion -> setStatus(nativeText("Thinking…"), awaitingAgent = true)
is RealtimeToolRegistration.Completed ->
dispatchRealtimeToolCompletion(registration.dispatch)
}
@@ -1929,7 +1931,7 @@ class TalkModeManager internal constructor(
}
if (markListening) {
setStatus("Listening")
setStatus(nativeText("Listening"))
_isListening.value = true
}
r.startListening(intent)
@@ -2021,7 +2023,7 @@ class TalkModeManager internal constructor(
private suspend fun finalizeTranscript(transcript: String) {
listeningMode = false
_isListening.value = false
setStatus("Thinking…", awaitingAgent = true)
setStatus(nativeText("Thinking…"), awaitingAgent = true)
lastTranscript = ""
lastHeardAtMs = null
// Release SpeechRecognizer before making the API call and playing TTS.
@@ -2038,7 +2040,7 @@ class TalkModeManager internal constructor(
ensureConfigLoaded()
val prompt = buildPrompt(transcript)
if (!isConnected()) {
setStatus("Gateway not connected")
setStatus(nativeText("Gateway not connected"))
Log.w(tag, "finalize: gateway not connected")
start()
return
@@ -2051,7 +2053,7 @@ class TalkModeManager internal constructor(
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
if (ack.isTerminalFailure) {
setStatus(if (ack.normalizedStatus == "error") nativeString("Chat error") else nativeString("Aborted"))
setStatus(if (ack.normalizedStatus == "error") nativeText("Chat error") else nativeText("Aborted"))
start()
return
}
@@ -2068,7 +2070,7 @@ class TalkModeManager internal constructor(
if (ok) 12_000 else 25_000,
)
if (assistant.isNullOrBlank()) {
setStatus("No reply")
setStatus(nativeText("No reply"))
Log.w(tag, "assistant text timeout runId=$runId")
start()
return
@@ -2084,7 +2086,7 @@ class TalkModeManager internal constructor(
Log.d(tag, "finalize speech cancelled")
return
}
setStatus("Talk failed: ${err.message ?: err::class.simpleName}")
setStatus(nativeText("Talk failed: \$message", err.message ?: err::class.simpleName.orEmpty()))
Log.w(tag, "finalize failed: ${err.message ?: err::class.simpleName}")
}
@@ -2309,7 +2311,7 @@ class TalkModeManager internal constructor(
_lastAssistantText.value = cleaned
ensurePlaybackActive(playbackToken)
setStatus("Generating voice…", awaitingAgent = true)
setStatus(nativeText("Generating voice…"), awaitingAgent = true)
_isSpeaking.value = false
lastSpokenText = cleaned
@@ -2337,7 +2339,7 @@ class TalkModeManager internal constructor(
Log.d(tag, "assistant speech cancelled")
return
}
setStatus("Speak failed: ${err.message ?: err::class.simpleName}")
setStatus(nativeText("Speak failed: \$message", err.message ?: err::class.simpleName.orEmpty()))
Log.w(tag, "talk playback failed: ${err.message ?: err::class.simpleName}")
} finally {
_isSpeaking.value = false
@@ -2485,7 +2487,7 @@ class TalkModeManager internal constructor(
private fun markAudioPlaybackStarting(playbackToken: Long) {
ensurePlaybackActive(playbackToken)
setStatus("Speaking…")
setStatus(nativeText("Speaking…"))
_isSpeaking.value = true
ensureInterruptListener()
requestAudioFocusForTts()
@@ -2497,7 +2499,7 @@ class TalkModeManager internal constructor(
scope.launch { cancelRealtimeOutput(reason = "android-stop-tts") }
stopSpeaking(resetInterrupt = true)
_isSpeaking.value = false
setStatus("Listening")
setStatus(nativeText("Listening"))
}
private suspend fun cancelRealtimeOutput(reason: String): Boolean =
@@ -2775,7 +2777,7 @@ class TalkModeManager internal constructor(
// Only a live listening session may claim the status; a speech-interrupt
// recognizer readying during playback must not touch Thinking state.
if (_isEnabled.value && _isListening.value) {
setStatus("Listening")
setStatus(nativeText("Listening"))
}
}
@@ -2798,21 +2800,21 @@ class TalkModeManager internal constructor(
if (stopRequested) return
_isListening.value = false
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
setStatus(nativeString("Microphone permission required"))
setStatus(nativeText("Microphone permission required"))
return
}
setStatus(
when (error) {
SpeechRecognizer.ERROR_AUDIO -> nativeString("Audio error")
SpeechRecognizer.ERROR_CLIENT -> nativeString("Client error")
SpeechRecognizer.ERROR_NETWORK -> nativeString("Network error")
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> nativeString("Network timeout")
SpeechRecognizer.ERROR_NO_MATCH -> nativeString("Listening")
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> nativeString("Recognizer busy")
SpeechRecognizer.ERROR_SERVER -> nativeString("Server error")
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> nativeString("Listening")
else -> nativeString("Speech error (\$error)", error)
SpeechRecognizer.ERROR_AUDIO -> nativeText("Audio error")
SpeechRecognizer.ERROR_CLIENT -> nativeText("Client error")
SpeechRecognizer.ERROR_NETWORK -> nativeText("Network error")
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> nativeText("Network timeout")
SpeechRecognizer.ERROR_NO_MATCH -> nativeText("Listening")
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> nativeText("Recognizer busy")
SpeechRecognizer.ERROR_SERVER -> nativeText("Server error")
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> nativeText("Listening")
else -> nativeText("Speech error (\$error)", error)
},
)
scheduleRestart(delayMs = 600)

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.gateway.DeviceAuthEntry
import ai.openclaw.app.gateway.DeviceAuthTokenStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.i18n.nativeText
import android.Manifest
import android.content.ComponentName
import android.content.IntentFilter
@@ -321,7 +322,7 @@ class TalkModeManagerTest {
setPrivateField(manager, "realtimeSessionId", "relay-1")
setMutableStateFlow(manager, "_isEnabled", true)
setMutableStateFlow(manager, "_statusText", "Talk failed: Provider rejected the session.")
setMutableStateFlow(manager, "_statusText", nativeText("Talk failed: \$message", "Provider rejected the session."))
manager.handleGatewayEvent(
"talk.event",
@@ -773,7 +774,7 @@ class TalkModeManagerTest {
setPrivateField(pause, "sessionId", "relay-1")
setPrivateField(manager, "realtimeSessionId", "relay-1")
setMutableStateFlow(manager, "_isListening", false)
setMutableStateFlow(manager, "_statusText", "Thinking…")
setMutableStateFlow(manager, "_statusText", nativeText("Thinking…"))
manager.resumeRealtimeCaptureAfterPushToTalk("capture-1")
@@ -829,7 +830,7 @@ class TalkModeManagerTest {
val pause = readPrivateField(manager, "realtimeCapturePause")!!
setPrivateField(pause, "restartRelay", true)
setPrivateField(manager, "stopRequested", true)
setMutableStateFlow(manager, "_statusText", "Off")
setMutableStateFlow(manager, "_statusText", nativeText("Off"))
manager.resumeRealtimeCaptureAfterPushToTalk("capture-1")
@@ -901,7 +902,7 @@ class TalkModeManagerTest {
setPrivateField(pause, "sessionId", "relay-1")
setPrivateField(manager, "realtimeSessionId", "relay-1")
setMutableStateFlow(manager, "_isListening", false)
setMutableStateFlow(manager, "_statusText", "Gateway not connected")
setMutableStateFlow(manager, "_statusText", nativeText("Gateway not connected"))
manager.resumeRealtimeCaptureAfterPushToTalk("capture-1")