From e067203b2217ebd61b4669f4e6cbda13c26f28cd Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 19 May 2026 20:30:04 +0530 Subject: [PATCH] feat(android): add v2 nodes devices settings --- .../java/ai/openclaw/app/MainViewModel.kt | 8 + .../main/java/ai/openclaw/app/NodeRuntime.kt | 184 +++++++++++ .../app/ui/V2NodesDevicesSettingsScreen.kt | 288 ++++++++++++++++++ .../ai/openclaw/app/ui/V2SettingsScreens.kt | 2 + .../java/ai/openclaw/app/ui/V2ShellScreen.kt | 23 ++ 5 files changed, 505 insertions(+) create mode 100644 apps/android/app/src/main/java/ai/openclaw/app/ui/V2NodesDevicesSettingsScreen.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index c3d75f3188b..61b7532b255 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -97,6 +97,10 @@ class MainViewModel( val skillsSummary: StateFlow = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary } val skillsRefreshing: StateFlow = runtimeState(initial = false) { it.skillsRefreshing } val skillsErrorText: StateFlow = runtimeState(initial = null) { it.skillsErrorText } + val nodesDevicesSummary: StateFlow = + runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary } + val nodesDevicesRefreshing: StateFlow = runtimeState(initial = false) { it.nodesDevicesRefreshing } + val nodesDevicesErrorText: StateFlow = runtimeState(initial = null) { it.nodesDevicesErrorText } val pendingGatewayTrust: StateFlow = runtimeState(initial = null) { it.pendingGatewayTrust } val seamColorArgb: StateFlow = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb } val mainSessionKey: StateFlow = runtimeState(initial = "main") { it.mainSessionKey } @@ -391,6 +395,10 @@ class MainViewModel( ensureRuntime().refreshSkills() } + fun refreshNodesDevices() { + ensureRuntime().refreshNodesDevices() + } + fun loadChat(sessionKey: String) { ensureRuntime().loadChat(sessionKey) } 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 583b6d496e2..e3b84f3621b 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 @@ -329,6 +329,19 @@ class NodeRuntime( val skillsRefreshing: StateFlow = _skillsRefreshing.asStateFlow() private val _skillsErrorText = MutableStateFlow(null) val skillsErrorText: StateFlow = _skillsErrorText.asStateFlow() + private val _nodesDevicesSummary = + MutableStateFlow( + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ), + ) + val nodesDevicesSummary: StateFlow = _nodesDevicesSummary.asStateFlow() + private val _nodesDevicesRefreshing = MutableStateFlow(false) + val nodesDevicesRefreshing: StateFlow = _nodesDevicesRefreshing.asStateFlow() + private val _nodesDevicesErrorText = MutableStateFlow(null) + val nodesDevicesErrorText: StateFlow = _nodesDevicesErrorText.asStateFlow() private val _isForeground = MutableStateFlow(true) val isForeground: StateFlow = _isForeground.asStateFlow() @@ -374,6 +387,12 @@ class NodeRuntime( _cronJobs.value = emptyList() _usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList()) _skillsSummary.value = GatewaySkillsSummary(skills = emptyList()) + _nodesDevicesSummary.value = + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ) chat.applyMainSessionKey(resolveMainSessionKey()) chat.onDisconnected(message) updateStatus() @@ -643,6 +662,7 @@ class NodeRuntime( refreshCronFromGateway() refreshUsageFromGateway() refreshSkillsFromGateway() + refreshNodesDevicesFromGateway() } } @@ -676,6 +696,12 @@ class NodeRuntime( } } + fun refreshNodesDevices() { + scope.launch { + refreshNodesDevicesFromGateway() + } + } + fun requestCanvasRehydrate( source: String = "manual", force: Boolean = true, @@ -1699,6 +1725,43 @@ class NodeRuntime( } } + private suspend fun refreshNodesDevicesFromGateway() { + _nodesDevicesRefreshing.value = true + _nodesDevicesErrorText.value = null + if (!operatorConnected) { + _nodesDevicesSummary.value = + GatewayNodesDevicesSummary( + nodes = emptyList(), + pendingDevices = emptyList(), + pairedDevices = emptyList(), + ) + _nodesDevicesRefreshing.value = false + return + } + try { + val nodesRes = operatorSession.request("node.list", "{}") + val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull() + val devicesRoot = + try { + val devicesRes = operatorSession.request("device.pair.list", "{}") + json.parseToJsonElement(devicesRes).asObjectOrNull() + } catch (_: Throwable) { + null + } + _nodesDevicesSummary.value = + GatewayNodesDevicesSummary( + nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray), + pendingDevices = parsePendingDevices(devicesRoot?.get("pending") as? JsonArray), + pairedDevices = parsePairedDevices(devicesRoot?.get("paired") as? JsonArray), + devicePairingAvailable = devicesRoot != null, + ) + } catch (_: Throwable) { + _nodesDevicesErrorText.value = "Could not load nodes and devices." + } finally { + _nodesDevicesRefreshing.value = false + } + } + private fun parseGatewayModels(models: JsonArray?): List = models ?.mapNotNull { item -> @@ -1809,6 +1872,80 @@ class NodeRuntime( private fun skillMissingCount(missing: JsonObject?): Int = listOf("bins", "env", "config", "os").sumOf { key -> (missing?.get(key) as? JsonArray)?.size ?: 0 } + private fun parseGatewayNodes(nodes: JsonArray?): List = + nodes + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj["nodeId"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return@mapNotNull null + GatewayNodeSummary( + id = id, + displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + version = obj["version"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + deviceFamily = obj["deviceFamily"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + paired = obj.boolean("paired"), + connected = obj.boolean("connected"), + capabilities = parseStringArray(obj["caps"] as? JsonArray), + commands = parseStringArray(obj["commands"] as? JsonArray), + ) + }.orEmpty() + + private fun parsePendingDevices(devices: JsonArray?): List = + devices + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val requestId = obj["requestId"].asStringOrNull()?.trim().orEmpty() + val deviceId = obj["deviceId"].asStringOrNull()?.trim().orEmpty() + if (requestId.isEmpty() || deviceId.isEmpty()) return@mapNotNull null + GatewayPendingDeviceSummary( + requestId = requestId, + deviceId = deviceId, + displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + roles = parseStringArray(obj["roles"] as? JsonArray), + scopes = parseStringArray(obj["scopes"] as? JsonArray), + requestedAtMs = obj.long("ts"), + repair = obj.boolean("isRepair"), + ) + }.orEmpty() + + private fun parsePairedDevices(devices: JsonArray?): List = + devices + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val deviceId = obj["deviceId"].asStringOrNull()?.trim().orEmpty() + if (deviceId.isEmpty()) return@mapNotNull null + GatewayPairedDeviceSummary( + deviceId = deviceId, + displayName = obj["displayName"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + remoteIp = obj["remoteIp"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }, + roles = parseStringArray(obj["roles"] as? JsonArray), + scopes = parseStringArray(obj["scopes"] as? JsonArray), + tokens = parseDeviceTokens(obj["tokens"] as? JsonArray), + approvedAtMs = obj.long("approvedAtMs"), + ) + }.orEmpty() + + private fun parseDeviceTokens(tokens: JsonArray?): List = + tokens + ?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val role = obj["role"].asStringOrNull()?.trim().orEmpty() + if (role.isEmpty()) return@mapNotNull null + GatewayDeviceTokenSummary( + role = role, + scopes = parseStringArray(obj["scopes"] as? JsonArray), + revoked = obj.long("revokedAtMs") != null, + updatedAtMs = obj.long("rotatedAtMs") ?: obj.long("createdAtMs") ?: obj.long("lastUsedAtMs"), + ) + }.orEmpty() + + private fun parseStringArray(items: JsonArray?): List = + items + ?.mapNotNull { item -> item.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } } + .orEmpty() + private fun cronScheduleLabel(schedule: JsonObject?): String = when (schedule?.get("kind").asStringOrNull()) { "at" -> "One time" @@ -2136,6 +2273,53 @@ data class GatewaySkillSummary( val installCount: Int, ) +data class GatewayNodesDevicesSummary( + val nodes: List, + val pendingDevices: List, + val pairedDevices: List, + val devicePairingAvailable: Boolean = true, +) + +data class GatewayNodeSummary( + val id: String, + val displayName: String?, + val remoteIp: String?, + val version: String?, + val deviceFamily: String?, + val paired: Boolean, + val connected: Boolean, + val capabilities: List, + val commands: List, +) + +data class GatewayPendingDeviceSummary( + val requestId: String, + val deviceId: String, + val displayName: String?, + val remoteIp: String?, + val roles: List, + val scopes: List, + val requestedAtMs: Long?, + val repair: Boolean, +) + +data class GatewayPairedDeviceSummary( + val deviceId: String, + val displayName: String?, + val remoteIp: String?, + val roles: List, + val scopes: List, + val tokens: List, + val approvedAtMs: Long?, +) + +data class GatewayDeviceTokenSummary( + val role: String, + val scopes: List, + val revoked: Boolean, + val updatedAtMs: Long?, +) + 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/V2NodesDevicesSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2NodesDevicesSettingsScreen.kt new file mode 100644 index 00000000000..be4396d26ef --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2NodesDevicesSettingsScreen.kt @@ -0,0 +1,288 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.GatewayDeviceTokenSummary +import ai.openclaw.app.GatewayNodeSummary +import ai.openclaw.app.GatewayNodesDevicesSummary +import ai.openclaw.app.GatewayPairedDeviceSummary +import ai.openclaw.app.GatewayPendingDeviceSummary +import ai.openclaw.app.MainViewModel +import ai.openclaw.app.ui.design.ClawPanel +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.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Surface +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +internal fun V2NodesDevicesSettingsScreen( + viewModel: MainViewModel, + onBack: () -> Unit, +) { + val summary by viewModel.nodesDevicesSummary.collectAsState() + val refreshing by viewModel.nodesDevicesRefreshing.collectAsState() + val errorText by viewModel.nodesDevicesErrorText.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() + + LaunchedEffect(isConnected) { + if (isConnected) { + viewModel.refreshNodesDevices() + } + } + + V2SettingsDetailFrame( + title = "Nodes & Devices", + subtitle = "Live nodes, paired phones, and pending device requests.", + icon = Icons.Default.Cloud, + onBack = onBack, + ) { + V2SettingsMetricPanel( + rows = + listOf( + V2SettingsMetric("Nodes", summary.nodes.size.toString()), + V2SettingsMetric("Online", summary.nodes.count { it.connected }.toString()), + V2SettingsMetric("Devices", if (summary.devicePairingAvailable) summary.pairedDevices.size.toString() else "Locked"), + V2SettingsMetric("Pending", summary.pendingDevices.size.toString()), + ), + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ClawSecondaryButton( + text = if (refreshing) "Refreshing" else "Refresh", + onClick = viewModel::refreshNodesDevices, + enabled = isConnected && !refreshing, + modifier = Modifier.weight(1f), + ) + } + errorText?.let { + ClawPanel { + Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning) + } + } + when { + !isConnected -> + ClawPanel { + Text(text = "Connect the gateway to load nodes and paired devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + summary.isEmpty() -> + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(text = "No nodes or paired devices.", style = ClawTheme.type.section, color = ClawTheme.colors.text) + Text(text = "Linked phones and node hosts will appear here after pairing.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + } + else -> V2NodesDevicesPanel(summary = summary) + } + } +} + +@Composable +private fun V2NodesDevicesPanel(summary: GatewayNodesDevicesSummary) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!summary.devicePairingAvailable) { + ClawPanel { + Text(text = "Pairing controls are not available from this connection.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + } + } + if (summary.pendingDevices.isNotEmpty()) { + V2NodesSection(title = "Pending Requests") { + summary.pendingDevices.forEachIndexed { index, device -> + V2PendingDeviceRow(device = device) + if (index != summary.pendingDevices.lastIndex) { + HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) + } + } + } + } + if (summary.nodes.isNotEmpty()) { + V2NodesSection(title = "Nodes") { + summary.nodes.forEachIndexed { index, node -> + V2NodeRow(node = node) + if (index != summary.nodes.lastIndex) { + HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) + } + } + } + } + if (summary.pairedDevices.isNotEmpty()) { + V2NodesSection(title = "Paired Devices") { + summary.pairedDevices.forEachIndexed { index, device -> + V2PairedDeviceRow(device = device) + if (index != summary.pairedDevices.lastIndex) { + HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp) + } + } + } + } + } +} + +@Composable +private fun V2NodesSection( + title: String, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) { + Column { + content() + } + } + } +} + +@Composable +private fun V2NodeRow(node: GatewayNodeSummary) { + V2DeviceListRow( + badge = nodeBadge(node.displayName ?: node.id), + title = node.displayName ?: node.id, + subtitle = nodeSubtitle(node), + statusText = if (node.connected) "Online" else "Offline", + status = if (node.connected) ClawStatus.Success else ClawStatus.Warning, + ) +} + +@Composable +private fun V2PendingDeviceRow(device: GatewayPendingDeviceSummary) { + V2DeviceListRow( + badge = nodeBadge(device.displayName ?: device.deviceId), + title = device.displayName ?: "New device", + subtitle = pendingDeviceSubtitle(device), + statusText = if (device.repair) "Repair" else "Review", + status = ClawStatus.Warning, + ) +} + +@Composable +private fun V2PairedDeviceRow(device: GatewayPairedDeviceSummary) { + V2DeviceListRow( + badge = nodeBadge(device.displayName ?: device.deviceId), + title = device.displayName ?: "Paired device", + subtitle = pairedDeviceSubtitle(device), + statusText = pairedDeviceStatusText(device.tokens), + status = pairedDeviceStatus(device.tokens), + ) +} + +@Composable +private fun V2DeviceListRow( + badge: String, + title: String, + subtitle: String, + statusText: String, + status: ClawStatus, +) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Surface( + modifier = Modifier.size(30.dp), + shape = CircleShape, + color = ClawTheme.colors.surfacePressed, + border = BorderStroke(1.dp, ClawTheme.colors.border), + contentColor = ClawTheme.colors.text, + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = badge, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + ClawStatusPill(text = statusText, status = status) + } +} + +private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty() + +private fun nodeSubtitle(node: GatewayNodeSummary): String { + val kind = node.deviceFamily ?: "Node host" + val version = node.version?.let { "OpenClaw $it" } + val status = if (node.paired) "Paired" else "Unpaired" + val commands = + node.commands + .take(2) + .joinToString(", ") + .takeIf { it.isNotBlank() } + return listOfNotNull(kind, version, status, commands).joinToString(" · ") +} + +private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String { + val roles = formatDeviceList(device.roles, "role") + val scopes = formatDeviceList(device.scopes, "scope") + val requested = device.requestedAtMs?.let { "requested ${relativeDeviceTime(it)}" } + return listOfNotNull(roles, scopes, requested, device.remoteIp).joinToString(" · ") +} + +private fun pairedDeviceSubtitle(device: GatewayPairedDeviceSummary): String { + val roles = formatDeviceList(device.roles, "role") + val scopes = formatDeviceList(device.scopes, "scope") + val tokens = "${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens" + return listOfNotNull(roles, scopes, tokens, device.remoteIp).joinToString(" · ") +} + +private fun pairedDeviceStatusText(tokens: List): String = + when { + tokens.isEmpty() -> "Paired" + tokens.any { !it.revoked } -> "Active" + else -> "Needs Token" + } + +private fun pairedDeviceStatus(tokens: List): ClawStatus = + when { + tokens.isEmpty() -> ClawStatus.Neutral + tokens.any { !it.revoked } -> ClawStatus.Success + else -> ClawStatus.Warning + } + +private fun formatDeviceList( + values: List, + fallback: String, +): String? = + when (values.size) { + 0 -> null + 1 -> values.first() + else -> "${values.size} ${fallback}s" + } + +private fun nodeBadge(value: String): String = + value + .split(' ', '-', '_') + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() } + .joinToString("") + .ifBlank { "N" } + +private fun relativeDeviceTime(timeMs: Long): String { + val minutes = ((System.currentTimeMillis() - timeMs).coerceAtLeast(0L)) / 60_000L + if (minutes < 1) return "now" + if (minutes < 60) return "${minutes}m ago" + val hours = minutes / 60L + if (hours < 24) return "${hours}h ago" + return "${hours / 24L}d ago" +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt index 8b5a94676b3..318fcf8bf3c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2SettingsScreens.kt @@ -76,6 +76,7 @@ internal enum class V2SettingsRoute { CronJobs, Usage, Skills, + NodesDevices, Notifications, PhoneCapabilities, Gateway, @@ -99,6 +100,7 @@ internal fun V2SettingsDetailScreen( V2SettingsRoute.CronJobs -> V2CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack) V2SettingsRoute.Usage -> V2UsageSettingsScreen(viewModel = viewModel, onBack = onBack) V2SettingsRoute.Skills -> V2SkillsSettingsScreen(viewModel = viewModel, onBack = onBack) + V2SettingsRoute.NodesDevices -> V2NodesDevicesSettingsScreen(viewModel = viewModel, onBack = onBack) V2SettingsRoute.Notifications -> V2NotificationSettingsScreen(viewModel = viewModel, onBack = onBack) V2SettingsRoute.PhoneCapabilities -> V2PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack) V2SettingsRoute.Gateway -> V2GatewaySettingsScreen(viewModel = viewModel, onBack = onBack) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt index de111324eb5..87f8f2bb0bc 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/V2ShellScreen.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.ui import ai.openclaw.app.BuildConfig +import ai.openclaw.app.GatewayNodesDevicesSummary import ai.openclaw.app.GatewaySkillSummary import ai.openclaw.app.HomeDestination import ai.openclaw.app.MainViewModel @@ -525,6 +526,7 @@ private fun V2SettingsShellScreen( val cronStatus by viewModel.cronStatus.collectAsState() val usageSummary by viewModel.usageSummary.collectAsState() val skillsSummary by viewModel.skillsSummary.collectAsState() + val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState() var route by rememberSaveable { mutableStateOf(V2SettingsRoute.Home) } LaunchedEffect(isConnected) { @@ -533,6 +535,7 @@ private fun V2SettingsShellScreen( viewModel.refreshCronJobs() viewModel.refreshUsage() viewModel.refreshSkills() + viewModel.refreshNodesDevices() } } @@ -573,6 +576,7 @@ private fun V2SettingsShellScreen( V2SettingsRow("Cron Jobs", cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, status = if (cronStatus.jobs > 0) cronStatus.enabled else null, route = V2SettingsRoute.CronJobs), V2SettingsRow("Usage", usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, status = if (usageSummary.providers.isNotEmpty()) true else null, route = V2SettingsRoute.Usage), V2SettingsRow("Skills", skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, status = skillsStatus(skillsSummary.skills), route = V2SettingsRoute.Skills), + V2SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = V2SettingsRoute.NodesDevices), V2SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = V2SettingsRoute.Notifications), V2SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = V2SettingsRoute.PhoneCapabilities), V2SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = V2SettingsRoute.Gateway), @@ -648,6 +652,25 @@ private fun skillsStatus(skills: List): Boolean? = else -> true } +private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String { + val online = summary.nodes.count { it.connected } + val devices = summary.pairedDevices.size + return when { + summary.pendingDevices.isNotEmpty() -> "${summary.pendingDevices.size} pending" + summary.nodes.isNotEmpty() -> "$online/${summary.nodes.size} online" + devices > 0 -> "$devices paired" + else -> "No devices" + } +} + +private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? = + when { + summary.pendingDevices.isNotEmpty() -> false + summary.nodes.any { it.connected } -> true + summary.pairedDevices.isNotEmpty() -> true + else -> null + } + private data class V2SettingsRow( val title: String, val value: String,