feat(android): add v2 about update status

This commit is contained in:
Ayaan Zaidi
2026-05-19 21:14:02 +05:30
parent f359299df4
commit 357e3ecc65
4 changed files with 131 additions and 34 deletions
@@ -5,6 +5,7 @@ import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
@@ -81,6 +82,8 @@ class MainViewModel(
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
val gatewayUpdateAvailable: StateFlow<GatewayUpdateAvailableSummary?> = runtimeState(initial = null) { it.gatewayUpdateAvailable }
val modelCatalog: StateFlow<List<GatewayModelSummary>> = runtimeState(initial = emptyList()) { it.modelCatalog }
val modelAuthProviders: StateFlow<List<GatewayModelProviderSummary>> = runtimeState(initial = emptyList()) { it.modelAuthProviders }
val modelCatalogRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.modelCatalogRefreshing }
@@ -12,6 +12,7 @@ import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.A2UIHandler
@@ -295,6 +296,12 @@ class NodeRuntime(
private val _remoteAddress = MutableStateFlow<String?>(null)
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
private val _gatewayVersion = MutableStateFlow<String?>(null)
val gatewayVersion: StateFlow<String?> = _gatewayVersion.asStateFlow()
private val _gatewayUpdateAvailable = MutableStateFlow<GatewayUpdateAvailableSummary?>(null)
val gatewayUpdateAvailable: StateFlow<GatewayUpdateAvailableSummary?> = _gatewayUpdateAvailable.asStateFlow()
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
private val _modelCatalog = MutableStateFlow<List<GatewayModelSummary>>(emptyList())
@@ -377,13 +384,15 @@ class NodeRuntime(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { name, remote, mainSessionKey ->
onConnected = { hello ->
operatorConnected = true
operatorStatusText = "Connected"
_serverName.value = name
_remoteAddress.value = remote
_serverName.value = hello.serverName
_remoteAddress.value = hello.remoteAddress
_gatewayVersion.value = hello.serverVersion
_gatewayUpdateAvailable.value = hello.updateAvailable
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
syncMainSessionKey(resolveAgentIdFromMainSessionKey(mainSessionKey))
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
updateStatus()
micCapture.onGatewayConnectionChanged(true)
scope.launch {
@@ -398,6 +407,8 @@ class NodeRuntime(
operatorStatusText = message
_serverName.value = null
_remoteAddress.value = null
_gatewayVersion.value = null
_gatewayUpdateAvailable.value = null
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_modelCatalog.value = emptyList()
_modelAuthProviders.value = emptyList()
@@ -429,7 +440,7 @@ class NodeRuntime(
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
onConnected = {
_nodeConnected.value = true
nodeStatusText = "Connected"
didAutoRequestCanvasRehydrate = false
@@ -1585,11 +1596,28 @@ class NodeRuntime(
event: String,
payloadJson: String?,
) {
if (event == "update.available") {
_gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson)
}
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? {
return try {
val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() }
val update = root?.get("updateAvailable").asObjectOrNull() ?: return null
GatewayUpdateAvailableSummary(
currentVersion = update["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
latestVersion = update["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
channel = update["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
)
} catch (_: Throwable) {
null
}
}
private fun parseChatSendRunId(response: String): String? {
return try {
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
@@ -68,6 +68,20 @@ data class GatewayConnectErrorDetails(
val reason: String? = null,
)
data class GatewayHelloSummary(
val serverName: String?,
val remoteAddress: String?,
val serverVersion: String?,
val mainSessionKey: String?,
val updateAvailable: GatewayUpdateAvailableSummary?,
)
data class GatewayUpdateAvailableSummary(
val currentVersion: String?,
val latestVersion: String?,
val channel: String?,
)
private data class SelectedConnectAuth(
val authToken: String?,
val authBootstrapToken: String?,
@@ -86,7 +100,7 @@ class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthTokenStore,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onConnected: (GatewayHelloSummary) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
@@ -647,7 +661,9 @@ class GatewaySession(
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val server = obj["server"].asObjectOrNull()
val serverName = server?.get("host").asStringOrNull()
val serverVersion = server?.get("version").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
@@ -685,13 +701,33 @@ class GatewaySession(
?.let { normalized -> surface to normalized }
} ?: emptyList()
pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
val snapshot = obj["snapshot"].asObjectOrNull()
val sessionDefaults =
obj["snapshot"]
.asObjectOrNull()
snapshot
?.get("sessionDefaults")
.asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(serverName, remoteAddress, mainSessionKey)
onConnected(
GatewayHelloSummary(
serverName = serverName,
remoteAddress = remoteAddress,
serverVersion = serverVersion,
mainSessionKey = mainSessionKey,
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
),
)
}
private fun parseUpdateAvailable(value: JsonObject?): GatewayUpdateAvailableSummary? {
if (value == null) return null
val latestVersion = value["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val currentVersion = value["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val channel = value["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
return GatewayUpdateAvailableSummary(
currentVersion = currentVersion,
latestVersion = latestVersion,
channel = channel,
)
}
private fun buildConnectParams(
@@ -18,7 +18,6 @@ import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -60,7 +59,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
@@ -111,7 +109,7 @@ internal fun V2SettingsDetailScreen(
V2SettingsRoute.Gateway -> V2GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.Appearance -> V2AppearanceSettingsScreen(onBack = onBack)
V2SettingsRoute.Health -> V2HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack)
V2SettingsRoute.About -> V2AboutSettingsScreen(onBack = onBack)
V2SettingsRoute.About -> V2AboutSettingsScreen(viewModel = viewModel, onBack = onBack)
}
}
@@ -456,22 +454,72 @@ private fun V2AppearanceSettingsScreen(onBack: () -> Unit) {
}
@Composable
private fun V2AboutSettingsScreen(onBack: () -> Unit) {
private fun V2AboutSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val gatewayVersion by viewModel.gatewayVersion.collectAsState()
val updateAvailable by viewModel.gatewayUpdateAvailable.collectAsState()
val latestVersion = updateAvailable?.latestVersion?.takeIf { it.isNotBlank() }
val currentGatewayVersion = updateAvailable?.currentVersion?.takeIf { it.isNotBlank() } ?: gatewayVersion
V2SettingsDetailFrame(title = "About", subtitle = "OpenClaw for Android.", icon = Icons.Default.Info, onBack = onBack) {
V2SettingsMetricPanel(
rows =
listOf(
V2SettingsMetric("Version", BuildConfig.VERSION_NAME),
V2SettingsMetric("Android App", BuildConfig.VERSION_NAME),
V2SettingsMetric("Build", BuildConfig.VERSION_CODE.toString()),
V2SettingsMetric("Channel", "Play"),
V2SettingsMetric("Gateway", currentGatewayVersion ?: "Not connected"),
),
)
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
V2AboutStatusRow(title = "Gateway", value = serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway", healthy = isConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
V2AboutStatusRow(title = "Runtime", value = currentGatewayVersion ?: "Waiting", healthy = currentGatewayVersion != null)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
V2AboutStatusRow(
title = "Update",
value = latestVersion?.let { "v$it available" } ?: "Up to date",
healthy = latestVersion == null,
)
}
}
ClawPanel {
Text(text = "OpenClaw turns this phone into a clean mobile command surface for your sessions, voice, providers, and Gateway.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = aboutUpdateText(latestVersion = latestVersion), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun V2AboutStatusRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = value, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
ClawStatusPill(text = if (healthy) "OK" else "Check", status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
private fun aboutUpdateText(latestVersion: String?): String =
if (latestVersion == null) {
"OpenClaw turns this phone into a clean mobile command surface for sessions, voice, providers, and Gateway."
} else {
"A Gateway update is available. Run the update from the Web UI or CLI when you are ready."
}
@Composable
internal fun V2SettingsDetailFrame(
title: String,
@@ -810,24 +858,6 @@ internal fun V2SettingsMetricPanel(rows: List<V2SettingsMetric>) {
}
}
@Composable
private fun V2HealthRow(
title: String,
value: String,
healthy: Boolean,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(if (healthy) ClawTheme.colors.success else ClawTheme.colors.warning))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = value, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
ClawStatusPill(text = if (healthy) "OK" else "Check", status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
}
@Composable
private fun V2SettingsBackButton(onClick: () -> Unit) {
Surface(onClick = onClick, modifier = Modifier.size(30.dp), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {