diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index d703384a91c5..de862d303b38 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -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 = _historyLoading.asStateFlow() - private val _errorText = MutableStateFlow(null) - val errorText: StateFlow = _errorText.asStateFlow() + private val _errorText = MutableStateFlow(null) + val errorText: StateFlow = _errorText.resolveOptionalNativeText() private val _healthOk = MutableStateFlow(false) val healthOk: StateFlow = _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, ): 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) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt index a20bfa842fb1..aa36e0957f64 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceScreen.kt @@ -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, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt index 0f1d8503e677..f66e32b89f40 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/MicCaptureManager.kt @@ -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 = _isListening - private val _statusText = MutableStateFlow("Mic off") - val statusText: StateFlow = _statusText + private val _statusText = MutableStateFlow(nativeText("Mic off")) + val statusText: StateFlow = _statusText.resolveNativeText() private val _liveTranscript = MutableStateFlow(null) val liveTranscript: StateFlow = _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 { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 69268c972bdd..305f12f75418 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -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 = _speechActive - private val _statusText = MutableStateFlow("Off") - val statusText: StateFlow = _statusText + private val _statusText = MutableStateFlow(nativeText("Off")) + val statusText: StateFlow = _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 = _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) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt index c8bf3d971afb..d315d3b950f4 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt @@ -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")