mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 15:41:36 +00:00
fix(android): refresh localized retained presentation
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user