fix(android): localize connection chat and voice states

This commit is contained in:
Vincent Koc
2026-07-12 07:47:16 +02:00
committed by Vincent Koc
parent 26e28ab2e3
commit bd81763cd0
10 changed files with 104 additions and 73 deletions
@@ -126,8 +126,9 @@ class NodeForegroundService : Service() {
state.connected -> nativeString("OpenClaw Node · Connected")
else -> nativeString("OpenClaw Node")
}
val displayStatus = gatewayConnectionStatusForDisplay(state.status)
val text =
(state.server?.let { "${state.status} · $it" } ?: state.status) +
(state.server?.let { nativeString("\$status · \$server", displayStatus, it) } ?: displayStatus) +
voiceNotificationSuffix(
mode = state.mode,
manualMicEnabled = state.capture.micEnabled,
@@ -319,6 +319,29 @@ data class GatewayConnectionDisplay(
val problem: GatewayConnectionProblem?,
)
private const val GATEWAY_STATUS_OFFLINE = "Offline"
private const val GATEWAY_STATUS_CONNECTED = "Connected"
private const val GATEWAY_STATUS_NODE_OFFLINE = "Connected (node offline)"
private const val GATEWAY_STATUS_OPERATOR_OFFLINE = "Connected (operator offline)"
private fun gatewayOperatorConnectionState(operator: String): String = "Connected (operator: $operator)"
internal fun gatewayConnectionStatusForDisplay(statusText: String): String {
val status = statusText.trim()
return when {
status.isEmpty() || status == GATEWAY_STATUS_OFFLINE -> nativeString("Offline")
status == GATEWAY_STATUS_CONNECTED -> nativeString("Connected")
status == GATEWAY_STATUS_NODE_OFFLINE -> nativeString("Connected (node offline)")
status == GATEWAY_STATUS_OPERATOR_OFFLINE -> nativeString("Connected (operator offline)")
status.startsWith("Connected (operator: ") && status.endsWith(")") ->
nativeString(
"Connected (operator: \$operator)",
status.removePrefix("Connected (operator: ").dropLast(1),
)
else -> status
}
}
private fun gatewayProblemAfterDisconnect(
problem: GatewayConnectionProblem?,
statusText: String,
@@ -337,16 +360,16 @@ internal fun gatewayConnectionDisplay(
val operator = operatorStatusText.trim()
val node = nodeStatusText.trim()
return when {
operatorConnected && nodeConnected -> GatewayConnectionDisplay(true, "Connected", null)
operatorConnected -> GatewayConnectionDisplay(true, "Connected (node offline)", nodeProblem)
operatorConnected && nodeConnected -> GatewayConnectionDisplay(true, GATEWAY_STATUS_CONNECTED, null)
operatorConnected -> GatewayConnectionDisplay(true, GATEWAY_STATUS_NODE_OFFLINE, nodeProblem)
nodeConnected ->
GatewayConnectionDisplay(
isConnected = false,
statusText =
if (operator.isNotEmpty() && operator != "Offline") {
"Connected (operator: $operator)"
gatewayOperatorConnectionState(operator)
} else {
"Connected (operator offline)"
GATEWAY_STATUS_OPERATOR_OFFLINE
},
problem = operatorProblem,
)
@@ -697,9 +720,9 @@ class NodeRuntime private constructor(
private val _nodeCapabilityApproval = MutableStateFlow<GatewayNodeCapabilityApproval>(GatewayNodeCapabilityApproval.Loading)
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> = _nodeCapabilityApproval.asStateFlow()
private val _gatewayConnectionDisplay = MutableStateFlow(GatewayConnectionDisplay(false, "Offline", null))
private val _gatewayConnectionDisplay = MutableStateFlow(GatewayConnectionDisplay(false, GATEWAY_STATUS_OFFLINE, null))
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> = _gatewayConnectionDisplay.asStateFlow()
private val _statusText = MutableStateFlow("Offline")
private val _statusText = MutableStateFlow(GATEWAY_STATUS_OFFLINE)
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = _gatewayConnectionProblem.asStateFlow()
@@ -424,9 +424,9 @@ private fun gatewayManualTransportPresentation(
effectiveTls = effectiveTls,
helperText =
when {
requiresTls -> "Secure connection is required for this host."
requiresTls -> nativeString("Secure connection is required for this host.")
effectiveTls -> null
else -> "Use only on a trusted private network."
else -> nativeString("Use only on a trusted private network.")
},
)
@@ -4,6 +4,7 @@ import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeCapabilityApproval
import ai.openclaw.app.gatewayConnectionStatusForDisplay
import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId
import ai.openclaw.app.i18n.nativeString
import android.content.ClipData
@@ -23,7 +24,7 @@ internal fun openClawAndroidVersionLabel(): String {
}
/** Normalizes blank gateway status text for display and diagnostics copy. */
internal fun gatewayStatusForDisplay(statusText: String): String = statusText.trim().ifEmpty { nativeString("Offline") }
internal fun gatewayStatusForDisplay(statusText: String): String = gatewayConnectionStatusForDisplay(statusText)
/** Resolves the best non-secret endpoint label available to diagnostics surfaces. */
internal fun gatewayDiagnosticsEndpoint(
@@ -38,7 +39,7 @@ internal fun gatewayDiagnosticsEndpoint(
/** Detects pairing/approval status text so UI can offer pairing-specific actions. */
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
val lower = statusText.trim().lowercase()
return lower.contains("pair") || lower.contains("approve")
}
@@ -136,7 +137,7 @@ internal fun buildGatewayDiagnosticsReport(
.orEmpty()
.ifEmpty { Build.VERSION.SDK_INT.toString() }
val endpoint = gatewayAddress.trim().ifEmpty { "unknown" }
val status = gatewayStatusForDisplay(statusText)
val status = statusText.trim().ifEmpty { "Offline" }
return """
Help diagnose this OpenClaw Android gateway connection failure.
@@ -532,7 +532,7 @@ fun OnboardingFlow(
) {
val trimmed = code.trim()
if (trimmed.isEmpty()) {
setupError = "Enter the setup code from openclaw qr."
setupError = nativeString("Enter the setup code from openclaw qr.")
return
}
val plan =
@@ -556,7 +556,7 @@ fun OnboardingFlow(
setupError =
endpointError?.let {
gatewayEndpointValidationMessage(it, GatewayEndpointInputSource.SETUP_CODE)
} ?: "Setup code was not accepted. Generate a fresh code with openclaw qr."
} ?: nativeString("Setup code was not accepted. Generate a fresh code with openclaw qr.")
return
}
connectGateway(plan = plan, inputSource = inputSource)
@@ -574,7 +574,7 @@ fun OnboardingFlow(
GatewayEndpointValidationError.IPV6_ZONE_ID_UNSUPPORTED,
->
gatewayEndpointValidationMessage(scanned.error, GatewayEndpointInputSource.QR_SCAN)
else -> "That QR code is not an OpenClaw setup QR. Generate a fresh code with openclaw qr, then try again."
else -> nativeString("That QR code is not an OpenClaw setup QR. Generate a fresh code with openclaw qr, then try again.")
}
showSetupScanError(message)
return
@@ -586,7 +586,7 @@ fun OnboardingFlow(
fun pairFromManualFields() {
if (manualTokenLooksLikeSetupCode(token)) {
setupError = "That looks like a setup code. Go back and choose Setup Gateway, then Use setup code."
setupError = nativeString("That looks like a setup code. Go back and choose Setup Gateway, then Use setup code.")
return
}
val transport =
@@ -637,7 +637,7 @@ fun OnboardingFlow(
try {
InputImage.fromFilePath(context, uri)
} catch (_: Exception) {
showSetupScanError("Could not read that image. Choose a clear screenshot or image of the QR from openclaw qr.")
showSetupScanError(nativeString("Could not read that image. Choose a clear screenshot or image of the QR from openclaw qr."))
return@rememberLauncherForActivityResult
}
setupBarcodeScanner
@@ -645,12 +645,12 @@ fun OnboardingFlow(
.addOnSuccessListener { barcodes ->
val rawValue = barcodes.firstNotNullOfOrNull { barcode -> barcode.rawValue?.takeIf { it.isNotBlank() } }
if (rawValue == null) {
showSetupScanError("No setup QR code was found in that image. Choose the QR generated by openclaw qr, or enter the setup code manually.")
showSetupScanError(nativeString("No setup QR code was found in that image. Choose the QR generated by openclaw qr, or enter the setup code manually."))
return@addOnSuccessListener
}
handleScannedSetupCode(rawValue, inputSource = OnboardingGatewayInputSource.SetupGallery)
}.addOnFailureListener {
showSetupScanError("Could not read a QR code from that image. Choose a clearer image or enter the setup code manually.")
showSetupScanError(nativeString("Could not read a QR code from that image. Choose a clearer image or enter the setup code manually."))
}
}
@@ -754,7 +754,7 @@ fun OnboardingFlow(
onRequestCameraPermission = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) },
onCodeScanned = { rawValue -> handleScannedSetupCode(rawValue, inputSource = OnboardingGatewayInputSource.SetupScanner) },
onCameraError = {
showSetupScanError("Could not start the camera. Choose a QR image from gallery or enter the setup code manually.")
showSetupScanError(nativeString("Could not start the camera. Choose a QR image from gallery or enter the setup code manually."))
},
onCloseScanner = { inlineQrScannerActive = false },
onChooseFromGallery = {
@@ -2559,13 +2559,13 @@ private fun finishingGatewayProgressItems(
/** Detects gateway-approved states where the Android node is still coming online. */
internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
val lower = statusText.trim().lowercase()
return lower.contains("operator offline") || lower.contains("node offline")
}
/** Detects explicit endpoint/auth failures surfaced as status text without structured details. */
internal fun gatewayStatusLooksLikeFailure(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
val lower = statusText.trim().lowercase()
return lower.startsWith("failed:") || lower.startsWith("error:") || lower.startsWith("gateway error:")
}
@@ -2616,25 +2616,27 @@ internal fun recoveryGatewayDetail(
gatewayConnectionProblem: GatewayConnectionProblem?,
): String =
if (ready) {
remoteAddress?.takeIf { it.isNotBlank() } ?: "Ready for chat and voice"
remoteAddress?.takeIf { it.isNotBlank() } ?: nativeString("Ready for chat and voice")
} else if (nodeCapabilityApprovalNeedsUserAction(nodeCapabilityApproval)) {
"Gateway paired. Waiting for node capability approval."
nativeString("Gateway paired. Waiting for node capability approval.")
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
?.let { "Gateway approval is pending. Run this on the gateway host:" }
?: "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry."
?.let { nativeString("Gateway approval is pending. Run this on the gateway host:") }
?: nativeString(
"Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry.",
)
} else if (gatewayConnectionProblem?.isPairingRequired == true && gatewayConnectionProblem.canAutoRetry) {
"Gateway approval is in progress. OpenClaw will retry automatically."
nativeString("Gateway approval is in progress. OpenClaw will retry automatically.")
} else if (gatewayConnectionProblem != null) {
recoveryGatewayAuthDetail(gatewayConnectionProblem)
} else if (nodeCapabilityApproval == GatewayNodeCapabilityApproval.Loading) {
"Gateway paired. Checking node capability approval."
nativeString("Gateway paired. Checking node capability approval.")
} else if (statusText.contains("operator offline", ignoreCase = true)) {
"Gateway paired. Waiting for operator access."
nativeString("Gateway paired. Waiting for operator access.")
} else if (gatewayStatusLooksLikePairing(statusText)) {
"Gateway approval is in progress. OpenClaw will retry automatically."
nativeString("Gateway approval is in progress. OpenClaw will retry automatically.")
} else {
remoteAddress?.takeIf { it.isNotBlank() } ?: "Gateway unreachable"
remoteAddress?.takeIf { it.isNotBlank() } ?: nativeString("Gateway unreachable")
}
internal fun recoveryGatewayAuthDetail(gatewayConnectionProblem: GatewayConnectionProblem): String =
@@ -2655,7 +2657,7 @@ internal fun recoveryGatewayAuthDetail(gatewayConnectionProblem: GatewayConnecti
"update_auth_credentials" -> nativeString("Saved authentication is invalid. Re-authenticate or reset this gateway connection.")
"update_auth_configuration" -> nativeString("Gateway authentication is not configured. Edit this connection and try again.")
"review_auth_configuration" -> nativeString("Gateway authentication needs review. Check gateway settings, then retry.")
else -> gatewayConnectionProblem.message.takeIf { it.isNotBlank() } ?: "Gateway authentication needs attention."
else -> gatewayConnectionProblem.message.takeIf { it.isNotBlank() } ?: nativeString("Gateway authentication needs attention.")
}
}
@@ -2666,12 +2668,12 @@ private fun recoveryGatewayProtocolMismatchDetail(gatewayConnectionProblem: Gate
val summary =
when {
clientMax != null && expected != null && clientMax < expected ->
"This app is older than the Gateway. Update OpenClaw on this device, then retry."
nativeString("This app is older than the Gateway. Update OpenClaw on this device, then retry.")
clientMin != null && expected != null && clientMin > expected ->
"The Gateway is older than this app. Update OpenClaw on the Gateway host, then retry."
else -> "The app and Gateway use incompatible protocol versions. Update OpenClaw on both, then retry."
nativeString("The Gateway is older than this app. Update OpenClaw on the Gateway host, then retry.")
else -> nativeString("The app and Gateway use incompatible protocol versions. Update OpenClaw on both, then retry.")
}
return protocolMismatchVersions(clientMin, clientMax, expected)?.let { "$summary $it" } ?: summary
return protocolMismatchVersions(clientMin, clientMax, expected)?.let { nativeString("\$summary \$details", summary, it) } ?: summary
}
internal fun recoveryGatewayProtocolMismatchCommand(
@@ -2863,9 +2865,9 @@ internal fun cameraPermissionRowStatusText(
androidCameraPermissionGranted: Boolean,
): String =
when {
capabilityEnabled -> "Enabled"
androidCameraPermissionGranted -> "Off"
else -> "Not allowed"
capabilityEnabled -> nativeString("Enabled")
androidCameraPermissionGranted -> nativeString("Off")
else -> nativeString("Not allowed")
}
internal fun cameraCapabilityAfterRowTap(
@@ -722,7 +722,7 @@ private fun InlineBase64Image(
if (image != null) {
Image(
bitmap = image,
contentDescription = mimeType ?: "image",
contentDescription = mimeType ?: nativeString("Image"),
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
@@ -433,19 +433,19 @@ fun ChatOutboxBubble(
val statusColor = if (failed) mobileDanger else mobileWarning
val statusLabel =
when (item.status) {
ChatOutboxStatus.Queued -> "Queued — sends when reconnected"
ChatOutboxStatus.Sending -> "Sending…"
ChatOutboxStatus.Accepted -> "Sent — confirming delivery…"
ChatOutboxStatus.Queued -> nativeString("Queued — sends when reconnected")
ChatOutboxStatus.Sending -> nativeString("Sending…")
ChatOutboxStatus.Accepted -> nativeString("Sent — confirming delivery…")
ChatOutboxStatus.Failed ->
item.lastError
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { "Failed — $it" } ?: "Failed"
?.let { nativeString("Failed — \$it", it) } ?: nativeString("Failed")
}
ChatBubbleContainer(
style = bubbleStyle("user").copy(borderColor = statusColor.copy(alpha = 0.6f)),
roleLabel = "You",
roleLabel = nativeString("You"),
) {
if (item.text.isNotBlank()) {
ChatMarkdown(text = item.text, textColor = mobileText)
@@ -566,7 +566,7 @@ internal fun ChatBase64Image(
Box {
Image(
bitmap = image,
contentDescription = mimeType ?: "attachment",
contentDescription = mimeType ?: nativeString("Attachment"),
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
@@ -309,7 +309,7 @@ fun ChatScreen(
attachments.addAll(merged.attachments)
shareImportNotice =
if (merged.failedImageCount + merged.droppedImageCount > 0) {
"Some shared images were omitted or could not be added."
nativeString("Some shared images were omitted or could not be added.")
} else {
null
}
@@ -653,9 +653,9 @@ private fun ChatHeader(
ModelPill(
text =
when {
pendingRunCount > 0 -> "Working"
healthOk -> "Ready"
else -> "Offline"
pendingRunCount > 0 -> nativeString("Working")
healthOk -> nativeString("Ready")
else -> nativeString("Offline")
},
status =
when {
@@ -1039,9 +1039,9 @@ private fun ChatBubble(
Text(
text =
when {
live -> "OpenClaw · Live"
isUser -> "You"
normalizedRole == "system" -> "System"
live -> nativeString("OpenClaw · Live")
isUser -> nativeString("You")
normalizedRole == "system" -> nativeString("System")
else -> nativeString("OpenClaw")
},
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
@@ -1056,7 +1056,7 @@ private fun ChatBubble(
base64 = checkNotNull(part.base64),
mimeType = part.mimeType,
)
else -> Text(text = part.fileName ?: "Attachment", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
else -> Text(text = part.fileName ?: nativeString("Attachment"), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
if (messageId != null) {
@@ -1569,7 +1569,7 @@ private fun SlashCommandRow(
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
text = command.description.ifBlank { command.category ?: "Command" },
text = command.description.ifBlank { command.category ?: nativeString("Command") },
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
@@ -1786,7 +1786,7 @@ private fun AttachmentChip(
}
Text(
text =
attachment.durationMs?.let { duration -> "Voice note · ${formatVoiceNoteDuration(duration)}" }
attachment.durationMs?.let { duration -> nativeString("Voice note · \${formatVoiceNoteDuration(duration)}", formatVoiceNoteDuration(duration)) }
?: attachment.fileName,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
@@ -1817,7 +1817,7 @@ private fun chatSessionChipText(
): String {
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
if (entry.key == mainKey || (entry.key == "main" && mainKey == "main")) return nativeString("Main")
val name = entry.displayName?.takeIf { it.isNotBlank() } ?: entry.key.takeIf { entry.updatedAtMs != null } ?: "Current"
val name = entry.displayName?.takeIf { it.isNotBlank() } ?: entry.key.takeIf { entry.updatedAtMs != null } ?: nativeString("Current")
return friendlySessionName(name)
}
@@ -1894,9 +1894,9 @@ internal fun userFacingChatError(
): String {
val lower = error.lowercase(Locale.US)
return when {
lower.contains("not connected") && gatewayConnected -> "Chat is still checking Gateway health."
lower.contains("not connected") -> "Gateway is offline. Fix the connection below or copy diagnostics."
lower.contains("unauthorized") || lower.contains("auth") -> "Gateway authentication needs attention."
lower.contains("not connected") && gatewayConnected -> nativeString("Chat is still checking Gateway health.")
lower.contains("not connected") -> nativeString("Gateway is offline. Fix the connection below or copy diagnostics.")
lower.contains("unauthorized") || lower.contains("auth") -> nativeString("Gateway authentication needs attention.")
else -> error
}
}
@@ -1913,7 +1913,10 @@ internal fun contextMeterLabel(
thinkingLevel: String,
thinkingSupported: Boolean = true,
): String {
val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --"
val contextLabel =
contextMeterWidth(usage)?.let {
nativeString("Context \${(it * 100).roundToInt()}%", (it * 100).roundToInt())
} ?: nativeString("Context --")
return if (thinkingSupported) nativeString("\$contextLabel · \${contextMeterThinkingLabel(thinkingLevel)}", contextLabel, contextMeterThinkingLabel(thinkingLevel)) else contextLabel
}
@@ -246,7 +246,7 @@ internal class MicCaptureManager(
_statusText.value =
when {
_micEnabled.value && _isSending.value -> nativeString("Listening · sending queued voice")
_micEnabled.value -> "Listening"
_micEnabled.value -> nativeString("Listening")
_isSending.value -> nativeString("Mic off · sending…")
else -> nativeString("Mic off")
}
@@ -602,7 +602,8 @@ internal class MicCaptureManager(
sendQueuedIfIdle()
}
private fun queuedWaitingStatus(): String = "${queuedMessageCount()} queued · waiting for gateway"
private fun queuedWaitingStatus(): String =
nativeString("\${queuedMessageCount()} queued · waiting for gateway", queuedMessageCount())
private fun appendConversation(
role: VoiceConversationRole,
@@ -783,8 +784,8 @@ internal class MicCaptureManager(
private fun listeningStatus(): String =
when {
_isSending.value -> nativeString("Listening · sending queued voice")
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
else -> "Listening"
hasQueuedMessages() -> nativeString("Listening · \${queuedMessageCount()} queued", queuedMessageCount())
else -> nativeString("Listening")
}
private fun pcm16ToPcmu(pcm16: ByteArray): ByteArray {
@@ -2798,21 +2798,21 @@ class TalkModeManager internal constructor(
if (stopRequested) return
_isListening.value = false
if (error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) {
setStatus("Microphone permission required")
setStatus(nativeString("Microphone permission required"))
return
}
setStatus(
when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
SpeechRecognizer.ERROR_CLIENT -> "Client error"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
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 -> "Recognizer busy"
SpeechRecognizer.ERROR_SERVER -> "Server error"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> nativeString("Recognizer busy")
SpeechRecognizer.ERROR_SERVER -> nativeString("Server error")
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> nativeString("Listening")
else -> "Speech error ($error)"
else -> nativeString("Speech error (\$error)", error)
},
)
scheduleRestart(delayMs = 600)