fix(android): refresh localized retained presentation

This commit is contained in:
Vincent Koc
2026-07-12 10:09:35 +02:00
parent 71db13d4c3
commit be900f2116
7 changed files with 167 additions and 59 deletions

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeLocaleChanges
import ai.openclaw.app.i18n.nativeString
import android.Manifest
import android.app.Notification
@@ -21,6 +22,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -88,36 +90,41 @@ class NodeForegroundService : Service() {
// can update without restarting runtime-owned connection work.
notificationJob =
scope.launch {
combine(
val notificationStates =
combine(
runtime.gatewayConnectionDisplay,
runtime.serverName,
runtime.voiceCaptureMode,
runtime.locationMode,
) { connection, server, mode, _ ->
VoiceNotificationBase(
status = connection.statusText,
server = server,
connected = connection.isConnected,
mode = mode,
)
},
combine(
runtime.micEnabled,
runtime.micIsListening,
runtime.talkModeListening,
runtime.talkModeSpeaking,
) { micEnabled, micListening, talkListening, talkSpeaking ->
VoiceNotificationCapture(
micEnabled = micEnabled,
micListening = micListening,
talkListening = talkListening,
talkSpeaking = talkSpeaking,
)
},
) { base, capture ->
VoiceNotificationState(base = base, capture = capture)
}.collect { state ->
combine(
runtime.gatewayConnectionDisplay,
runtime.serverName,
runtime.voiceCaptureMode,
runtime.locationMode,
) { connection, server, mode, _ ->
VoiceNotificationBase(
status = connection.statusText,
server = server,
connected = connection.isConnected,
mode = mode,
)
},
combine(
runtime.micEnabled,
runtime.micIsListening,
runtime.talkModeListening,
runtime.talkModeSpeaking,
) { micEnabled, micListening, talkListening, talkSpeaking ->
VoiceNotificationCapture(
micEnabled = micEnabled,
micListening = micListening,
talkListening = talkListening,
talkSpeaking = talkSpeaking,
)
},
) { base, capture ->
VoiceNotificationState(base = base, capture = capture)
}
refreshNotificationOnLocaleChanges(
states = notificationStates,
localeChanges = nativeLocaleChanges,
).collect { state ->
voiceCaptureMode = state.mode
val title =
when {
@@ -413,3 +420,9 @@ private data class VoiceNotificationState(
val mode: VoiceCaptureMode
get() = base.mode
}
/** Re-emits stable runtime state when app-owned notification copy changes locale. */
internal fun <T> refreshNotificationOnLocaleChanges(
states: Flow<T>,
localeChanges: Flow<Long>,
): Flow<T> = combine(states, localeChanges) { state, _ -> state }

View File

@@ -5997,31 +5997,11 @@ class NodeRuntime private constructor(
val body = raw.substringAfter("<!-- openclaw:dreaming:diary:start -->", raw).substringBefore("<!-- openclaw:dreaming:diary:end -->")
return body
.split(Regex("\\n---\\n"))
.mapNotNull(::parseDreamDiaryEntry)
.mapNotNull(::parseGatewayDreamDiaryEntry)
.asReversed()
.take(4)
}
private fun parseDreamDiaryEntry(block: String): GatewayDreamDiaryEntry? {
val lines = block.trim().lines()
val date =
lines
.firstOrNull { line ->
val trimmed = line.trim()
trimmed.length > 2 && trimmed.startsWith("*") && trimmed.endsWith("*")
}?.trim()
?.trim('*')
?.takeIf { it.isNotEmpty() }
val text =
lines
.map { it.trim() }
.filter { line -> line.isNotEmpty() && !line.startsWith("#") && !line.startsWith("<!--") && !(line.startsWith("*") && line.endsWith("*")) }
.joinToString(" ")
.replace(Regex("\\s+"), " ")
.takeIf { it.isNotEmpty() }
return text?.let { GatewayDreamDiaryEntry(date = date ?: "Dream", text = it) }
}
private fun parseStringArray(items: JsonArray?): List<String> =
items
?.mapNotNull { item -> item.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } }
@@ -6711,10 +6691,35 @@ data class GatewayDreamingSummary(
)
data class GatewayDreamDiaryEntry(
val date: String,
val date: NativeText,
val text: String,
)
internal fun parseGatewayDreamDiaryEntry(block: String): GatewayDreamDiaryEntry? {
val lines = block.trim().lines()
val date =
lines
.firstOrNull { line ->
val trimmed = line.trim()
trimmed.length > 2 && trimmed.startsWith("*") && trimmed.endsWith("*")
}?.trim()
?.trim('*')
?.takeIf { it.isNotEmpty() }
val text =
lines
.map { it.trim() }
.filter { line -> line.isNotEmpty() && !line.startsWith("#") && !line.startsWith("<!--") && !(line.startsWith("*") && line.endsWith("*")) }
.joinToString(" ")
.replace(Regex("\\s+"), " ")
.takeIf { it.isNotEmpty() }
return text?.let {
GatewayDreamDiaryEntry(
date = date?.let(::verbatimText) ?: nativeText("Dream"),
text = it,
)
}
}
data class GatewayHealthLogsSummary(
val fileName: String? = null,
val cursor: Long? = null,

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.GatewayDreamDiaryEntry
import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatusRow
@@ -166,7 +167,7 @@ private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
text = if (entry.date == "Dream") nativeString("Dream") else entry.date,
text = entry.date.resolveNativeText(),
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
maxLines = 1,

View File

@@ -85,10 +85,16 @@ internal data class GatewayScannedSetupCodeResult(
)
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
private const val remoteGatewaySecurityRule =
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs."
private const val remoteGatewaySecurityFix =
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
private fun remoteGatewaySecurityRule(): String =
nativeString(
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs.",
)
private fun remoteGatewaySecurityFix(): String =
nativeString(
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access.",
)
/** Resolves setup-code or manual UI fields without reading stored credentials. */
internal fun resolveGatewayConnectConfig(
@@ -305,11 +311,23 @@ internal fun gatewayEndpointValidationMessage(
GatewayEndpointValidationError.INSECURE_REMOTE_URL ->
when (source) {
GatewayEndpointInputSource.SETUP_CODE ->
nativeString("Setup code points to an insecure remote gateway. \$remoteGatewaySecurityRule \$remoteGatewaySecurityFix", remoteGatewaySecurityRule, remoteGatewaySecurityFix)
nativeString(
"Setup code points to an insecure remote gateway. \$remoteGatewaySecurityRule \$remoteGatewaySecurityFix",
remoteGatewaySecurityRule(),
remoteGatewaySecurityFix(),
)
GatewayEndpointInputSource.QR_SCAN ->
nativeString("QR code points to an insecure remote gateway. \$remoteGatewaySecurityRule \$remoteGatewaySecurityFix", remoteGatewaySecurityRule, remoteGatewaySecurityFix)
nativeString(
"QR code points to an insecure remote gateway. \$remoteGatewaySecurityRule \$remoteGatewaySecurityFix",
remoteGatewaySecurityRule(),
remoteGatewaySecurityFix(),
)
GatewayEndpointInputSource.MANUAL ->
nativeString("\$remoteGatewaySecurityRule \$remoteGatewaySecurityFix", remoteGatewaySecurityRule, remoteGatewaySecurityFix)
nativeString(
"\$remoteGatewaySecurityRule \$remoteGatewaySecurityFix",
remoteGatewaySecurityRule(),
remoteGatewaySecurityFix(),
)
}
GatewayEndpointValidationError.IPV6_ZONE_ID_UNSUPPORTED ->
when (source) {

View File

@@ -0,0 +1,25 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class DreamingRuntimeTest {
@Test
fun missingDiaryDateUsesOwnedFallbackText() {
val entry = parseGatewayDreamDiaryEntry("# Dream\n\nA narrative summary.")
assertEquals("A narrative summary.", entry?.text)
assertEquals(NativeText.Resource(source = "Dream", formatArgs = emptyList()), entry?.date)
}
@Test
fun gatewayDiaryDateEqualToFallbackRemainsVerbatim() {
val entry = parseGatewayDreamDiaryEntry("*Dream*\n\nGateway-authored summary.")
assertEquals("Gateway-authored summary.", entry?.text)
assertTrue(entry?.date is NativeText.Verbatim)
assertEquals(NativeText.Verbatim("Dream"), entry?.date)
}
}

View File

@@ -5,6 +5,12 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -24,6 +30,31 @@ import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NodeForegroundServiceTest {
@Test
fun stableNotificationStateReemitsWhenLocaleChanges() =
runBlocking {
val localeChanges = MutableStateFlow(0L)
val firstEmission = CompletableDeferred<Unit>()
val emissions = mutableListOf<String>()
val collection =
launch(start = CoroutineStart.UNDISPATCHED) {
refreshNotificationOnLocaleChanges(
states = flowOf("stable"),
localeChanges = localeChanges,
).take(2)
.collect { state ->
emissions += state
if (emissions.size == 1) firstEmission.complete(Unit)
}
}
firstEmission.await()
localeChanges.value = 1L
collection.join()
assertEquals(listOf("stable", "stable"), emissions)
}
@Test
fun restoreStickyRuntimeCreatesAndActivatesMissingProcessRuntime() =
runBlocking {

View File

@@ -9,6 +9,21 @@ import java.util.Base64
@RunWith(RobolectricTestRunner::class)
class GatewayConfigResolverTest {
@Test
fun insecureRemoteGuidanceRetainsTheCompleteSecurityRuleAndFix() {
val message =
gatewayEndpointValidationMessage(
GatewayEndpointValidationError.INSECURE_REMOTE_URL,
GatewayEndpointInputSource.MANUAL,
)
assertEquals(
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, .local hosts, the Android emulator, and private LAN IPs. " +
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access.",
message,
)
}
@Test
fun manualTransportForcesSecureConnectionForRemoteHosts() {
val presentation =