diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 7ab5422c4b9e..2b9c6a7ddcc2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -2458,12 +2458,19 @@ class NodeRuntime( }.orEmpty() private fun parseGatewayLogEntry(line: String): GatewayLogEntry { + val sanitizedLine = sanitizeGatewayLogText(line) val root = try { json.parseToJsonElement(line).asObjectOrNull() } catch (_: Throwable) { null - } ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" }) + } ?: return GatewayLogEntry( + time = null, + level = null, + subsystem = null, + message = sanitizedLine.trim().ifEmpty { "Empty log entry" }, + raw = sanitizedLine, + ) val meta = root["_meta"].asObjectOrNull() val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull() val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull()) @@ -2481,7 +2488,7 @@ class NodeRuntime( ?: root["message"].asStringOrNull() ?: line val normalizedMessage = - message + sanitizeGatewayLogText(message) .trim() .replace(Regex("\\s+"), " ") .take(240) @@ -2489,8 +2496,9 @@ class NodeRuntime( return GatewayLogEntry( time = time, level = level, - subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() }, + subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() }, message = normalizedMessage, + raw = sanitizedLine, ) } @@ -2579,6 +2587,7 @@ class NodeRuntime( if (name.isEmpty()) return@mapNotNull null val missing = obj["missing"].asObjectOrNull() GatewaySkillSummary( + skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name, name = name, description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown", @@ -3101,6 +3110,7 @@ data class GatewaySkillsSummary( ) data class GatewaySkillSummary( + val skillKey: String, val name: String, val description: String?, val source: String, @@ -3298,8 +3308,19 @@ data class GatewayLogEntry( val level: String?, val subsystem: String?, val message: String, + val raw: String, ) +private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]") +private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""") +private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])") + +internal fun sanitizeGatewayLogText(value: String): String = + value + .replace(gatewayAnsiControlPattern, "") + .replace(gatewayEscapedAnsiControlPattern, "") + .replace(gatewayVisibleSgrPattern, "") + private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull() private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt index 8659c462b910..7fc933b33b1f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/HealthLogsSettingsScreen.kt @@ -8,6 +8,8 @@ import ai.openclaw.app.ui.design.ClawSecondaryButton import ai.openclaw.app.ui.design.ClawStatus import ai.openclaw.app.ui.design.ClawStatusPill import ai.openclaw.app.ui.design.ClawTheme +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -15,13 +17,18 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -43,6 +50,7 @@ internal fun HealthLogsSettingsScreen( val logsSummary by viewModel.healthLogsSummary.collectAsState() val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState() val logsErrorText by viewModel.healthLogsErrorText.collectAsState() + var selectedLogEntry by remember { mutableStateOf(null) } LaunchedEffect(isConnected) { if (isConnected) { @@ -52,6 +60,11 @@ internal fun HealthLogsSettingsScreen( } } + selectedLogEntry?.let { entry -> + GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null }) + return + } + SettingsDetailFrame( title = "Health", subtitle = "Gateway status, phone node readiness, and recent log stream.", @@ -93,7 +106,46 @@ internal fun HealthLogsSettingsScreen( Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning) } } - GatewayLogsPanel(isConnected = isConnected, summary = logsSummary) + GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it }) + } +} + +@Composable +private fun GatewayLogDetailSettingsScreen( + entry: GatewayLogEntry, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + SettingsDetailFrame( + title = "Log Entry", + subtitle = "Readable gateway log detail.", + icon = Icons.Default.Settings, + onBack = onBack, + ) { + SettingsMetricPanel( + rows = + listOf( + SettingsMetric("Time", compactLogTime(entry.time)), + SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"), + SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"), + ), + ) + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text) + } + } + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text( + text = entry.raw.take(4_000), + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + } + } } } @@ -148,6 +200,7 @@ private fun HealthStatusRow( private fun GatewayLogsPanel( isConnected: Boolean, summary: GatewayHealthLogsSummary, + onLogClick: (GatewayLogEntry) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { @@ -170,7 +223,7 @@ private fun GatewayLogsPanel( val entries = summary.entries.takeLast(12) Column { entries.forEachIndexed { index, entry -> - GatewayLogRow(entry = entry) + GatewayLogRow(entry = entry, onClick = { onLogClick(entry) }) if (index != entries.lastIndex) { HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) } @@ -185,9 +238,16 @@ private fun GatewayLogsPanel( } @Composable -private fun GatewayLogRow(entry: GatewayLogEntry) { +private fun GatewayLogRow( + entry: GatewayLogEntry, + onClick: () -> Unit, +) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClickLabel = "Open log entry", onClick = onClick) + .padding(horizontal = 10.dp, vertical = 7.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(9.dp), ) { @@ -199,6 +259,11 @@ private fun GatewayLogRow(entry: GatewayLogEntry) { } } ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = ClawTheme.colors.textSubtle, + ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index 1f97ffb5aed5..ec7db09f5a7e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -856,6 +856,7 @@ private fun GatewaySettingsScreen( var bootstrapTokenInput by remember { mutableStateOf("") } var passwordInput by remember { mutableStateOf("") } var validationText by remember { mutableStateOf(null) } + var showSetupCodeHelp by remember { mutableStateOf(false) } SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) { SettingsMetricPanel( @@ -876,7 +877,17 @@ private fun GatewaySettingsScreen( Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text) Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) - ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2) + ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info) + } + if (showSetupCodeHelp) { + Text( + text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + } } } ClawPanel { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt index 56a1e08a594a..7f3ddde99dc8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt @@ -10,17 +10,24 @@ import ai.openclaw.app.ui.design.ClawStatus import ai.openclaw.app.ui.design.ClawStatusPill import ai.openclaw.app.ui.design.ClawTextBadge import ai.openclaw.app.ui.design.ClawTheme +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -37,6 +44,7 @@ internal fun SkillsSettingsScreen( val skills = skillsSummary.skills val readyCount = skills.count { skillReady(it) } val needsSetupCount = skills.count { skillNeedsSetup(it) } + var selectedSkillKey by remember { mutableStateOf(null) } LaunchedEffect(isConnected) { if (isConnected) { @@ -44,6 +52,17 @@ internal fun SkillsSettingsScreen( } } + selectedSkillKey?.let { skillKey -> + val selectedSkill = skills.firstOrNull { it.skillKey == skillKey } + SkillDetailSettingsScreen( + skill = selectedSkill, + skillKey = skillKey, + isConnected = isConnected, + onBack = { selectedSkillKey = null }, + ) + return + } + SettingsDetailFrame( title = "Skills", subtitle = "Installed capabilities available to OpenClaw.", @@ -83,25 +102,117 @@ internal fun SkillsSettingsScreen( Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } } - else -> SkillsPanel(skills = skills) + else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey }) } } } @Composable -private fun SkillsPanel(skills: List) { - ClawListPanel(items = skills) { skill -> - SkillListRow(skill = skill) +private fun SkillDetailSettingsScreen( + skill: GatewaySkillSummary?, + skillKey: String, + isConnected: Boolean, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + + SettingsDetailFrame( + title = skill?.name ?: skillKey, + subtitle = "Inspect installed skill capability and setup state.", + icon = Icons.Default.Settings, + onBack = onBack, + ) { + skill?.let { summary -> + SettingsMetricPanel( + rows = + listOf( + SettingsMetric("Status", skillStatusText(summary)), + SettingsMetric("Source", skillSourceLabel(summary)), + SettingsMetric("Missing", summary.missingCount.toString()), + ), + ) + SkillSetupPanel(summary) + } + SkillDetailPanel(skill = skill, isConnected = isConnected) } } @Composable -private fun SkillListRow(skill: GatewaySkillSummary) { +private fun SkillSetupPanel(skill: GatewaySkillSummary) { + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + } +} + +@Composable +private fun SkillDetailPanel( + skill: GatewaySkillSummary?, + isConnected: Boolean, +) { + if (!isConnected) { + ClawPanel { + Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + return + } + if (skill == null) { + ClawPanel { + Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + return + } + SettingsMetricPanel( + rows = + listOf( + SettingsMetric("Skill Key", skill.skillKey), + SettingsMetric("Display", skill.name), + SettingsMetric("Source", skillSourceLabel(skill)), + SettingsMetric("Install Options", skill.installCount.toString()), + ), + ) + skill.description?.let { description -> + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + } + } +} + +@Composable +private fun SkillsPanel( + skills: List, + onSkillClick: (GatewaySkillSummary) -> Unit, +) { + ClawListPanel(items = skills) { skill -> + SkillListRow(skill = skill, onClick = { onSkillClick(skill) }) + } +} + +@Composable +private fun SkillListRow( + skill: GatewaySkillSummary, + onClick: () -> Unit, +) { ClawDetailRow( title = skill.name, subtitle = skillSubtitle(skill), + modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick), leading = { ClawTextBadge(text = skillBadge(skill)) }, - trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) }, + trailing = { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = ClawTheme.colors.textSubtle, + ) + } + }, ) } @@ -135,6 +246,15 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String { return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" ยท ") } +private fun skillConfigurationText(skill: GatewaySkillSummary): String = + when { + skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI." + skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI." + skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI." + !skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes." + else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI." + } + private fun skillSourceLabel(skill: GatewaySkillSummary): String = when (skill.source) { "openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled" diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayLogTextTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayLogTextTest.kt new file mode 100644 index 000000000000..bf34a01a1184 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayLogTextTest.kt @@ -0,0 +1,46 @@ +package ai.openclaw.app + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GatewayLogTextTest { + @Test + fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() { + assertEquals( + "hindsight: Skipping retain", + sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"), + ) + } + + @Test + fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() { + assertEquals( + "hindsight: Skipping retain", + sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"), + ) + } + + @Test + fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() { + assertEquals( + "error and bold", + sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"), + ) + } + + @Test + fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() { + assertEquals( + """{"1":"hindsight: Skipping retain"}""", + sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""), + ) + } + + @Test + fun sanitizeGatewayLogTextKeepsPlainBracketedText() { + assertEquals( + "cache ttl [5m] expired", + sanitizeGatewayLogText("cache ttl [5m] expired"), + ) + } +}