fix(android): address overhaul review findings

This commit is contained in:
Ayaan Zaidi
2026-05-20 10:52:12 +05:30
parent bbcac0019b
commit 989e53c20d
21 changed files with 398 additions and 259 deletions

View File

@@ -93,7 +93,6 @@ class MainViewModel(
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
val cronSaving: StateFlow<Boolean> = runtimeState(initial = false) { it.cronSaving }
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
@@ -329,6 +328,10 @@ class MainViewModel(
ensureRuntime().setMicEnabled(enabled)
}
fun cancelMicCapture() {
ensureRuntime().cancelMicCapture()
}
fun setTalkModeEnabled(enabled: Boolean) {
ensureRuntime().setTalkModeEnabled(enabled)
}
@@ -403,20 +406,6 @@ class MainViewModel(
ensureRuntime().refreshCronJobs()
}
fun createCronJob(
name: String,
message: String,
scheduleKind: String,
scheduleValue: String,
) {
ensureRuntime().createCronJob(
name = name,
message = message,
scheduleKind = scheduleKind,
scheduleValue = scheduleValue,
)
}
fun refreshUsage() {
ensureRuntime().refreshUsage()
}

View File

@@ -70,7 +70,6 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@@ -323,8 +322,6 @@ class NodeRuntime(
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = _cronJobs.asStateFlow()
private val _cronRefreshing = MutableStateFlow(false)
val cronRefreshing: StateFlow<Boolean> = _cronRefreshing.asStateFlow()
private val _cronSaving = MutableStateFlow(false)
val cronSaving: StateFlow<Boolean> = _cronSaving.asStateFlow()
private val _cronErrorText = MutableStateFlow<String?>(null)
val cronErrorText: StateFlow<String?> = _cronErrorText.asStateFlow()
private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList()))
@@ -413,6 +410,8 @@ class NodeRuntime(
_gatewayVersion.value = null
_gatewayUpdateAvailable.value = null
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
_gatewayDefaultAgentId.value = null
_gatewayAgents.value = emptyList()
_modelCatalog.value = emptyList()
_modelAuthProviders.value = emptyList()
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
@@ -722,22 +721,6 @@ class NodeRuntime(
}
}
fun createCronJob(
name: String,
message: String,
scheduleKind: String,
scheduleValue: String,
) {
scope.launch {
createCronJobOnGateway(
name = name,
message = message,
scheduleKind = scheduleKind,
scheduleValue = scheduleValue,
)
}
}
fun refreshUsage() {
scope.launch {
refreshUsageFromGateway()
@@ -1102,6 +1085,12 @@ class NodeRuntime(
setVoiceCaptureMode(if (value) VoiceCaptureMode.ManualMic else VoiceCaptureMode.Off)
}
fun cancelMicCapture() {
micCapture.cancelMicCapture()
setVoiceCaptureMode(VoiceCaptureMode.Off, persistManualMic = false)
prefs.setVoiceMicEnabled(false)
}
fun setTalkModeEnabled(value: Boolean) {
setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off)
}
@@ -1763,119 +1752,6 @@ class NodeRuntime(
}
}
private suspend fun createCronJobOnGateway(
name: String,
message: String,
scheduleKind: String,
scheduleValue: String,
) {
_cronSaving.value = true
_cronErrorText.value = null
if (!operatorConnected) {
_cronErrorText.value = "Connect the gateway before creating a cron job."
_cronSaving.value = false
return
}
try {
val cleanName = name.trim()
val cleanMessage = message.trim()
if (cleanName.isEmpty()) {
throw IllegalArgumentException("Add a job name.")
}
if (cleanMessage.isEmpty()) {
throw IllegalArgumentException("Add the message OpenClaw should run.")
}
val schedule = buildCronCreateSchedule(scheduleKind = scheduleKind, scheduleValue = scheduleValue.trim())
val params =
buildJsonObject {
put("name", JsonPrimitive(cleanName))
put("enabled", JsonPrimitive(true))
put("deleteAfterRun", JsonPrimitive(scheduleKind == "Once"))
put("sessionTarget", JsonPrimitive("isolated"))
put("wakeMode", JsonPrimitive("now"))
put("schedule", schedule)
put(
"payload",
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
put("message", JsonPrimitive(cleanMessage))
},
)
put(
"delivery",
buildJsonObject {
put("mode", JsonPrimitive("announce"))
put("channel", JsonPrimitive("last"))
put("bestEffort", JsonPrimitive(true))
},
)
}
operatorSession.request("cron.add", params.toString())
refreshCronFromGateway()
} catch (err: IllegalArgumentException) {
_cronErrorText.value = err.message ?: "Could not create cron job."
} catch (_: Throwable) {
_cronErrorText.value = "Could not create cron job."
} finally {
_cronSaving.value = false
}
}
private fun buildCronCreateSchedule(
scheduleKind: String,
scheduleValue: String,
): JsonObject =
when (scheduleKind) {
"Every" ->
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(parseCronDurationMs(scheduleValue)))
}
"Once" ->
buildJsonObject {
put("kind", JsonPrimitive("at"))
put("at", JsonPrimitive(resolveCronAtIso(scheduleValue)))
}
"Cron" ->
buildJsonObject {
val expr = scheduleValue.takeIf { it.isNotEmpty() } ?: throw IllegalArgumentException("Add a cron expression.")
put("kind", JsonPrimitive("cron"))
put("expr", JsonPrimitive(expr))
put("staggerMs", JsonPrimitive(0))
}
else -> throw IllegalArgumentException("Choose a schedule.")
}
private fun resolveCronAtIso(value: String): String {
val clean = value.trim()
if (clean.startsWith("+")) {
return Instant.ofEpochMilli(System.currentTimeMillis() + parseCronDurationMs(clean.drop(1))).toString()
}
return try {
Instant.parse(clean).toString()
} catch (_: Throwable) {
throw IllegalArgumentException("Use ISO time or +30m.")
}
}
private fun parseCronDurationMs(value: String): Long {
val match =
Regex("""^(\d+)\s*([mhd])$""")
.matchEntire(value.trim().lowercase())
?: throw IllegalArgumentException("Use 15m, 1h, or 1d.")
val amount = match.groupValues[1].toLong()
if (amount <= 0) {
throw IllegalArgumentException("Use a duration greater than zero.")
}
val unit = match.groupValues[2]
return amount *
when (unit) {
"m" -> 60_000L
"h" -> 3_600_000L
else -> 86_400_000L
}
}
private suspend fun refreshUsageFromGateway() {
_usageRefreshing.value = true
_usageErrorText.value = null
@@ -2151,12 +2027,7 @@ class NodeRuntime(
scheduleLabel = cronScheduleLabel(schedule),
promptPreview = cronPayloadPreview(payload),
nextRunAtMs = state.long("nextRunAtMs"),
lastRunStatus =
state
?.get("lastRunStatus")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
lastRunStatus = cronJobLastRunStatus(state),
)
}.orEmpty()
@@ -2649,11 +2520,7 @@ internal fun resolveOperatorSessionConnectAuth(
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
if (explicitBootstrapToken != null) {
return NodeRuntime.GatewayConnectAuth(
token = null,
bootstrapToken = explicitBootstrapToken,
password = null,
)
return null
}
return NodeRuntime.GatewayConnectAuth(
@@ -2867,6 +2734,18 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
state
.cronStatus("lastStatus")
?: state.cronStatus("lastRunStatus")
private fun JsonObject?.cronStatus(key: String): String? =
this
?.get(key)
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
fun providerDisplayName(provider: String): String =
when (provider.trim().lowercase()) {
"openai" -> "OpenAI"

View File

@@ -233,6 +233,7 @@ class ChatController(
true
} catch (err: Throwable) {
clearPendingRun(runId)
removeOptimisticMessage(runId)
_errorText.value = err.message
false
}
@@ -374,7 +375,13 @@ class ChatController(
if (state == "error") {
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
}
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
if (runId != null) {
clearPendingRun(runId)
optimisticMessagesByRunId.remove(runId)
} else {
clearPendingRuns()
optimisticMessagesByRunId.clear()
}
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
@@ -476,6 +483,7 @@ class ChatController(
}
if (!stillPending) return@launch
clearPendingRun(runId)
removeOptimisticMessage(runId)
_errorText.value = "Timed out waiting for a reply; try again or refresh."
}
}
@@ -493,12 +501,18 @@ class ChatController(
job.cancel()
}
pendingRunTimeoutJobs.clear()
optimisticMessagesByRunId.clear()
synchronized(pendingRuns) {
pendingRuns.clear()
_pendingRunCount.value = 0
}
}
private fun removeOptimisticMessage(runId: String) {
val message = optimisticMessagesByRunId.remove(runId) ?: return
_messages.value = _messages.value.filterNot { it.id == message.id }
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -631,11 +645,19 @@ internal fun mergeOptimisticMessages(
): List<ChatMessage> {
if (optimistic.isEmpty()) return incoming
val incomingKeys = incoming.mapNotNull(::messageIdentityKey).toSet()
val unmatchedIncoming = incoming.toMutableList()
val missingOptimistic =
optimistic.filter { message ->
val key = messageIdentityKey(message) ?: return@filter false
key !in incomingKeys
val matchIndex =
unmatchedIncoming.indexOfFirst { incomingMessage ->
incomingMessageConsumesOptimistic(incomingMessage, message)
}
if (matchIndex >= 0) {
unmatchedIncoming.removeAt(matchIndex)
false
} else {
true
}
}
if (missingOptimistic.isEmpty()) return incoming
@@ -643,10 +665,28 @@ internal fun mergeOptimisticMessages(
}
internal fun messageIdentityKey(message: ChatMessage): String? {
val contentKey = messageContentIdentityKey(message) ?: return null
val timestamp = message.timestampMs?.toString().orEmpty()
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
return listOf(contentKey, timestamp).joinToString(separator = "|")
}
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
private fun incomingMessageConsumesOptimistic(
incoming: ChatMessage,
optimistic: ChatMessage,
): Boolean {
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
val incomingTimestamp = incoming.timestampMs ?: return false
val optimisticTimestamp = optimistic.timestampMs ?: return true
return incomingTimestamp >= optimisticTimestamp
}
private fun messageContentIdentityKey(message: ChatMessage): String? {
val role = message.role.trim().lowercase()
if (role.isEmpty()) return null
val timestamp = message.timestampMs?.toString().orEmpty()
val contentFingerprint =
message.content.joinToString(separator = "\u001E") { part ->
listOf(
@@ -664,8 +704,7 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
).joinToString(separator = "\u001F")
}
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
return listOf(role, contentFingerprint).joinToString(separator = "|")
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

@@ -618,6 +618,7 @@ class GatewaySession(
val allowedOperatorScopes =
setOf(
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
)

View File

@@ -167,6 +167,7 @@ class ConnectionManager(
scopes =
listOf(
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
),

View File

@@ -42,6 +42,7 @@ internal fun CanvasSettingsScreen(
val rehydratePending by viewModel.canvasRehydratePending.collectAsState()
val rehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
val hasLivePage = currentUrl?.isNotBlank() == true
val showCanvasSurface = isConnected
val canvasLabel = if (hasLivePage) "Live page" else "Home canvas"
LaunchedEffect(isConnected) {
@@ -92,7 +93,7 @@ internal fun CanvasSettingsScreen(
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box {
if (hasLivePage) {
if (showCanvasSurface) {
CanvasScreen(viewModel = viewModel, visible = true, modifier = Modifier.fillMaxWidth().height(520.dp))
} else {
CanvasStandbyPanel(isConnected = isConnected)

View File

@@ -75,6 +75,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -93,6 +94,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
@@ -840,21 +844,22 @@ private fun resolveGatewayConfig(
val setup = setupCode.takeIf { it.isNotBlank() }?.let(::decodeGatewaySetupCode)
if (setup != null) {
val endpoint = parseGatewayEndpointResult(setup.url).config ?: return null
val bootstrapToken = setup.bootstrapToken?.trim().orEmpty()
return GatewayConfig(
host = endpoint.host,
port = endpoint.port,
tls = endpoint.tls,
bootstrapToken = setup.bootstrapToken.orEmpty(),
bootstrapToken = bootstrapToken,
token =
setup.token
?.trim()
.orEmpty()
.ifEmpty { token.trim() },
.ifEmpty { if (bootstrapToken.isEmpty()) token.trim() else "" },
password =
setup.password
?.trim()
.orEmpty()
.ifEmpty { password.trim() },
.ifEmpty { if (bootstrapToken.isEmpty()) password.trim() else "" },
)
}
@@ -962,6 +967,18 @@ private fun rememberPermissionState(
)
}
var callLogGranted by rememberSaveable { mutableStateOf(!callLogAvailable || hasPermission(context, Manifest.permission.READ_CALL_LOG)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, context) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
notificationListenerGranted = DeviceNotificationListenerService.isAccessEnabled(context)
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
@@ -1015,7 +1032,6 @@ private fun rememberPermissionState(
},
PermissionRowModel("Notification listener", "Forward selected app alerts", Icons.Default.Sensors, notificationListenerGranted) {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
notificationListenerGranted = DeviceNotificationListenerService.isAccessEnabled(context)
},
if (motionAvailable) {
PermissionRowModel("Motion", "Read activity and step context", Icons.Default.Sensors, motionGranted) {

View File

@@ -247,7 +247,11 @@ private fun providerSetupSubtitle(
internal fun modelProviderReady(status: String): Boolean {
val normalized = status.trim().lowercase()
return normalized == "ok" || normalized == "ready" || normalized == "healthy" || normalized == "configured"
return normalized == "ok" ||
normalized == "ready" ||
normalized == "healthy" ||
normalized == "configured" ||
normalized == "static"
}
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =

View File

@@ -8,6 +8,7 @@ import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
@@ -200,14 +201,8 @@ private fun CronJobsSettingsScreen(
val cronStatus by viewModel.cronStatus.collectAsState()
val cronJobs by viewModel.cronJobs.collectAsState()
val cronRefreshing by viewModel.cronRefreshing.collectAsState()
val cronSaving by viewModel.cronSaving.collectAsState()
val cronErrorText by viewModel.cronErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
var showEditor by remember { mutableStateOf(false) }
var jobName by remember { mutableStateOf("") }
var jobMessage by remember { mutableStateOf("") }
var scheduleKind by remember { mutableStateOf("Every") }
var scheduleValue by remember { mutableStateOf("1h") }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -224,38 +219,9 @@ private fun CronJobsSettingsScreen(
SettingsMetric("Next Wake", formatCronWake(cronStatus.nextWakeAtMs)),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.weight(1f))
ClawPrimaryButton(text = if (showEditor) "Close" else "New Job", onClick = { showEditor = !showEditor }, enabled = isConnected, modifier = Modifier.weight(1f))
}
if (showEditor) {
CronJobEditorPanel(
name = jobName,
onNameChange = { jobName = it },
message = jobMessage,
onMessageChange = { jobMessage = it },
scheduleKind = scheduleKind,
onScheduleKindChange = {
scheduleKind = it
scheduleValue =
when (it) {
"Once" -> "+30m"
"Cron" -> "0 9 * * *"
else -> "1h"
}
},
scheduleValue = scheduleValue,
onScheduleValueChange = { scheduleValue = it },
saving = cronSaving,
onSave = {
viewModel.createCronJob(
name = jobName,
message = jobMessage,
scheduleKind = scheduleKind,
scheduleValue = scheduleValue,
)
},
)
ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.fillMaxWidth())
ClawPanel {
Text(text = "Android shows scheduled work status. Create and edit schedules from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
cronErrorText?.let { errorText ->
ClawPanel {
@@ -271,7 +237,7 @@ private fun CronJobsSettingsScreen(
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No scheduled jobs.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Create recurring OpenClaw work here or from the WebUI.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Create recurring OpenClaw work from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> CronJobsPanel(jobs = cronJobs)
@@ -279,44 +245,6 @@ private fun CronJobsSettingsScreen(
}
}
@Composable
private fun CronJobEditorPanel(
name: String,
onNameChange: (String) -> Unit,
message: String,
onMessageChange: (String) -> Unit,
scheduleKind: String,
onScheduleKindChange: (String) -> Unit,
scheduleValue: String,
onScheduleValueChange: (String) -> Unit,
saving: Boolean,
onSave: () -> Unit,
) {
val canSave = !saving && name.isNotBlank() && message.isNotBlank() && scheduleValue.isNotBlank()
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "New Job", style = ClawTheme.type.section, color = ClawTheme.colors.text)
ClawTextField(value = name, onValueChange = onNameChange, placeholder = "Name")
ClawTextField(value = message, onValueChange = onMessageChange, placeholder = "Message OpenClaw should run", minLines = 3)
ClawSegmentedControl(
options = listOf("Every", "Once", "Cron"),
selected = scheduleKind,
onSelect = onScheduleKindChange,
modifier = Modifier.fillMaxWidth(),
)
ClawTextField(value = scheduleValue, onValueChange = onScheduleValueChange, placeholder = cronSchedulePlaceholder(scheduleKind))
ClawPrimaryButton(text = if (saving) "Saving" else "Save Job", onClick = onSave, enabled = canSave, modifier = Modifier.fillMaxWidth())
}
}
}
private fun cronSchedulePlaceholder(scheduleKind: String): String =
when (scheduleKind) {
"Once" -> "+30m or 2026-05-19T18:00:00Z"
"Cron" -> "0 9 * * *"
else -> "15m, 1h, or 1d"
}
@Composable
private fun AgentsSettingsScreen(
viewModel: MainViewModel,
@@ -726,6 +654,19 @@ private fun GatewaySettingsScreen(
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val savedBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
val savedGatewayToken by viewModel.gatewayToken.collectAsState()
var setupCode by remember { mutableStateOf("") }
var hostInput by remember(manualHost) { mutableStateOf(manualHost.ifBlank { "127.0.0.1" }) }
var portInput by remember(manualPort) { mutableStateOf(manualPort.toString()) }
var tlsInput by remember(manualTls) { mutableStateOf(manualTls) }
var tokenInput by remember { mutableStateOf("") }
var bootstrapTokenInput by remember { mutableStateOf("") }
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
@@ -742,6 +683,78 @@ private fun GatewaySettingsScreen(
ClawPrimaryButton(text = "Reconnect", onClick = viewModel::refreshGatewayConnection, modifier = Modifier.weight(1f))
ClawSecondaryButton(text = "Disconnect", onClick = viewModel::disconnect, modifier = Modifier.weight(1f))
}
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "Connection Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
ClawTextField(value = setupCode, onValueChange = { setupCode = it }, placeholder = "Setup code")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawTextField(value = hostInput, onValueChange = { hostInput = it }, placeholder = "Host", modifier = Modifier.weight(1f))
ClawTextField(value = portInput, onValueChange = { portInput = it }, placeholder = "Port", modifier = Modifier.weight(0.55f))
}
ClawSegmentedControl(
options = listOf("Local", "TLS"),
selected = if (tlsInput) "TLS" else "Local",
onSelect = { selected -> tlsInput = selected == "TLS" },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawTextField(value = tokenInput, onValueChange = { tokenInput = it }, placeholder = "Token", modifier = Modifier.weight(1f))
ClawTextField(value = bootstrapTokenInput, onValueChange = { bootstrapTokenInput = it }, placeholder = "Bootstrap", modifier = Modifier.weight(1f))
}
ClawTextField(value = passwordInput, onValueChange = { passwordInput = it }, placeholder = "Password")
validationText?.let {
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
}
ClawPrimaryButton(
text = "Save & Connect",
onClick = {
val setup = setupCode.trim().takeIf { it.isNotEmpty() }?.let(::decodeGatewaySetupCode)
val endpointConfig =
if (setup != null) {
parseGatewayEndpointResult(setup.url).config
} else {
composeGatewayManualUrl(hostInput, portInput, tlsInput)?.let { parseGatewayEndpointResult(it).config }
}
if (endpointConfig == null) {
validationText = "Enter a valid setup code or gateway address."
return@ClawPrimaryButton
}
val bootstrapToken =
setup
?.bootstrapToken
?.trim()
.orEmpty()
.ifEmpty { bootstrapTokenInput.trim().ifEmpty { savedBootstrapToken } }
val token =
setup
?.token
?.trim()
.orEmpty()
.ifEmpty { tokenInput.trim().ifEmpty { if (bootstrapToken.isBlank()) savedGatewayToken else "" } }
val password =
setup
?.password
?.trim()
.orEmpty()
.ifEmpty { passwordInput.trim() }
validationText = null
viewModel.setManualEnabled(true)
viewModel.setManualHost(endpointConfig.host)
viewModel.setManualPort(endpointConfig.port)
viewModel.setManualTls(endpointConfig.tls)
viewModel.setGatewayBootstrapToken(bootstrapToken)
viewModel.setGatewayToken(token)
viewModel.setGatewayPassword(password)
viewModel.connect(
GatewayEndpoint.manual(host = endpointConfig.host, port = endpointConfig.port),
token = token.ifEmpty { null },
bootstrapToken = bootstrapToken.ifEmpty { null },
password = password.ifEmpty { null },
)
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}

View File

@@ -7,6 +7,7 @@ import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.HomeDestination
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NodeRuntime
import ai.openclaw.app.ui.chat.ChatScreen
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawEmptyState
@@ -50,10 +51,12 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.MicNone
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -94,6 +97,7 @@ fun ShellScreen(
var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) }
var commandOpen by rememberSaveable { mutableStateOf(false) }
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
LaunchedEffect(requestedHomeDestination) {
val destination = requestedHomeDestination ?: return@LaunchedEffect
@@ -221,10 +225,49 @@ fun ShellScreen(
},
)
}
pendingTrust?.let { prompt ->
GatewayTrustDialog(
prompt = prompt,
onAccept = viewModel::acceptGatewayTrustPrompt,
onDecline = viewModel::declineGatewayTrustPrompt,
)
}
}
}
}
@Composable
private fun GatewayTrustDialog(
prompt: NodeRuntime.GatewayTrustPrompt,
onAccept: () -> Unit,
onDecline: () -> Unit,
) {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
"Verify the certificate fingerprint before trusting this gateway.\n\n${prompt.fingerprintSha256}"
} else {
"The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256:\n${prompt.fingerprintSha256}"
}
AlertDialog(
onDismissRequest = onDecline,
containerColor = ClawTheme.colors.surfaceRaised,
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
text = { Text(message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) },
confirmButton = {
TextButton(onClick = onAccept) {
Text("Trust")
}
},
dismissButton = {
TextButton(onClick = onDecline) {
Text("Cancel")
}
},
)
}
@Composable
private fun OverviewScreen(
viewModel: MainViewModel,

View File

@@ -145,7 +145,7 @@ fun VoiceScreen(
sending = micIsSending,
statusText = activeStatus,
gatewayStatus = gatewayStatus,
onCancel = { viewModel.setMicEnabled(false) },
onCancel = { viewModel.cancelMicCapture() },
onSend = { viewModel.setMicEnabled(false) },
onOpenVoiceSettings = onOpenVoiceSettings,
)

View File

@@ -114,7 +114,10 @@ fun ChatScreen(
}
LaunchedEffect(Unit) {
viewModel.loadChat(mainSessionKey)
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
if (loadSessionKey != null) {
viewModel.loadChat(loadSessionKey)
}
viewModel.refreshChatSessions(limit = 100)
}

View File

@@ -71,6 +71,16 @@ internal suspend fun dispatchPendingAssistantAutoSend(
return dispatch(prompt)
}
internal fun resolveInitialChatLoadSessionKey(
sessionKey: String,
mainSessionKey: String,
): String? {
val current = sessionKey.trim()
val main = mainSessionKey.trim().ifEmpty { "main" }
if (current.isNotEmpty() && current != "main" && current != main) return null
return main
}
@Composable
fun ChatSheetContent(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState()
@@ -87,7 +97,10 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadChat(mainSessionKey)
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
if (loadSessionKey != null) {
viewModel.loadChat(loadSessionKey)
}
}
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {

View File

@@ -186,6 +186,15 @@ class MicCaptureManager(
}
}
fun cancelMicCapture() {
transcriptionDrainJob?.cancel()
transcriptionDrainJob = null
_micEnabled.value = false
_micCooldown.value = false
_liveTranscript.value = null
stop()
}
suspend fun pauseForTts() {
val shouldPause =
synchronized(ttsPauseLock) {

View File

@@ -0,0 +1,40 @@
package ai.openclaw.app
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CronJobStatusParsingTest {
@Test
fun cronJobLastRunStatusReadsGatewayLastStatus() {
val state =
buildJsonObject {
put("lastStatus", JsonPrimitive(" error "))
put("lastRunStatus", JsonPrimitive("success"))
}
assertEquals("error", cronJobLastRunStatus(state))
}
@Test
fun cronJobLastRunStatusReadsLastRunStatus() {
val state =
buildJsonObject {
put("lastRunStatus", JsonPrimitive("error"))
}
assertEquals("error", cronJobLastRunStatus(state))
}
@Test
fun cronJobLastRunStatusIgnoresEmptyStatus() {
val state =
buildJsonObject {
put("lastStatus", JsonPrimitive(" "))
}
assertNull(cronJobLastRunStatus(state))
}
}

View File

@@ -30,14 +30,14 @@ import java.util.UUID
@Config(sdk = [34])
class GatewayBootstrapAuthTest {
@Test
fun connectsOperatorSessionWhenOnlyBootstrapAuthExists() {
assertTrue(
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
),
)
assertTrue(
assertFalse(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
@@ -85,14 +85,14 @@ class GatewayBootstrapAuthTest {
}
@Test
fun resolveOperatorSessionConnectAuthUsesBootstrapWhenNoStoredOperatorTokenExists() {
fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() {
val resolved =
resolveOperatorSessionConnectAuth(
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
)
assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), resolved)
assertNull(resolved)
}
@Test
@@ -183,7 +183,7 @@ class GatewayBootstrapAuthTest {
assertEquals("f1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
assertEquals("setup-bootstrap-token", waitForDesiredBootstrapToken(runtime, "nodeSession"))
assertEquals("setup-bootstrap-token", waitForDesiredBootstrapToken(runtime, "operatorSession"))
assertNull(desiredBootstrapToken(runtime, "operatorSession"))
}
@Test

View File

@@ -116,4 +116,53 @@ class ChatControllerMessageIdentityTest {
assertEquals(listOf("remote-user"), merged.map { it.id })
}
@Test
fun mergeOptimisticMessagesDoesNotDuplicateGatewayPersistedUserTurnWithDifferentTimestamp() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
)
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L)
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
assertEquals(listOf("remote-user"), merged.map { it.id })
}
@Test
fun mergeOptimisticMessagesKeepsRepeatedOptimisticTurnWhenHistoryOnlyHasOneMatch() {
val first =
ChatMessage(
id = "local-user-1",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
)
val second = first.copy(id = "local-user-2", timestampMs = 1100L)
val remoteUser = first.copy(id = "remote-user", timestampMs = 2000L)
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(first, second))
assertEquals(listOf("local-user-2", "remote-user"), merged.map { it.id })
}
@Test
fun mergeOptimisticMessagesDoesNotConsumeOlderIdenticalHistoryTurn() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "ok")),
timestampMs = 2000L,
)
val oldHistoryUser = optimistic.copy(id = "remote-old-user", timestampMs = 1000L)
val merged = mergeOptimisticMessages(incoming = listOf(oldHistoryUser), optimistic = listOf(optimistic))
assertEquals(listOf("remote-old-user", "local-user"), merged.map { it.id })
}
}

View File

@@ -335,7 +335,7 @@ class GatewaySessionInvokeTest {
connectResponseFrame(
id,
authJson =
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.read","operator.talk.secrets","operator.write"]}]}""",
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.pairing","operator.read","operator.talk.secrets","operator.write"]}]}""",
),
)
webSocket.close(1000, "done")
@@ -365,7 +365,7 @@ class GatewaySessionInvokeTest {
assertEquals(emptyList<String>(), nodeEntry?.scopes)
assertEquals("bootstrap-operator-token", operatorEntry?.token)
assertEquals(
listOf("operator.approvals", "operator.read", "operator.write"),
listOf("operator.approvals", "operator.pairing", "operator.read", "operator.write"),
operatorEntry?.scopes,
)
} finally {

View File

@@ -375,6 +375,7 @@ class ConnectionManagerTest {
assertEquals(
listOf(
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
),

View File

@@ -0,0 +1,17 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProviderModelStatusTest {
@Test
fun staticProviderStatusIsReady() {
assertTrue(modelProviderReady("static"))
}
@Test
fun missingProviderStatusIsNotReady() {
assertFalse(modelProviderReady("missing"))
}
}

View File

@@ -71,4 +71,25 @@ class ChatSheetContentTest {
assertTrue(consumed)
assertEquals("summarize mail", dispatchedPrompt)
}
@Test
fun initialChatLoadUsesMainWhenNoSessionIsSelected() {
assertEquals(
"agent:ops:device",
resolveInitialChatLoadSessionKey(
sessionKey = "main",
mainSessionKey = "agent:ops:device",
),
)
}
@Test
fun initialChatLoadPreservesSelectedSession() {
assertNull(
resolveInitialChatLoadSessionKey(
sessionKey = "session:history",
mainSessionKey = "agent:ops:device",
),
)
}
}