mirror of https://github.com/openclaw/openclaw.git
fix: keep android canvas home visible after restart
This commit is contained in:
parent
f6e5b6758e
commit
2ae8837987
|
|
@ -176,6 +176,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||
runtime.requestCanvasRehydrate(source = source, force = true)
|
||||
}
|
||||
|
||||
fun refreshHomeCanvasOverviewIfConnected() {
|
||||
runtime.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
|
||||
fun loadChat(sessionKey: String) {
|
||||
runtime.loadChat(sessionKey)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
|
@ -210,7 +212,8 @@ class NodeRuntime(context: Context) {
|
|||
private val _isForeground = MutableStateFlow(true)
|
||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||
|
||||
private var lastAutoA2uiUrl: String? = null
|
||||
private var gatewayDefaultAgentId: String? = null
|
||||
private var gatewayAgents: List<GatewayAgentSummary> = emptyList()
|
||||
private var didAutoRequestCanvasRehydrate = false
|
||||
private val canvasRehydrateSeq = AtomicLong(0)
|
||||
private var operatorConnected = false
|
||||
|
|
@ -232,7 +235,7 @@ class NodeRuntime(context: Context) {
|
|||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
refreshBrandingFromGateway()
|
||||
refreshHomeCanvasOverviewIfConnected()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.refreshConfig()
|
||||
}
|
||||
|
|
@ -270,7 +273,7 @@ class NodeRuntime(context: Context) {
|
|||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
maybeNavigateToA2uiOnConnect()
|
||||
showLocalCanvasOnConnect()
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
_nodeConnected.value = false
|
||||
|
|
@ -396,6 +399,7 @@ class NodeRuntime(context: Context) {
|
|||
_mainSessionKey.value = trimmed
|
||||
talkMode.setMainSessionKey(trimmed)
|
||||
chat.applyMainSessionKey(trimmed)
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
|
|
@ -415,6 +419,7 @@ class NodeRuntime(context: Context) {
|
|||
operator.isNotBlank() && operator != "Offline" -> operator
|
||||
else -> node
|
||||
}
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
|
|
@ -422,23 +427,31 @@ class NodeRuntime(context: Context) {
|
|||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
||||
lastAutoA2uiUrl = a2uiUrl
|
||||
canvas.navigate(a2uiUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
lastAutoA2uiUrl = null
|
||||
private fun showLocalCanvasOnConnect() {
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
private fun showLocalCanvasOnDisconnect() {
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
canvas.navigate("")
|
||||
}
|
||||
|
||||
fun refreshHomeCanvasOverviewIfConnected() {
|
||||
if (!operatorConnected) {
|
||||
updateHomeCanvasState()
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
refreshBrandingFromGateway()
|
||||
refreshAgentsFromGateway()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) {
|
||||
scope.launch {
|
||||
if (!_nodeConnected.value) {
|
||||
|
|
@ -602,6 +615,8 @@ class NodeRuntime(context: Context) {
|
|||
canvas.setDebugStatus(status, server ?: remote)
|
||||
}
|
||||
}
|
||||
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
|
|
@ -928,11 +943,177 @@ class NodeRuntime(context: Context) {
|
|||
|
||||
val parsed = parseHexColorArgb(raw)
|
||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||
updateHomeCanvasState()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshAgentsFromGateway() {
|
||||
if (!operatorConnected) return
|
||||
try {
|
||||
val res = operatorSession.request("agents.list", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty()
|
||||
val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull())
|
||||
val agents =
|
||||
(root["agents"] as? JsonArray)?.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
|
||||
if (id.isEmpty()) return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()?.trim()
|
||||
val emoji = obj["identity"].asObjectOrNull()?.get("emoji").asStringOrNull()?.trim()
|
||||
GatewayAgentSummary(
|
||||
id = id,
|
||||
name = name?.takeIf { it.isNotEmpty() },
|
||||
emoji = emoji?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
gatewayDefaultAgentId = defaultAgentId.ifEmpty { null }
|
||||
gatewayAgents = agents
|
||||
applyMainSessionKey(mainKey)
|
||||
updateHomeCanvasState()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHomeCanvasState() {
|
||||
val payload =
|
||||
try {
|
||||
json.encodeToString(makeHomeCanvasPayload())
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
canvas.updateHomeCanvasState(payload)
|
||||
}
|
||||
|
||||
private fun makeHomeCanvasPayload(): HomeCanvasPayload {
|
||||
val state = resolveHomeCanvasGatewayState()
|
||||
val gatewayName = normalized(_serverName.value)
|
||||
val gatewayAddress = normalized(_remoteAddress.value)
|
||||
val gatewayLabel = gatewayName ?: gatewayAddress ?: "Gateway"
|
||||
val activeAgentId = resolveActiveAgentId()
|
||||
val agents = homeCanvasAgents(activeAgentId)
|
||||
|
||||
return when (state) {
|
||||
HomeCanvasGatewayState.Connected ->
|
||||
HomeCanvasPayload(
|
||||
gatewayState = "connected",
|
||||
eyebrow = "Connected to $gatewayLabel",
|
||||
title = "Your agents are ready",
|
||||
subtitle =
|
||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||
gatewayLabel = gatewayLabel,
|
||||
activeAgentName = resolveActiveAgentName(activeAgentId),
|
||||
activeAgentBadge = agents.firstOrNull { it.isActive }?.badge ?: "OC",
|
||||
activeAgentCaption = "Selected on this phone",
|
||||
agentCount = agents.size,
|
||||
agents = agents.take(6),
|
||||
footer = "The overview refreshes on reconnect and when this screen opens.",
|
||||
)
|
||||
HomeCanvasGatewayState.Connecting ->
|
||||
HomeCanvasPayload(
|
||||
gatewayState = "connecting",
|
||||
eyebrow = "Reconnecting",
|
||||
title = "OpenClaw is syncing back up",
|
||||
subtitle =
|
||||
"The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.",
|
||||
gatewayLabel = gatewayLabel,
|
||||
activeAgentName = resolveActiveAgentName(activeAgentId),
|
||||
activeAgentBadge = "OC",
|
||||
activeAgentCaption = "Gateway session in progress",
|
||||
agentCount = agents.size,
|
||||
agents = agents.take(4),
|
||||
footer = "If the gateway is reachable, reconnect should complete without intervention.",
|
||||
)
|
||||
HomeCanvasGatewayState.Error, HomeCanvasGatewayState.Offline ->
|
||||
HomeCanvasPayload(
|
||||
gatewayState = if (state == HomeCanvasGatewayState.Error) "error" else "offline",
|
||||
eyebrow = "Welcome to OpenClaw",
|
||||
title = "Your phone stays quiet until it is needed",
|
||||
subtitle =
|
||||
"Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.",
|
||||
gatewayLabel = gatewayLabel,
|
||||
activeAgentName = "Main",
|
||||
activeAgentBadge = "OC",
|
||||
activeAgentCaption = "Connect to load your agents",
|
||||
agentCount = agents.size,
|
||||
agents = agents.take(4),
|
||||
footer = "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState {
|
||||
val lower = _statusText.value.trim().lowercase()
|
||||
return when {
|
||||
_isConnected.value -> HomeCanvasGatewayState.Connected
|
||||
lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting
|
||||
lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error
|
||||
else -> HomeCanvasGatewayState.Offline
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveActiveAgentId(): String {
|
||||
val mainKey = _mainSessionKey.value.trim()
|
||||
if (mainKey.startsWith("agent:")) {
|
||||
val agentId = mainKey.removePrefix("agent:").substringBefore(':').trim()
|
||||
if (agentId.isNotEmpty()) return agentId
|
||||
}
|
||||
return gatewayDefaultAgentId?.trim().orEmpty()
|
||||
}
|
||||
|
||||
private fun resolveActiveAgentName(activeAgentId: String): String {
|
||||
if (activeAgentId.isNotEmpty()) {
|
||||
gatewayAgents.firstOrNull { it.id == activeAgentId }?.let { agent ->
|
||||
return normalized(agent.name) ?: agent.id
|
||||
}
|
||||
return activeAgentId
|
||||
}
|
||||
return gatewayAgents.firstOrNull()?.let { normalized(it.name) ?: it.id } ?: "Main"
|
||||
}
|
||||
|
||||
private fun homeCanvasAgents(activeAgentId: String): List<HomeCanvasAgentCard> {
|
||||
val defaultAgentId = gatewayDefaultAgentId?.trim().orEmpty()
|
||||
return gatewayAgents
|
||||
.map { agent ->
|
||||
val isActive = activeAgentId.isNotEmpty() && agent.id == activeAgentId
|
||||
val isDefault = defaultAgentId.isNotEmpty() && agent.id == defaultAgentId
|
||||
HomeCanvasAgentCard(
|
||||
id = agent.id,
|
||||
name = normalized(agent.name) ?: agent.id,
|
||||
badge = homeCanvasBadge(agent),
|
||||
caption =
|
||||
when {
|
||||
isActive -> "Active on this phone"
|
||||
isDefault -> "Default agent"
|
||||
else -> "Ready"
|
||||
},
|
||||
isActive = isActive,
|
||||
)
|
||||
}.sortedWith(compareByDescending<HomeCanvasAgentCard> { it.isActive }.thenBy { it.name.lowercase() })
|
||||
}
|
||||
|
||||
private fun homeCanvasBadge(agent: GatewayAgentSummary): String {
|
||||
val emoji = normalized(agent.emoji)
|
||||
if (emoji != null) return emoji
|
||||
val initials =
|
||||
(normalized(agent.name) ?: agent.id)
|
||||
.split(' ', '-', '_')
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { token -> token.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
return if (initials.isNotEmpty()) initials else "OC"
|
||||
}
|
||||
|
||||
private fun normalized(value: String?): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
return trimmed.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun triggerCameraFlash() {
|
||||
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||
|
|
@ -951,3 +1132,40 @@ class NodeRuntime(context: Context) {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
Connected,
|
||||
Connecting,
|
||||
Error,
|
||||
Offline,
|
||||
}
|
||||
|
||||
private data class GatewayAgentSummary(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val emoji: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class HomeCanvasPayload(
|
||||
val gatewayState: String,
|
||||
val eyebrow: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val gatewayLabel: String,
|
||||
val activeAgentName: String,
|
||||
val activeAgentBadge: String,
|
||||
val activeAgentCaption: String,
|
||||
val agentCount: Int,
|
||||
val agents: List<HomeCanvasAgentCard>,
|
||||
val footer: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class HomeCanvasAgentCard(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val badge: String,
|
||||
val caption: String,
|
||||
val isActive: Boolean,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class CanvasController {
|
|||
@Volatile private var debugStatusEnabled: Boolean = false
|
||||
@Volatile private var debugStatusTitle: String? = null
|
||||
@Volatile private var debugStatusSubtitle: String? = null
|
||||
@Volatile private var homeCanvasStateJson: String? = null
|
||||
private val _currentUrl = MutableStateFlow<String?>(null)
|
||||
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ class CanvasController {
|
|||
this.webView = webView
|
||||
reload()
|
||||
applyDebugStatus()
|
||||
applyHomeCanvasState()
|
||||
}
|
||||
|
||||
fun detach(webView: WebView) {
|
||||
|
|
@ -88,6 +90,12 @@ class CanvasController {
|
|||
|
||||
fun onPageFinished() {
|
||||
applyDebugStatus()
|
||||
applyHomeCanvasState()
|
||||
}
|
||||
|
||||
fun updateHomeCanvasState(json: String?) {
|
||||
homeCanvasStateJson = json
|
||||
applyHomeCanvasState()
|
||||
}
|
||||
|
||||
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
|
||||
|
|
@ -142,6 +150,22 @@ class CanvasController {
|
|||
}
|
||||
}
|
||||
|
||||
private fun applyHomeCanvasState() {
|
||||
val payload = homeCanvasStateJson ?: "null"
|
||||
withWebViewOnMain { wv ->
|
||||
val js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api || typeof api.renderHome !== 'function') return;
|
||||
api.renderHome($payload);
|
||||
} catch (_) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
wv.evaluateJavascript(js, null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun eval(javaScript: String): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
|
|
|
|||
|
|
@ -134,43 +134,14 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||
@Composable
|
||||
private fun ScreenTabScreen(viewModel: MainViewModel) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val canvasUrl by viewModel.canvasCurrentUrl.collectAsState()
|
||||
val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState()
|
||||
val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState()
|
||||
val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
|
||||
val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true
|
||||
val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated))
|
||||
val restoreCtaText =
|
||||
when {
|
||||
canvasRehydratePending -> "Restore requested. Waiting for agent…"
|
||||
!canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!!
|
||||
else -> "Canvas reset. Tap to restore dashboard."
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshHomeCanvasOverviewIfConnected()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||
|
||||
if (showRestoreCta) {
|
||||
Surface(
|
||||
onClick = {
|
||||
if (canvasRehydratePending) return@Surface
|
||||
viewModel.requestCanvasRehydrate(source = "screen_tab_cta")
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = mobileSurface.copy(alpha = 0.9f),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = restoreCtaText,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Medium),
|
||||
color = mobileText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue