docs: clarify inline code comments

Comment-only follow-up documenting reusable gateway, auth, proxy, device, Talk, session, and agent helper contracts.\n\nVerification: git diff --check plus targeted tests recorded in PR body.
This commit is contained in:
Peter Steinberger
2026-05-31 14:37:41 +01:00
committed by GitHub
parent 75e0053cf9
commit 85beee613c
262 changed files with 2351 additions and 61 deletions

View File

@@ -2,10 +2,18 @@ package ai.openclaw.app
import android.content.Intent
/** Android Assistant entry point used by manifest-declared app actions. */
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
/** Debug action that opens the Voice tab directly for Android E2E automation. */
const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E"
/** Intent extra that carries an optional assistant prompt for app actions. */
const val extraAssistantPrompt = "prompt"
/**
* Top-level home destinations that external actions may request.
*/
enum class HomeDestination {
Connect,
Chat,
@@ -14,20 +22,30 @@ enum class HomeDestination {
Settings,
}
/**
* Normalized launch request from Android Assistant or explicit app actions.
*/
data class AssistantLaunchRequest(
val source: String,
val prompt: String?,
val autoSend: Boolean,
)
/**
* Parses app-owned navigation actions that should open a specific home tab.
*/
fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? {
val action = intent?.action ?: return null
return when {
// Debug-only shortcut keeps E2E navigation out of release builds.
BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice
else -> null
}
}
/**
* Parse external assistant entry points without starting any UI side effects.
*/
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
val action = intent?.action ?: return null
return when (action) {

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app
/** Camera HUD state categories shown over the Android UI during capture. */
enum class CameraHudKind {
Photo,
Recording,
@@ -7,6 +8,7 @@ enum class CameraHudKind {
Error,
}
/** One-shot camera HUD message keyed by token so repeated text still replays. */
data class CameraHudState(
val token: Long,
val kind: CameraHudKind,

View File

@@ -5,6 +5,7 @@ import android.os.Build
import android.provider.Settings
object DeviceNames {
/** Prefers the user-visible Android device name, then falls back to manufacturer/model text. */
fun bestDefaultNodeName(context: Context): String {
val deviceName =
runCatching {
@@ -15,6 +16,8 @@ object DeviceNames {
if (deviceName.isNotEmpty()) return deviceName
// Manufacturer/model are best-effort platform fields; keep the final
// fallback stable so stored default names do not become blank.
val model =
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
.joinToString(" ")

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app
/**
* Persisted location capture mode advertised to the gateway.
*/
enum class LocationMode(
val rawValue: String,
) {
@@ -8,8 +11,10 @@ enum class LocationMode(
;
companion object {
/** Parses persisted location mode text while migrating old always-on configs to while-using. */
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
// Older configs used "always"; Android node currently exposes while-using location only.
if (normalized == "always") return WhileUsing
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}

View File

@@ -15,6 +15,9 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
/**
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
*/
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester
@@ -43,6 +46,7 @@ class MainActivity : ComponentActivity() {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.runtimeInitialized.collect { ready ->
if (!ready || didAttachRuntimeUi) return@collect
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
didAttachRuntimeUi = true
if (!didStartNodeService) {
@@ -78,6 +82,9 @@ class MainActivity : ComponentActivity() {
handleAssistantIntent(intent)
}
/**
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
*/
private fun handleAssistantIntent(intent: android.content.Intent?) {
parseHomeDestinationIntent(intent)?.let { destination ->
viewModel.requestHomeDestination(destination)

View File

@@ -22,6 +22,9 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
/**
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(
app: Application,
@@ -39,6 +42,9 @@ class MainViewModel(
private val _pendingAssistantAutoSend = MutableStateFlow<String?>(null)
val pendingAssistantAutoSend: StateFlow<String?> = _pendingAssistantAutoSend
/**
* Lazily starts NodeRuntime and preserves the current foreground bit across startup.
*/
private fun ensureRuntime(): NodeRuntime {
runtimeRef.value?.let { return it }
val runtime = nodeApp.ensureRuntime()
@@ -47,6 +53,9 @@ class MainViewModel(
return runtime
}
/**
* Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
*/
private fun <T> runtimeState(
initial: T,
selector: (NodeRuntime) -> StateFlow<T>,
@@ -185,6 +194,9 @@ class MainViewModel(
val sms: SmsManager
get() = ensureRuntime().sms
/**
* Attaches Activity-owned permission and lifecycle seams after runtime initialization.
*/
fun attachRuntimeUi(
owner: LifecycleOwner,
permissionRequester: PermissionRequester,
@@ -195,6 +207,9 @@ class MainViewModel(
runtime.sms.attachPermissionRequester(permissionRequester)
}
/**
* Starts runtime on foreground entry only after onboarding has completed.
*/
fun setForeground(value: Boolean) {
foreground = value
val runtime =
@@ -254,10 +269,12 @@ class MainViewModel(
prefs.setGatewayPassword(value)
}
/** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */
fun resetGatewaySetupAuth() {
ensureRuntime().resetGatewaySetupAuth()
}
/** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */
fun setOnboardingCompleted(value: Boolean) {
if (value) {
ensureRuntime()
@@ -265,6 +282,7 @@ class MainViewModel(
prefs.setOnboardingCompleted(value)
}
/** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
fun pairNewGateway() {
runtimeRef.value?.disconnect()
resetGatewaySetupAuth()
@@ -272,6 +290,7 @@ class MainViewModel(
prefs.setOnboardingCompleted(false)
}
/** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */
fun clearGatewaySetupStartRequest() {
_startOnboardingAtGatewaySetup.value = false
}
@@ -315,6 +334,7 @@ class MainViewModel(
ensureRuntime().setVoiceScreenActive(active)
}
/** Routes assistant intents into chat, either as a draft or queued auto-send prompt. */
fun handleAssistantLaunch(request: AssistantLaunchRequest) {
_requestedHomeDestination.value = HomeDestination.Chat
if (request.autoSend) {

View File

@@ -3,11 +3,17 @@ package ai.openclaw.app
import android.app.Application
import android.os.StrictMode
/**
* Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup.
*/
class NodeApp : Application() {
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
@Volatile private var runtimeInstance: NodeRuntime? = null
/**
* Returns the single NodeRuntime for this process, creating it on first use.
*/
fun ensureRuntime(): NodeRuntime {
runtimeInstance?.let { return it }
return synchronized(this) {
@@ -15,6 +21,9 @@ class NodeApp : Application() {
}
}
/**
* Reads the runtime without forcing startup, used by lifecycle probes and services.
*/
fun peekRuntime(): NodeRuntime? = runtimeInstance
override fun onCreate() {

View File

@@ -19,6 +19,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
@@ -36,6 +37,8 @@ class NodeForegroundService : Service() {
stopSelf()
return
}
// Split connection and capture flows before combining so notification text
// can update without restarting runtime-owned connection work.
notificationJob =
scope.launch {
combine(
@@ -181,6 +184,7 @@ class NodeForegroundService : Service() {
private fun startForegroundWithTypes(notification: Notification) {
val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode)
if (didStartForeground) {
// Re-issue startForeground when Talk mode toggles so Android sees the microphone service type.
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
return
}
@@ -196,16 +200,19 @@ class NodeForegroundService : Service() {
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
/** Starts the persistent node foreground service from UI lifecycle code. */
fun start(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java)
context.startForegroundService(intent)
}
/** Requests disconnect through the service action path so notification actions and UI share behavior. */
fun stop(context: Context) {
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
/** Updates Android's foreground-service type before voice capture mode changes require microphone access. */
fun setVoiceCaptureMode(
context: Context,
mode: VoiceCaptureMode,
@@ -215,6 +222,7 @@ class NodeForegroundService : Service() {
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
.putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name)
if (mode == VoiceCaptureMode.TalkMode) {
// Microphone foreground service type must be declared before Talk capture starts.
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
@@ -223,6 +231,9 @@ class NodeForegroundService : Service() {
}
}
/**
* Foreground-service type mask required by Android for the current voice capture mode.
*/
internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
return if (mode == VoiceCaptureMode.TalkMode) {
@@ -232,6 +243,9 @@ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
}
}
/**
* Compact notification suffix for voice state; kept pure for service-notification tests.
*/
internal fun voiceNotificationSuffix(
mode: VoiceCaptureMode,
manualMicEnabled: Boolean,
@@ -260,6 +274,7 @@ private fun String?.toVoiceCaptureMode(): VoiceCaptureMode =
it.name == this
} ?: VoiceCaptureMode.Off
/** Connection fields that drive foreground notification title/body text. */
private data class VoiceNotificationBase(
val status: String,
val server: String?,
@@ -267,6 +282,7 @@ private data class VoiceNotificationBase(
val mode: VoiceCaptureMode,
)
/** Voice capture fields that affect foreground-service type and suffix. */
private data class VoiceNotificationCapture(
val micEnabled: Boolean,
val micListening: Boolean,
@@ -274,6 +290,7 @@ private data class VoiceNotificationCapture(
val talkSpeaking: Boolean,
)
/** Aggregated notification state from runtime flows. */
private data class VoiceNotificationState(
val base: VoiceNotificationBase,
val capture: VoiceNotificationCapture,

View File

@@ -75,11 +75,17 @@ import kotlinx.serialization.json.buildJsonObject
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
/**
* Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state.
*/
class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
private val tlsFingerprintProbe: suspend (String, Int) -> GatewayTlsProbeResult = ::probeGatewayTlsFingerprint,
) {
/**
* Authentication material supplied by setup/manual connect flows before gateway session routing.
*/
data class GatewayConnectAuth(
val token: String?,
val bootstrapToken: String?,
@@ -251,6 +257,9 @@ class NodeRuntime(
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
)
/**
* Pending TLS trust decision when a gateway certificate is new or has changed.
*/
data class GatewayTrustPrompt(
val endpoint: GatewayEndpoint,
val fingerprintSha256: String,
@@ -282,6 +291,9 @@ class NodeRuntime(
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
private val connectAttemptSeq = AtomicLong(0)
/**
* Builds the node-owned session key from stable device identity plus optional active agent.
*/
private fun resolveNodeMainSessionKey(agentId: String? = null): String {
val deviceId = identityStore.loadOrCreate().deviceId
return buildNodeMainSessionKey(deviceId, agentId)
@@ -841,6 +853,7 @@ class NodeRuntime(
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
/** Clears setup credentials plus paired device tokens for both Android gateway roles. */
fun resetGatewaySetupAuth() {
prefs.clearGatewaySetupAuth()
val deviceId = identityStore.loadOrCreate().deviceId
@@ -848,6 +861,7 @@ class NodeRuntime(
deviceAuthStore.clearToken(deviceId, "operator")
}
/** Persists onboarding state; callers decide whether runtime startup is needed first. */
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
@@ -917,6 +931,7 @@ class NodeRuntime(
updateHomeCanvasState()
}
/** Updates foreground state and triggers reconnect/presence behavior on app visibility changes. */
fun setForeground(value: Boolean) {
_isForeground.value = value
if (value) {
@@ -1006,6 +1021,8 @@ class NodeRuntime(
if (didAutoConnect) return
if (_isConnected.value) return
val endpoint = resolvePreferredGatewayEndpoint() ?: return
// Only attempt the stored preferred gateway once per runtime lifetime; users
// can still reconnect explicitly from the UI after a failed auto attempt.
didAutoConnect = true
connect(endpoint)
}

View File

@@ -3,6 +3,7 @@ package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
/** Package-filter mode used before notification events are forwarded to the gateway. */
enum class NotificationPackageFilterMode(
val rawValue: String,
) {
@@ -11,10 +12,12 @@ enum class NotificationPackageFilterMode(
;
companion object {
/** Parses persisted filter mode text, defaulting to blocklist for safer forwarding. */
fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
}
/** Runtime policy used before forwarding notification events to a node session. */
internal data class NotificationForwardingPolicy(
val enabled: Boolean,
val mode: NotificationPackageFilterMode,
@@ -26,6 +29,7 @@ internal data class NotificationForwardingPolicy(
val sessionKey: String?,
)
/** Applies the operator-configured package allow/block list after trimming input. */
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
val normalized = packageName.trim()
if (normalized.isEmpty()) {
@@ -37,6 +41,7 @@ internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Bo
}
}
/** Returns true for both same-day and overnight quiet-hour windows. */
internal fun NotificationForwardingPolicy.isWithinQuietHours(
nowEpochMs: Long,
zoneId: ZoneId = ZoneId.systemDefault(),
@@ -64,12 +69,14 @@ internal fun NotificationForwardingPolicy.isWithinQuietHours(
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
/** Normalizes persisted or user-entered local times to strict HH:mm form. */
internal fun normalizeLocalHourMinute(raw: String): String? {
val trimmed = raw.trim()
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
return "${match.groupValues[1]}:${match.groupValues[2]}"
}
/** Converts strict local HH:mm text to minutes since midnight for window checks. */
internal fun parseLocalHourMinute(raw: String): Int? {
val normalized = normalizeLocalHourMinute(raw) ?: return null
val parts = normalized.split(':')
@@ -78,11 +85,13 @@ internal fun parseLocalHourMinute(raw: String): Int? {
return hour * 60 + minute
}
/** Fixed-window limiter that bounds notification bursts per wall-clock minute. */
internal class NotificationBurstLimiter {
private val lock = Any()
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
/** Returns true when the current minute bucket still has forwarding capacity. */
fun allow(
nowEpochMs: Long,
maxEventsPerMinute: Int,
@@ -90,6 +99,8 @@ internal class NotificationBurstLimiter {
if (maxEventsPerMinute <= 0) {
return false
}
// Align all callers to the same minute bucket so concurrent notifications
// share the quota even when they arrive with slightly different timestamps.
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
synchronized(lock) {
if (currentWindow != windowStartMs) {

View File

@@ -26,6 +26,9 @@ import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
/**
* Serializes Android runtime-permission prompts behind coroutine-friendly request calls.
*/
class PermissionRequester internal constructor(
private val activity: ComponentActivity,
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
@@ -50,8 +53,12 @@ class PermissionRequester internal constructor(
private val mutex = Mutex()
private val requestSlotsLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
// ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows.
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
/**
* Request missing Android runtime permissions and return the final grant state for every requested permission.
*/
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
@@ -93,6 +100,7 @@ class PermissionRequester internal constructor(
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
// Late ActivityResult callbacks are ignored by completePermissionRequest.
request.timedOut = true
throw err
}
@@ -130,6 +138,7 @@ class PermissionRequester internal constructor(
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
synchronized(requestSlotsLock) {
// The outer mutex serializes normal callers; this guard catches accidental concurrent launchers in tests.
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
slot.request = request
slot
@@ -145,6 +154,7 @@ class PermissionRequester internal constructor(
slot.request = null
}
} ?: return
// Timed-out requests have already resumed callers with failure; ignore any late platform callback.
if (request.timedOut) return
request.deferred.complete(result)
}
@@ -186,6 +196,7 @@ class PermissionRequester internal constructor(
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
// Do not resume a destroyed Activity with a positive result.
finish(false)
}
observer = actualObserver

View File

@@ -15,6 +15,9 @@ import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
/**
* Reactive settings facade for Android node preferences and encrypted gateway credentials.
*/
class SecurePrefs(
context: Context,
private val securePrefsOverride: SharedPreferences? = null,
@@ -42,9 +45,11 @@ class SecurePrefs(
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
// Gateway credentials and arbitrary secret strings are isolated behind EncryptedSharedPreferences.
private val masterKey by lazy {
MasterKey
.Builder(appContext)
@@ -253,6 +258,7 @@ class SecurePrefs(
val configuredPackages = loadNotificationForwardingPackages()
val normalizedAppPackage = appPackageName.trim()
// Always block OpenClaw's own notifications in blocklist mode to prevent forwarding loops.
val defaultBlockedPackages =
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
@@ -311,6 +317,7 @@ class SecurePrefs(
.toSet()
.toList()
.sorted()
// Persist deterministic JSON so settings diffs and state restoration are stable.
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
_notificationForwardingPackages.value = sanitized.toSet()
@@ -355,6 +362,7 @@ class SecurePrefs(
_notificationForwardingSessionKey.value = normalized
}
/** Loads manual or instance-scoped gateway token material from encrypted preferences. */
fun loadGatewayToken(): String? {
val manual =
_gatewayToken.value.trim().ifEmpty {
@@ -363,16 +371,19 @@ class SecurePrefs(
stored
}
if (manual.isNotEmpty()) return manual
// Per-instance tokens keep reused Android installs from sharing stale gateway auth.
val key = "gateway.token.${_instanceId.value}"
val stored = securePrefs.getString(key, null)?.trim()
return stored?.takeIf { it.isNotEmpty() }
}
/** Saves the paired gateway token under the current Android instance id. */
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
securePrefs.edit { putString(key, token.trim()) }
}
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
fun loadGatewayBootstrapToken(): String? {
val key = "gateway.bootstrapToken.${_instanceId.value}"
val stored =
@@ -404,9 +415,11 @@ class SecurePrefs(
securePrefs.edit { putString(key, password.trim()) }
}
/** Clears manual/setup credentials without removing persisted role-specific device tokens. */
fun clearGatewaySetupAuth() {
val instanceId = _instanceId.value
securePrefs.edit {
// Clear both current manual credentials and instance-scoped setup credentials after pairing/reset.
remove("gateway.manual.token")
remove("gateway.token.$instanceId")
remove("gateway.bootstrapToken.$instanceId")
@@ -416,11 +429,13 @@ class SecurePrefs(
_gatewayBootstrapToken.value = ""
}
/** Loads the pinned gateway TLS fingerprint for a discovered/manual stable endpoint id. */
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
/** Persists the gateway TLS fingerprint captured through TOFU or explicit trust. */
fun saveGatewayTlsFingerprint(
stableId: String,
fingerprint: String,
@@ -457,6 +472,7 @@ class SecurePrefs(
private fun loadOrCreateInstanceId(): String {
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
// Instance id is not secret; it scopes local credentials and survives display-name changes.
val fresh = UUID.randomUUID().toString()
plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh
@@ -466,6 +482,7 @@ class SecurePrefs(
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
// Replace the historical generic name with a device-specific default once.
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
@@ -473,6 +490,7 @@ class SecurePrefs(
return resolved
}
/** Persists sanitized voice wake triggers and updates the reactive settings flow. */
fun setWakeWords(words: List<String>) {
val sanitized = WakeWords.sanitize(words, defaultWakeWords)
val encoded =
@@ -521,7 +539,7 @@ class SecurePrefs(
val raw = plainPrefs.getString(voiceWakeModeKey, null)
val resolved = VoiceWakeMode.fromRawValue(raw)
// Default ON (foreground) when unset.
// Default ON (foreground) when unset, but keep "always" opt-in through explicit settings.
if (raw.isNullOrBlank()) {
plainPrefs.edit { putString(voiceWakeModeKey, resolved.rawValue) }
}
@@ -533,6 +551,7 @@ class SecurePrefs(
val raw = plainPrefs.getString(locationModeKey, "off")
val resolved = LocationMode.fromRawValue(raw)
if (raw?.trim()?.lowercase() == "always") {
// Migrate old "always" configs to the current while-using contract.
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
}
return resolved

View File

@@ -1,10 +1,12 @@
package ai.openclaw.app
/** Normalizes blank gateway session keys to the legacy main session alias. */
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Accepts only gateway session keys that can represent the main chat stream. */
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
@@ -12,6 +14,7 @@ internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
return trimmed.startsWith("agent:")
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
if (!trimmed.startsWith("agent:")) return null
@@ -22,6 +25,7 @@ internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
.ifEmpty { null }
}
/** Builds the node session key shape consumed by gateway chat and presence APIs. */
internal fun buildNodeMainSessionKey(
deviceId: String,
agentId: String?,

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app
/**
* Persisted voice capture mode that controls foreground-service microphone requirements.
*/
enum class VoiceCaptureMode {
Off,
ManualMic,

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app
/**
* Persisted wake-word mode; raw values are stored in secure preferences.
*/
enum class VoiceWakeMode(
val rawValue: String,
) {
@@ -9,6 +12,9 @@ enum class VoiceWakeMode(
;
companion object {
/**
* Invalid stored values fall back to foreground wake so hands-free behavior stays opt-in.
*/
fun fromRawValue(raw: String?): VoiceWakeMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Foreground
}
}

View File

@@ -1,11 +1,16 @@
package ai.openclaw.app
/**
* Wake-word parsing limits and sanitizers shared by settings and voice runtime paths.
*/
object WakeWords {
const val maxWords: Int = 32
const val maxWordLength: Int = 64
/** Splits comma-separated user input into non-empty wake-word entries. */
fun parseCommaSeparated(input: String): List<String> = input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
/** Returns null when edited text normalizes to the current wake-word list. */
fun parseIfChanged(
input: String,
current: List<String>,
@@ -14,6 +19,7 @@ object WakeWords {
return if (parsed == current) null else parsed
}
/** Applies persisted-list bounds and falls back to defaults when all entries are empty. */
fun sanitize(
words: List<String>,
defaults: List<String>,

View File

@@ -61,12 +61,15 @@ class ChatController(
private val pendingRuns = mutableSetOf<String>()
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
// Preserve sent messages locally until chat.history includes the gateway-confirmed copy.
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
private val pendingRunTimeoutMs = 120_000L
// Drops stale history responses after session switches or refresh races.
private val historyLoadGeneration = AtomicLong(0)
private var lastHealthPollAtMs: Long? = null
/** Clears transient chat state when the operator gateway session disconnects. */
fun onDisconnected(message: String) {
_healthOk.value = false
_errorText.value = null
@@ -78,6 +81,7 @@ class ChatController(
_sessionId.value = null
}
/** Loads a chat session, normalizing "main" to the current gateway-provided main session key. */
fun load(sessionKey: String) {
val key = normalizeRequestedSessionKey(sessionKey)
val generation = beginHistoryLoad(key, clearMessages = key != _sessionKey.value)
@@ -86,6 +90,7 @@ class ChatController(
}
}
/** Rebinds chat to a new canonical main session key after gateway hello/agent changes. */
fun applyMainSessionKey(mainSessionKey: String) {
val trimmed = mainSessionKey.trim()
if (trimmed.isEmpty()) return
@@ -108,6 +113,7 @@ class ChatController(
}
}
/** Refreshes current chat history and session list without clearing optimistic messages first. */
fun refresh() {
val key = normalizeRequestedSessionKey(_sessionKey.value)
val generation = beginHistoryLoad(key, clearMessages = false)
@@ -120,12 +126,14 @@ class ChatController(
scope.launch { fetchSessions(limit = limit) }
}
/** Persists the normalized thinking level used for subsequent chat sends. */
fun setThinkingLevel(thinkingLevel: String) {
val normalized = normalizeThinking(thinkingLevel)
if (normalized == _thinkingLevel.value) return
_thinkingLevel.value = normalized
}
/** Switches to another gateway chat session and starts a fresh history load. */
fun switchSession(sessionKey: String) {
val key = normalizeRequestedSessionKey(sessionKey)
if (key.isEmpty()) return
@@ -163,6 +171,7 @@ class ChatController(
return key
}
/** Queues a chat send without waiting for gateway acceptance. */
fun sendMessage(
message: String,
thinkingLevel: String,
@@ -177,6 +186,7 @@ class ChatController(
}
}
/** Sends a chat message and returns once the gateway accepts or rejects the request. */
suspend fun sendMessageAwaitAcceptance(
message: String,
thinkingLevel: String,
@@ -194,7 +204,7 @@ class ChatController(
val sessionKey = _sessionKey.value
val thinking = normalizeThinking(thinkingLevel)
// Optimistic user message.
// Optimistic user message keeps the composer responsive while chat.send and history refresh complete.
val userContent =
buildList {
add(ChatMessageContent(type = "text", text = text))
@@ -257,6 +267,7 @@ class ChatController(
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
clearPendingRun(runId)
armPendingRunTimeout(actualRunId)
@@ -274,6 +285,7 @@ class ChatController(
}
}
/** Sends best-effort abort requests for every currently pending gateway run. */
fun abort() {
val runIds =
synchronized(pendingRuns) {
@@ -296,6 +308,7 @@ class ChatController(
}
}
/** Applies gateway chat/agent stream events to local transcript and pending-run state. */
fun handleGatewayEvent(
event: String,
payloadJson: String?,
@@ -396,7 +409,7 @@ class ChatController(
val state = payload["state"].asStringOrNull()
when (state) {
"delta" -> {
// Only show streaming text for runs we initiated
// Only show streaming text for runs we initiated in this controller.
if (!isPending) return
val text = parseAssistantDeltaText(payload)
if (!text.isNullOrEmpty()) {
@@ -637,6 +650,9 @@ internal fun isCurrentHistoryLoad(
activeGeneration: Long,
): Boolean = requestedSessionKey == currentSessionKey && requestGeneration == activeGeneration
/**
* Convert gateway chat content parts into Android UI content parts.
*/
internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
val obj = el.asObjectOrNull() ?: return null
return when (obj["type"].asStringOrNull() ?: "text") {
@@ -663,6 +679,9 @@ internal data class MainSessionState(
val appliedMainSessionKey: String,
)
/**
* Rewrite only the active "main" alias when the gateway publishes a new canonical main session key.
*/
internal fun applyMainSessionKey(
currentSessionKey: String,
appliedMainSessionKey: String,
@@ -680,6 +699,9 @@ internal fun applyMainSessionKey(
)
}
/**
* Keep Compose item identity stable across history refreshes by matching existing messages to incoming copies.
*/
internal fun reconcileMessageIds(
previous: List<ChatMessage>,
incoming: List<ChatMessage>,
@@ -729,6 +751,9 @@ internal fun mergeOptimisticMessages(
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
}
/**
* Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys.
*/
internal fun messageIdentityKey(message: ChatMessage): String? {
val contentKey = messageContentIdentityKey(message) ?: return null
val timestamp = message.timestampMs?.toString().orEmpty()

View File

@@ -1,5 +1,8 @@
package ai.openclaw.app.chat
/**
* Chat transcript item as delivered by gateway chat history and live chat events.
*/
data class ChatMessage(
val id: String,
val role: String,
@@ -7,6 +10,9 @@ data class ChatMessage(
val timestampMs: Long?,
)
/**
* One content part in a chat message; binary parts carry base64 plus their MIME metadata.
*/
data class ChatMessageContent(
val type: String = "text",
val text: String? = null,
@@ -15,6 +21,9 @@ data class ChatMessageContent(
val base64: String? = null,
)
/**
* Tool call placeholder shown while a gateway run is still streaming.
*/
data class ChatPendingToolCall(
val toolCallId: String,
val name: String,
@@ -23,12 +32,18 @@ data class ChatPendingToolCall(
val isError: Boolean? = null,
)
/**
* Stable session selector row; [key] is the gateway session key used in chat requests.
*/
data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val displayName: String? = null,
)
/**
* Snapshot of one chat session, including optional thinking level selected on the gateway.
*/
data class ChatHistory(
val sessionKey: String,
val sessionId: String?,
@@ -36,6 +51,9 @@ data class ChatHistory(
val messages: List<ChatMessage>,
)
/**
* User-selected attachment payload sent to the gateway as inline base64.
*/
data class OutgoingAttachment(
val type: String,
val mimeType: String,

View File

@@ -1,6 +1,10 @@
package ai.openclaw.app.gateway
/**
* Decoder for Bonjour DNS-SD service names returned with decimal byte escapes.
*/
object BonjourEscapes {
/** Decodes Bonjour DNS-SD decimal escapes while preserving ordinary UTF-8. */
fun decode(input: String): String {
if (input.isEmpty()) return input
@@ -15,6 +19,7 @@ object BonjourEscapes {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..255) {
// Bonjour escape bytes are decimal octets, not Unicode code points.
bytes.add(value.toByte())
i += 4
continue

View File

@@ -1,6 +1,10 @@
package ai.openclaw.app.gateway
/**
* Canonical device-auth payload builder shared with gateway verification rules.
*/
internal object DeviceAuthPayload {
/** Builds the canonical v3 auth string signed by device registration flows. */
fun buildV3(
deviceId: String,
clientId: String,
@@ -32,6 +36,7 @@ internal object DeviceAuthPayload {
).joinToString("|")
}
/** Normalizes signed metadata fields without locale-sensitive lowercasing. */
internal fun normalizeMetadataField(value: String?): String {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) {

View File

@@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/** Stored gateway device-token material scoped by device id and role. */
data class DeviceAuthEntry(
val token: String,
val role: String,
@@ -18,17 +19,21 @@ private data class PersistedDeviceAuthMetadata(
val updatedAtMs: Long = 0L,
)
/** Persistence interface used by gateway pairing/session code for role tokens. */
interface DeviceAuthTokenStore {
/** Loads the stored token plus metadata for one device/role pair. */
fun loadEntry(
deviceId: String,
role: String,
): DeviceAuthEntry?
/** Loads only the bearer token when callers do not need scope metadata. */
fun loadToken(
deviceId: String,
role: String,
): String? = loadEntry(deviceId, role)?.token
/** Persists a role token and deterministic scope metadata under normalized keys. */
fun saveToken(
deviceId: String,
role: String,
@@ -36,12 +41,14 @@ interface DeviceAuthTokenStore {
scopes: List<String> = emptyList(),
)
/** Removes both token and metadata for the normalized device/role pair. */
fun clearToken(
deviceId: String,
role: String,
)
}
/** SecurePrefs-backed implementation of Android gateway device-token storage. */
class DeviceAuthStore(
private val prefs: SecurePrefs,
) : DeviceAuthTokenStore {
@@ -103,6 +110,8 @@ class DeviceAuthStore(
): String {
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
// Keep key normalization shared with metadata keys so token and metadata
// are added/removed as one logical auth entry.
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
}
@@ -115,14 +124,19 @@ class DeviceAuthStore(
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
}
/** Normalizes device ids before they become encrypted preference key segments. */
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
/** Normalizes role names so node/operator token slots are stable across callers. */
private fun normalizeRole(role: String): String = role.trim().lowercase()
/** Stores scopes in deterministic order for display and restart comparisons. */
private fun normalizeScopes(scopes: List<String>): List<String> =
scopes
.map { it.trim() }
.filter { it.isNotEmpty() }
// Persist deterministic scope lists because they are displayed and may be
// compared across process restarts.
.distinct()
.sorted()
}

View File

@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
/** Persistent Ed25519 identity used to register this Android node with gateways. */
@Serializable
data class DeviceIdentity(
val deviceId: String,
@@ -15,6 +16,7 @@ data class DeviceIdentity(
val createdAtMs: Long,
)
/** Owns device identity generation, persistence, and auth payload signatures. */
class DeviceIdentityStore(
context: Context,
) {
@@ -23,6 +25,7 @@ class DeviceIdentityStore(
@Volatile private var cachedIdentity: DeviceIdentity? = null
/** Loads the persisted identity or creates one, repairing old device-id drift. */
@Synchronized
fun loadOrCreate(): DeviceIdentity {
cachedIdentity?.let { return it }
@@ -44,12 +47,13 @@ class DeviceIdentityStore(
return fresh
}
/** Signs gateway connect payload text with the persisted Ed25519 private key. */
fun signPayload(
payload: String,
identity: DeviceIdentity,
): String? =
try {
// Use BC lightweight API directly JCA provider registration is broken by R8
// Use BC lightweight API directly; R8 can break JCA provider registration.
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val pkInfo =
org.bouncycastle.asn1.pkcs.PrivateKeyInfo
@@ -74,6 +78,7 @@ class DeviceIdentityStore(
null
}
/** Verifies a signature against the persisted public key for debug diagnostics. */
fun verifySelfSignature(
payload: String,
signatureBase64Url: String,
@@ -97,12 +102,16 @@ class DeviceIdentityStore(
false
}
/** Decodes gateway URL-safe base64 signatures, accepting unpadded input. */
private fun base64UrlDecode(input: String): ByteArray {
val normalized = input.replace('-', '+').replace('_', '/')
// Android Base64 expects padded input; gateway signatures are URL-safe
// unpadded strings.
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
return Base64.decode(padded, Base64.DEFAULT)
}
/** Returns the public key in the gateway's unpadded URL-safe base64 format. */
fun publicKeyBase64Url(identity: DeviceIdentity): String? =
try {
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
@@ -142,7 +151,7 @@ class DeviceIdentityStore(
}
private fun generate(): DeviceIdentity {
// Use BC lightweight API directly to avoid JCA provider issues with R8
// Use BC lightweight API directly to avoid JCA provider issues with R8.
val kpGen =
org.bouncycastle.crypto.generators
.Ed25519KeyPairGenerator()
@@ -155,7 +164,8 @@ class DeviceIdentityStore(
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
val rawPublic = pubKey.encoded // 32 bytes
val deviceId = sha256Hex(rawPublic)
// Encode private key as PKCS8 for storage
// Store private key as PKCS8 so signPayload can parse the same persisted
// shape after app restarts and upgrades.
val privKeyInfo =
org.bouncycastle.crypto.util.PrivateKeyInfoFactory
.createPrivateKeyInfo(privKey)
@@ -168,6 +178,7 @@ class DeviceIdentityStore(
)
}
/** Re-derives the stable device id from the raw Ed25519 public key bytes. */
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
try {
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)

View File

@@ -49,6 +49,9 @@ import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
*/
class GatewayDiscovery(
context: Context,
private val scope: CoroutineScope,
@@ -63,9 +66,11 @@ class GatewayDiscovery(
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
/** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
private val _statusText = MutableStateFlow("Searching…")
/** Short diagnostic text shown by connect UI while discovery is running. */
val statusText: StateFlow<String> = _statusText.asStateFlow()
private var unicastJob: Job? = null
@@ -130,6 +135,8 @@ class GatewayDiscovery(
val cm = connectivity ?: return
cm.activeNetwork?.let(availableNetworks::add)
try {
// Track all networks so wide-area DNS can prefer VPN/split-DNS answers
// even when Android's active network is not the VPN.
cm.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback)
} catch (_: Throwable) {
// ignore (best-effort)
@@ -168,6 +175,7 @@ class GatewayDiscovery(
private fun resolve(serviceInfo: NsdServiceInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Android 14+ streams service updates; older releases require one-shot resolve calls.
resolveWithServiceInfoCallback(serviceInfo)
} else {
resolveLegacy(serviceInfo)
@@ -255,6 +263,7 @@ class GatewayDiscovery(
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
// Local NSD gives the socket host/port; TXT ports are retained as gateway metadata only.
localById[id] =
GatewayEndpoint(
stableId = id,
@@ -288,6 +297,7 @@ class GatewayDiscovery(
private fun publish() {
_gateways.value =
// Merge local and wide-area results deterministically for stable UI selection.
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
_statusText.value = buildStatusText()
}
@@ -369,6 +379,7 @@ class GatewayDiscovery(
?: resolveHostUnicast(targetFqdn)
?: continue
// Wide-area DNS-SD may put TXT in additional records; fall back to a direct TXT query.
val txtFromPtr =
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
.orEmpty()
@@ -454,6 +465,7 @@ class GatewayDiscovery(
val system = queryViaSystemDns(query)
if (records(system, Section.ANSWER).any { it.type == type }) return system
// Android's DnsResolver can miss split-DNS answers; retry with dnsjava against network DNS servers.
val direct = createDirectResolver() ?: return system
return try {
val msg = direct.send(query)
@@ -548,6 +560,7 @@ class GatewayDiscovery(
val candidateNetworks =
buildList {
// Put VPN DNS first so Tailscale split-horizon names win over public DNS.
trackedNetworks(cm)
.firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.gateway
/** Resolved gateway address and optional metadata discovered from Bonjour/manual entry. */
data class GatewayEndpoint(
val stableId: String,
val name: String,
@@ -13,6 +14,7 @@ data class GatewayEndpoint(
val tlsFingerprintSha256: String? = null,
) {
companion object {
/** Builds a stable manual endpoint key that survives display-name changes. */
fun manual(
host: String,
port: Int,

View File

@@ -4,6 +4,7 @@ import android.os.Build
import java.net.InetAddress
import java.util.Locale
/** Returns true only for loopback hosts safe to treat as local gateway origins. */
internal fun isLoopbackGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
@@ -18,9 +19,12 @@ internal fun isLoopbackGatewayHost(
host = host.dropLast(1)
}
val zoneIndex = host.indexOf('%')
// Scoped IPv6 literals are not stable origin identifiers; reject them for
// loopback trust instead of guessing which interface the zone names.
if (zoneIndex >= 0) return false
if (host.isEmpty()) return false
if (host == "localhost") return true
// Android emulator maps host loopback through this bridge alias.
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
parseIpv4Address(host)?.let { ipv4 ->
@@ -44,6 +48,7 @@ internal fun isLoopbackGatewayHost(
return isMappedIpv4 && address[12] == 127.toByte()
}
/** Allows cleartext only for loopback and private/link-local network ranges. */
internal fun isLocalCleartextGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
@@ -59,6 +64,8 @@ internal fun isLocalCleartextGatewayHost(
}
val zoneIndex = host.indexOf('%')
if (zoneIndex >= 0) {
// Link-local cleartext policy is about the address range; strip the
// interface zone before InetAddress parsing rejects otherwise valid hosts.
host = host.substring(0, zoneIndex)
}
if (host.isEmpty()) return false
@@ -107,6 +114,7 @@ private fun isAndroidEmulatorRuntime(): Boolean {
product.contains("simulator")
}
/** Parses strict dotted-quad IPv4, rejecting shorthand and out-of-range octets. */
private fun parseIpv4Address(host: String): ByteArray? {
val parts = host.split('.')
if (parts.size != 4) return null
@@ -119,4 +127,5 @@ private fun parseIpv4Address(host: String): ByteArray? {
return bytes
}
/** Cheap prefilter before handing potential IPv6 literals to InetAddress. */
private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.'

View File

@@ -1,4 +1,7 @@
package ai.openclaw.app.gateway
/** Gateway protocol version emitted by Android node clients. */
const val GATEWAY_PROTOCOL_VERSION = 4
/** Oldest gateway protocol version this Android client can speak safely. */
const val GATEWAY_MIN_PROTOCOL_VERSION = 4

View File

@@ -33,6 +33,9 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
/**
* Identity advertised during gateway connect; these fields become the device row users approve.
*/
data class GatewayClientInfo(
val id: String,
val displayName: String?,
@@ -44,6 +47,9 @@ data class GatewayClientInfo(
val modelIdentifier: String?,
)
/**
* Role, scopes, commands, and permission snapshot sent with the connect frame.
*/
data class GatewayConnectOptions(
val role: String,
val scopes: List<String>,
@@ -62,6 +68,9 @@ private enum class GatewayConnectAuthSource {
NONE,
}
/**
* Structured auth failure guidance from the gateway, preserved for reconnect and UI decisions.
*/
data class GatewayConnectErrorDetails(
val code: String?,
val canRetryWithDeviceToken: Boolean,
@@ -70,6 +79,9 @@ data class GatewayConnectErrorDetails(
val reason: String? = null,
)
/**
* Server hello fields cached by the Android runtime after a successful connect.
*/
data class GatewayHelloSummary(
val serverName: String?,
val remoteAddress: String?,
@@ -99,6 +111,9 @@ private class GatewayConnectFailure(
val gatewayError: GatewaySession.ErrorShape,
) : IllegalStateException(gatewayError.message)
/**
* WebSocket RPC session that maintains gateway connection lifecycle, auth, events, and node invokes.
*/
class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
@@ -114,6 +129,9 @@ class GatewaySession(
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
}
/**
* Gateway node.invoke request routed to Android command handlers.
*/
data class InvokeRequest(
val id: String,
val nodeId: String,
@@ -143,6 +161,9 @@ class GatewaySession(
val details: GatewayConnectErrorDetails? = null,
)
/**
* Structured RPC result used by callers that need error codes without exceptions.
*/
data class RpcResult(
val ok: Boolean,
val payloadJson: String?,
@@ -174,12 +195,15 @@ class GatewaySession(
@Volatile private var currentConnection: Connection? = null
// One reconnect can retry a shared-token mismatch by pairing the shared token with the stored device token.
@Volatile private var pendingDeviceTokenRetry = false
// Keep the mismatch retry single-shot so an invalid stored token cannot create an auth loop.
@Volatile private var deviceTokenRetryBudgetUsed = false
@Volatile private var reconnectPausedForAuthFailure = false
/** Starts or replaces the desired gateway connection and launches the reconnect loop. */
fun connect(
endpoint: GatewayEndpoint,
token: String?,
@@ -202,6 +226,7 @@ class GatewaySession(
connectionToClose?.closeQuietly()
}
/** Clears desired connection state, closes the socket, and stops reconnect attempts. */
fun disconnect() {
val jobToCancel: Job?
val connectionToClose: Connection?
@@ -225,6 +250,7 @@ class GatewaySession(
}
}
/** Forces the current socket closed so the loop reconnects to the current desired endpoint. */
fun reconnect() {
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
@@ -232,6 +258,7 @@ class GatewaySession(
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
val refreshed =
refreshPluginSurfaceUrl(
@@ -247,6 +274,7 @@ class GatewaySession(
fun currentMainSessionKey(): String? = mainSessionKey
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
payloadJson: String?,
@@ -287,6 +315,7 @@ class GatewaySession(
}
}
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
suspend fun sendNodeEventDetailed(
event: String,
payloadJson: String?,
@@ -319,9 +348,11 @@ class GatewaySession(
): JsonObject =
buildJsonObject {
put("event", JsonPrimitive(event))
// Gateway node events carry payloadJSON as a string for compatibility with non-JSON payload producers.
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
/** Sends an RPC request and throws a code-prefixed exception when the gateway returns an error. */
suspend fun request(
method: String,
paramsJson: String?,
@@ -333,6 +364,7 @@ class GatewaySession(
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
}
/** Sends an RPC request and returns the structured success/error payload. */
suspend fun requestDetailed(
method: String,
paramsJson: String?,
@@ -349,6 +381,7 @@ class GatewaySession(
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
}
/** Sends an RPC request frame and reports errors asynchronously through [onError]. */
suspend fun sendRequestFrame(
method: String,
paramsJson: String?,
@@ -705,6 +738,7 @@ class GatewaySession(
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
}
if (shouldPersistBootstrapHandoffTokens(authSource)) {
// Bootstrap connects can mint role-specific device tokens; store only locally trusted handoffs.
authObj
?.get("deviceTokens")
.asArrayOrNull()
@@ -725,6 +759,7 @@ class GatewaySession(
val rawPluginSurfaceUrls = obj["pluginSurfaceUrls"].asObjectOrNull()
val normalizedPluginSurfaceUrls =
rawPluginSurfaceUrls?.mapNotNull { (surface, value) ->
// Canvas URLs may be loopback gateway metadata; normalize them to the reachable Android endpoint.
normalizeCanvasHostUrl(value.asStringOrNull(), endpoint, isTlsConnection = tls != null)
?.let { normalized -> surface to normalized }
} ?: emptyList()
@@ -797,6 +832,7 @@ class GatewaySession(
val connectScopes = resolveConnectScopes(selectedAuth)
val signedAtMs = System.currentTimeMillis()
// V3 signatures bind the auth token, nonce, role, and scopes so replayed connect frames fail.
val payload =
DeviceAuthPayload.buildV3(
deviceId = identity.deviceId,
@@ -966,6 +1002,7 @@ class GatewaySession(
if (parsedPayload != null) {
put("payload", parsedPayload)
} else if (result.payloadJson != null) {
// Preserve malformed/non-object payloads as payloadJSON so the gateway can report handler output.
put("payloadJSON", JsonPrimitive(result.payloadJson))
}
result.error?.let { err ->
@@ -1189,6 +1226,7 @@ class GatewaySession(
if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false
val detailCode = error.details?.code
val recommendedNextStep = error.details?.recommendedNextStep
// New gateways set canRetryWithDeviceToken; older builds expose equivalent string codes.
return error.details?.canRetryWithDeviceToken == true ||
recommendedNextStep == "retry_with_device_token" ||
detailCode == "AUTH_TOKEN_MISMATCH"
@@ -1213,10 +1251,13 @@ class GatewaySession(
tls: GatewayTlsParams?,
): Boolean {
if (isLocalCleartextGatewayHost(endpoint.host)) return true
// Retrying a stored device token alongside a shared token is only safe for
// remote gateways when an existing TLS pin already identifies the endpoint.
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
}
}
/** Decides whether auth failures should stop reconnect churn until the user changes credentials. */
internal fun shouldPauseGatewayReconnectAfterAuthFailure(
error: GatewaySession.ErrorShape,
hasBootstrapToken: Boolean,
@@ -1249,6 +1290,7 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
else -> false
}
/** Builds the gateway WebSocket URL from endpoint authority and TLS policy. */
internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,
@@ -1258,6 +1300,7 @@ internal fun buildGatewayWebSocketUrl(
return "$scheme://${formatGatewayAuthority(host, port)}"
}
/** Formats host/port for gateway URLs, including IPv6 bracket wrapping. */
internal fun formatGatewayAuthority(
host: String,
port: Int,
@@ -1308,6 +1351,7 @@ private fun parseJsonOrNull(payload: String): JsonElement? {
}
}
/** Keeps invoke-result ack waits inside the gateway-supported timeout window. */
internal fun resolveInvokeResultAckTimeoutMs(invokeTimeoutMs: Long?): Long {
val normalized = invokeTimeoutMs?.takeIf { it > 0L } ?: 15_000L
return normalized.coerceIn(15_000L, 120_000L)

View File

@@ -25,6 +25,7 @@ import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/** TLS pinning inputs for a discovered or manually configured gateway endpoint. */
data class GatewayTlsParams(
val required: Boolean,
val expectedFingerprint: String?,
@@ -32,22 +33,26 @@ data class GatewayTlsParams(
val stableId: String,
)
/** SSL primitives installed into OkHttp when a gateway needs TLS pinning/TOFU. */
data class GatewayTlsConfig(
val sslSocketFactory: SSLSocketFactory,
val trustManager: X509TrustManager,
val hostnameVerifier: HostnameVerifier,
)
/** Distinguishes non-TLS endpoints from unreachable endpoints during probing. */
enum class GatewayTlsProbeFailure {
TLS_UNAVAILABLE,
ENDPOINT_UNREACHABLE,
}
/** Result of probing a gateway TLS endpoint for first-use fingerprint capture. */
data class GatewayTlsProbeResult(
val fingerprintSha256: String? = null,
val failure: GatewayTlsProbeFailure? = null,
)
/** Builds a TLS config that supports pinned fingerprints and trust-on-first-use. */
fun buildGatewayTlsConfig(
params: GatewayTlsParams?,
onStore: ((String) -> Unit)? = null,
@@ -82,6 +87,9 @@ fun buildGatewayTlsConfig(
return
}
if (params.allowTOFU) {
// Store only after the TLS stack presents a concrete server cert; the
// caller persists the fingerprint against the endpoint's stable id,
// and later connects must come back through the pinned branch above.
onStore?.invoke(fingerprint)
return
}
@@ -107,6 +115,7 @@ fun buildGatewayTlsConfig(
)
}
/** Connects with a probe trust manager that captures the presented cert hash. */
suspend fun probeGatewayTlsFingerprint(
host: String,
port: Int,
@@ -132,6 +141,7 @@ suspend fun probeGatewayTlsFingerprint(
) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
fingerprintRef.set(sha256Hex(chain[0].encoded))
// Abort validation after capture; the probe is not deciding trust.
throw CertificateException("gateway TLS probe captured fingerprint")
}
@@ -154,7 +164,8 @@ suspend fun probeGatewayTlsFingerprint(
socket.sslParameters = params
}
} catch (_: Throwable) {
// ignore
// SNI is only a probe hint. IP literals and odd Bonjour names should
// still be probed instead of failing before the TLS handshake.
}
socket.startHandshake()
@@ -203,6 +214,7 @@ private fun sha256Hex(data: ByteArray): String {
return out.toString()
}
/** Normalizes user-visible fingerprint text to lowercase bare SHA-256 hex. */
fun normalizeGatewayTlsFingerprint(raw: String): String {
val stripped =
raw

View File

@@ -5,10 +5,15 @@ data class ParsedInvokeError(
val message: String,
val hadExplicitCode: Boolean,
) {
/** Gateway-facing form expected by UI and retry copy. */
val prefixedMessage: String
get() = "$code: $message"
}
/**
* Parses gateway invoke errors encoded as CODE: message while preserving legacy
* plain-text errors as UNAVAILABLE.
*/
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
val trimmed = raw.trim()
if (trimmed.isEmpty()) {
@@ -30,6 +35,7 @@ fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
}
/** Extracts an invoke error from a throwable without exposing blank messages. */
fun parseInvokeErrorFromThrowable(
err: Throwable,
fallbackMessage: String = "error",

View File

@@ -6,6 +6,9 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Android bridge for applying gateway A2UI messages to the canvas WebView.
*/
class A2UIHandler(
private val canvas: CanvasController,
private val json: Json,
@@ -21,6 +24,7 @@ class A2UIHandler(
fun resolveA2uiHostUrl(): String? {
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty()
// Prefer node-advertised canvas host; operator URL is a fallback for older hello payloads.
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
@@ -36,6 +40,7 @@ class A2UIHandler(
}
canvas.navigate(a2uiUrl)
// A2UI host bootstraps asynchronously after navigation; poll briefly before failing the command.
repeat(50) {
try {
val ready = canvas.eval(a2uiReadyCheckJS)
@@ -65,6 +70,7 @@ class A2UIHandler(
if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) {
val jsonl = jsonlField
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
// JSONL keeps large A2UI streams model-friendly while still validating each message.
val messages =
jsonl
.lineSequence()
@@ -98,6 +104,7 @@ class A2UIHandler(
lineNumber: Int,
) {
if (msg.containsKey("createSurface")) {
// Android scaffold currently implements A2UI v0.8, not the v0.9 createSurface shape.
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
)

View File

@@ -20,12 +20,18 @@ import java.util.TimeZone
private const val DEFAULT_CALENDAR_LIMIT = 50
/**
* Parsed calendar.events request; times are epoch millis for CalendarContract queries.
*/
internal data class CalendarEventsRequest(
val startMs: Long,
val endMs: Long,
val limit: Int,
)
/**
* Parsed calendar.add request before resolving the target Android calendar.
*/
internal data class CalendarAddRequest(
val title: String,
val startMs: Long,
@@ -37,6 +43,9 @@ internal data class CalendarAddRequest(
val calendarTitle: String?,
)
/**
* Normalized calendar event returned through gateway calendar commands.
*/
internal data class CalendarEventRecord(
val identifier: String,
val title: String,
@@ -47,6 +56,9 @@ internal data class CalendarEventRecord(
val calendarTitle: String?,
)
/**
* Injectable CalendarProvider facade for command tests and Android runtime access.
*/
internal interface CalendarDataSource {
fun hasReadPermission(context: Context): Boolean
@@ -78,6 +90,7 @@ private object SystemCalendarDataSource : CalendarDataSource {
): List<CalendarEventRecord> {
val resolver = context.contentResolver
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
// Instances expands recurring events inside the requested time window.
ContentUris.appendId(builder, request.startMs)
ContentUris.appendId(builder, request.endMs)
val projection =
@@ -155,10 +168,12 @@ private object SystemCalendarDataSource : CalendarDataSource {
calendarTitle: String?,
): Long {
if (calendarId != null) {
// Explicit id wins over title/default selection and must already exist.
if (calendarExists(resolver, calendarId)) return calendarId
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
}
if (!calendarTitle.isNullOrEmpty()) {
// Title lookup is exact to avoid adding events to a similarly named calendar.
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
}
@@ -209,6 +224,7 @@ private object SystemCalendarDataSource : CalendarDataSource {
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
// Prefer Android's primary visible calendar, then lowest id for deterministic fallback.
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
@@ -342,6 +358,7 @@ class CalendarHandler private constructor(
if (paramsJson.isNullOrBlank()) {
val start = Instant.now()
val end = start.plus(7, ChronoUnit.DAYS)
// Default calendar read is a one-week window, not the full calendar store.
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
}
val params =
@@ -354,6 +371,7 @@ class CalendarHandler private constructor(
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
val resolvedStart = start ?: Instant.now()
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
// Keep model-driven calendar reads bounded.
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
return CalendarEventsRequest(
startMs = resolvedStart.toEpochMilli(),
@@ -390,6 +408,7 @@ class CalendarHandler private constructor(
private fun parseISO(raw: String?): Instant? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
// Gateway calendar payloads use UTC ISO-8601 instants for unambiguous Android storage.
return try {
Instant.parse(value)
} catch (_: Throwable) {

View File

@@ -41,19 +41,25 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt
/**
* CameraX-backed capture service used by gateway camera commands.
*/
class CameraCaptureManager(
private val context: Context,
) {
/** Base64 JSON response for camera.snap after resize and JPEG budget enforcement. */
data class Payload(
val payloadJson: String,
)
/** Temporary MP4 response for camera.clip before CameraHandler validates invoke size. */
data class FilePayload(
val file: File,
val durationMs: Long,
val hasAudio: Boolean,
)
/** Camera device metadata exposed through camera.list. */
data class CameraDeviceInfo(
val id: String,
val name: String,
@@ -65,14 +71,19 @@ class CameraCaptureManager(
@Volatile private var permissionRequester: PermissionRequester? = null
/** Supplies the foreground Activity lifecycle required by CameraX use-case binding. */
fun attachLifecycleOwner(owner: LifecycleOwner) {
// CameraX binds use cases to an Activity lifecycle; background services cannot capture alone.
lifecycleOwner = owner
}
/** Supplies the Activity-owned permission launcher used by camera and microphone commands. */
fun attachPermissionRequester(requester: PermissionRequester) {
// Permission prompts must be launched by the Activity that owns the ActivityResult registry.
permissionRequester = requester
}
/** Lists CameraX devices with stable Camera2 ids where available. */
suspend fun listDevices(): List<CameraDeviceInfo> =
withContext(Dispatchers.Main) {
val provider = context.cameraProvider()
@@ -107,6 +118,7 @@ class CameraCaptureManager(
}
}
/** Captures one still image and returns a gateway-sized JPEG payload. */
suspend fun snap(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
@@ -122,6 +134,7 @@ class CameraCaptureManager(
val selector = resolveCameraSelector(provider, facing, deviceId)
provider.unbindAll()
// Bind only the still capture use case; CameraX owns camera open/close through the lifecycle owner.
provider.bindToLifecycle(owner, selector, capture)
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
@@ -179,6 +192,7 @@ class CameraCaptureManager(
}
}
/** Records a short MP4 clip into a temporary cache file for the caller to encode/delete. */
@SuppressLint("MissingPermission")
suspend fun clip(paramsJson: String?): FilePayload =
withContext(Dispatchers.Main) {
@@ -303,6 +317,7 @@ class CameraCaptureManager(
orientation: Int,
): Bitmap {
val matrix = Matrix()
// CameraX JPEG bytes keep sensor orientation in EXIF; normalize before resizing/encoding.
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
@@ -365,6 +380,7 @@ class CameraCaptureManager(
}
return CameraSelector
.Builder()
// CameraX selectors are filters over CameraInfo; pin by Camera2 id for stable device selection.
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
.build()
}
@@ -419,7 +435,9 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
)
}
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
/**
* Returns JPEG bytes plus EXIF orientation so callers can normalize the decoded bitmap.
*/
private suspend fun ImageCapture.takeJpegWithExif(
executor: Executor,
tempDir: File,

View File

@@ -16,8 +16,14 @@ import kotlinx.serialization.json.put
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
/**
* Raw MP4 size guard before base64 encoding the clip into a node.invoke response.
*/
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
/**
* Gateway camera command adapter that adds HUD feedback and payload-size enforcement.
*/
class CameraHandler(
private val appContext: Context,
private val camera: CameraCaptureManager,
@@ -26,6 +32,7 @@ class CameraHandler(
private val triggerCameraFlash: () -> Unit,
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
) {
/** Handles camera.list by exposing CameraX devices through gateway metadata. */
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult =
try {
val devices = camera.listDevices()
@@ -53,6 +60,7 @@ class CameraHandler(
GatewaySession.InvokeResult.error(code = code, message = message)
}
/** Handles camera.snap with HUD progress, flash feedback, and normalized invoke errors. */
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
@@ -92,6 +100,7 @@ class CameraHandler(
}
}
/** Handles camera.clip and keeps external audio capture paused while camera audio is active. */
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
@@ -124,6 +133,7 @@ class CameraHandler(
val rawBytes = filePayload.file.length()
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
// Delete oversized clips before returning so cache files do not accumulate after failed invokes.
withContext(Dispatchers.IO) { filePayload.file.delete() }
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
return GatewaySession.InvokeResult.error(
@@ -152,6 +162,7 @@ class CameraHandler(
clipLog("stack: ${err.stackTraceToString().take(2000)}")
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
} finally {
// Prevent talk/transcription capture from competing with camera audio after every exit path.
if (includeAudio) externalAudioCaptureActive.value = false
}
}

View File

@@ -2,9 +2,14 @@ package ai.openclaw.app.node
import java.net.URI
/**
* Trust helper for WebView-originated canvas/A2UI actions.
*/
object CanvasActionTrust {
/** Local canvas scaffold is the only trusted file URL. */
const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html"
/** Accepts local scaffold or exact remote A2UI URLs advertised by the gateway. */
fun isTrustedCanvasActionUrl(
rawUrl: String?,
trustedA2uiUrls: List<String>,
@@ -28,11 +33,14 @@ object CanvasActionTrust {
candidateUri: URI,
trustedUrl: String,
): Boolean {
// Gateway-advertised URLs are capabilities. Treat malformed entries as
// absent instead of broadening trust to same-origin or prefix matches.
val trustedUri = parseUri(trustedUrl) ?: return false
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
return candidateUri == normalizedTrusted
}
/** Normalizes only the URL parts allowed to vary across trusted remote A2UI URLs. */
private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? {
// Keep Android trust normalization aligned with iOS ScreenController:
// exact remote URL match, scheme/host normalized, fragment ignored.
@@ -52,6 +60,7 @@ object CanvasActionTrust {
}
}
/** Parses untrusted WebView/gateway URL text without throwing into UI event handlers. */
private fun parseUri(raw: String): URI? =
try {
URI(raw)

View File

@@ -23,6 +23,9 @@ import org.json.JSONObject
import java.io.ByteArrayOutputStream
import kotlin.coroutines.resume
/**
* Owns the Android WebView canvas surface used by canvas and A2UI commands.
*/
class CanvasController {
enum class SnapshotFormat(
val rawValue: String,
@@ -60,19 +63,23 @@ class CanvasController {
return scale(maxWidth, scaledHeight)
}
/** Attaches the active WebView and replays state that may have arrived before the view existed. */
fun attach(webView: WebView) {
this.webView = webView
// Replay persisted state because WebView attachment can happen after gateway events arrive.
reload()
applyDebugStatus()
applyHomeCanvasState()
}
/** Detaches only the currently attached WebView instance. */
fun detach(webView: WebView) {
if (this.webView === webView) {
this.webView = null
}
}
/** Navigates the canvas to a remote URL or back to the bundled scaffold for blank/root input. */
fun navigate(url: String) {
val trimmed = url.trim()
this.url = if (trimmed.isBlank() || trimmed == "/") null else trimmed
@@ -113,6 +120,7 @@ class CanvasController {
if (Looper.myLooper() == Looper.getMainLooper()) {
block(wv)
} else {
// WebView APIs must run on the main thread.
wv.post { block(wv) }
}
}
@@ -178,6 +186,7 @@ class CanvasController {
}
}
/** Evaluates JavaScript against the attached WebView on the main thread. */
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
@@ -206,6 +215,7 @@ class CanvasController {
}
}
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
suspend fun snapshotBase64(
format: SnapshotFormat,
quality: Double?,
@@ -246,17 +256,22 @@ class CanvasController {
}
companion object {
/**
* Parsed canvas.snapshot options used by invoke dispatch.
*/
data class SnapshotParams(
val format: SnapshotFormat,
val quality: Double?,
val maxWidth: Int?,
)
/** Parses canvas.navigate params and returns blank when the payload is missing or invalid. */
fun parseNavigateUrl(paramsJson: String?): String {
val obj = parseParamsObject(paramsJson) ?: return ""
return obj.string("url").trim()
}
/** Parses non-blank JavaScript from canvas.eval params. */
fun parseEvalJs(paramsJson: String?): String? {
val obj = parseParamsObject(paramsJson) ?: return null
val js = obj.string("javaScript").trim()
@@ -286,9 +301,11 @@ class CanvasController {
if (!obj.containsKey("quality")) return null
val q = obj.double("quality") ?: Double.NaN
if (!q.isFinite()) return null
// Keep JPEG quality inside encoder-safe bounds; PNG ignores it.
return q.coerceIn(0.1, 1.0)
}
/** Parses canvas.snapshot params using JPEG defaults and encoder-safe bounds. */
fun parseSnapshotParams(paramsJson: String?): SnapshotParams =
SnapshotParams(
format = parseSnapshotFormat(paramsJson),

View File

@@ -12,6 +12,9 @@ import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
import ai.openclaw.app.gateway.isLoopbackGatewayHost
import android.os.Build
/**
* Builds gateway connect metadata from current Android permissions, settings, and device identity.
*/
class ConnectionManager(
private val prefs: SecurePrefs,
private val cameraEnabled: () -> Boolean,
@@ -28,6 +31,9 @@ class ConnectionManager(
private val manualTls: () -> Boolean,
) {
companion object {
/**
* Decide whether a discovered/manual endpoint must use pinned TLS or can stay local cleartext.
*/
internal fun resolveTlsParamsForEndpoint(
endpoint: GatewayEndpoint,
storedFingerprint: String?,
@@ -44,6 +50,7 @@ class ConnectionManager(
}
if (isManual) {
// Manual remote hosts default to TLS; only local manual hosts may honor the cleartext toggle.
if (!manualTlsEnabled && cleartextAllowedHost) return null
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
@@ -83,6 +90,7 @@ class ConnectionManager(
}
if (!cleartextAllowedHost) {
// Non-loopback discovered hosts require TLS even without TXT hints.
return GatewayTlsParams(
required = true,
expectedFingerprint = null,
@@ -110,10 +118,15 @@ class ConnectionManager(
debugBuild = BuildConfig.DEBUG,
)
/** Builds the gateway-advertised node.invoke command list from current permission and feature state. */
fun buildInvokeCommands(): List<String> = InvokeCommandRegistry.advertisedCommands(runtimeFlags())
/** Builds the gateway-advertised capability list from current permission and feature state. */
fun buildCapabilities(): List<String> = InvokeCommandRegistry.advertisedCapabilities(runtimeFlags())
/**
* Debug Android builds advertise a dev version so gateway logs do not look like release clients.
*/
fun resolvedVersionName(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
@@ -123,12 +136,16 @@ class ConnectionManager(
}
}
/** Human-readable Android device model used in gateway client metadata. */
fun resolveModelIdentifier(): String? =
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
/**
* User-Agent used for gateway telemetry and troubleshooting.
*/
fun buildUserAgent(): String {
val version = resolvedVersionName()
val release =
@@ -139,6 +156,7 @@ class ConnectionManager(
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
}
/** Client identity block shared by node and operator gateway sessions. */
fun buildClientInfo(
clientId: String,
clientMode: String,
@@ -154,6 +172,7 @@ class ConnectionManager(
modelIdentifier = resolveModelIdentifier(),
)
/** Connect options for the Android node session that exposes phone capabilities. */
fun buildNodeConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "node",
@@ -165,6 +184,7 @@ class ConnectionManager(
userAgent = buildUserAgent(),
)
/** Connect options for the Android operator session that drives approvals and UI actions. */
fun buildOperatorConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "operator",
@@ -181,6 +201,7 @@ class ConnectionManager(
userAgent = buildUserAgent(),
)
/** Resolves persisted TLS pin policy for a concrete gateway endpoint. */
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())

View File

@@ -17,6 +17,9 @@ import kotlinx.serialization.json.put
private const val DEFAULT_CONTACTS_LIMIT = 25
/**
* Normalized Android contact row returned through the contacts commands.
*/
internal data class ContactRecord(
val identifier: String,
val displayName: String,
@@ -27,11 +30,17 @@ internal data class ContactRecord(
val emails: List<String>,
)
/**
* Parsed contacts.search request with bounded result count.
*/
internal data class ContactsSearchRequest(
val query: String?,
val limit: Int,
)
/**
* Parsed contacts.add request before ContentProviderOperation batching.
*/
internal data class ContactsAddRequest(
val givenName: String?,
val familyName: String?,
@@ -41,6 +50,9 @@ internal data class ContactsAddRequest(
val emails: List<String>,
)
/**
* Injectable ContactsProvider facade for command tests and Android runtime access.
*/
internal interface ContactsDataSource {
fun hasReadPermission(context: Context): Boolean
@@ -82,6 +94,7 @@ private object SystemContactsDataSource : ContactsDataSource {
selection = null
selectionArgs = null
} else {
// Escape wildcard characters so user text remains a substring search, not a LIKE pattern.
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\\'"
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
}
@@ -119,6 +132,7 @@ private object SystemContactsDataSource : ContactsDataSource {
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build()
// Subsequent Data rows use back-reference 0 to attach to the RawContact inserted above.
if (!request.givenName.isNullOrEmpty() || !request.familyName.isNullOrEmpty() || !request.displayName.isNullOrEmpty()) {
operations +=
ContentProviderOperation
@@ -168,6 +182,7 @@ private object SystemContactsDataSource : ContactsDataSource {
rawContactUri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("contact insert failed")
val contactId =
// Android returns the RawContact id; resolve the aggregate Contact id used by search APIs.
resolveContactIdForRawContact(resolver, rawContactId)
?: throw IllegalStateException("contact insert failed")
return loadContactRecord(
@@ -330,12 +345,16 @@ private object SystemContactsDataSource : ContactsDataSource {
}
}
/**
* Handles contacts.search and contacts.add gateway commands through Android ContactsProvider.
*/
class ContactsHandler private constructor(
private val appContext: Context,
private val dataSource: ContactsDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemContactsDataSource)
/** Searches contacts by optional display-name substring with bounded result count. */
fun handleContactsSearch(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -369,6 +388,7 @@ class ContactsHandler private constructor(
}
}
/** Adds a local contact after validating that at least one user-visible field is present. */
fun handleContactsAdd(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasWritePermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -418,6 +438,7 @@ class ContactsHandler private constructor(
null
} ?: return null
val query = (params["query"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null }
// Keep gateway-driven searches bounded even if the model asks for a large contact dump.
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CONTACTS_LIMIT).coerceIn(1, 200)
return ContactsSearchRequest(query = query, limit = limit)
}
@@ -435,6 +456,7 @@ class ContactsHandler private constructor(
organizationName = (params["organizationName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
displayName = (params["displayName"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
phoneNumbers = stringArray(params["phoneNumbers"] as? JsonArray),
// Store emails case-normalized so repeated model calls do not create casing-only duplicates.
emails = stringArray(params["emails"] as? JsonArray).map { it.lowercase() },
)
}
@@ -458,6 +480,7 @@ class ContactsHandler private constructor(
}
companion object {
/** Creates a handler with an injected contacts source for parser and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: ContactsDataSource,

View File

@@ -8,15 +8,21 @@ import kotlinx.serialization.json.JsonPrimitive
private const val LOGCAT_PATH = "/system/bin/logcat"
/**
* Debug-only node.invoke commands for Android cryptography and log diagnostics.
*/
class DebugHandler(
private val appContext: Context,
private val identityStore: DeviceIdentityStore,
) {
/**
* Runs an Ed25519 self-test and returns redacted diagnostics for debug builds.
*/
fun handleEd25519(): GatewaySession.InvokeResult {
if (!BuildConfig.DEBUG) {
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
}
// Self-test Ed25519 signing and return diagnostic info
// Self-test Ed25519 signing without returning full private/public key material.
try {
val identity = identityStore.loadOrCreate()
val testPayload = "test|${identity.deviceId}|${System.currentTimeMillis()}"
@@ -25,15 +31,14 @@ class DebugHandler(
results.add("publicKeyRawBase64: ${identity.publicKeyRawBase64.take(20)}...")
results.add("privateKeyPkcs8Base64: ${identity.privateKeyPkcs8Base64.take(20)}...")
// Test publicKeyBase64Url
// Public-key URL encoding must match the gateway device-auth payload contract.
val pubKeyUrl = identityStore.publicKeyBase64Url(identity)
results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}")
// Test signing
// Sign/verify through DeviceIdentityStore to catch provider and key-format failures together.
val signature = identityStore.signPayload(testPayload, identity)
results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}")
// Test self-verify
if (signature != null) {
val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity)
results.add("verifySelfSignature: $verifyOk")
@@ -74,6 +79,9 @@ class DebugHandler(
}
}
/**
* Returns a filtered logcat snapshot plus CameraX debug log for debug builds.
*/
fun handleLogs(): GatewaySession.InvokeResult {
if (!BuildConfig.DEBUG) {
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
@@ -81,7 +89,7 @@ class DebugHandler(
val pid = android.os.Process.myPid()
val rt = Runtime.getRuntime()
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory() / 1024}K total=${rt.totalMemory() / 1024}K max=${rt.maxMemory() / 1024}K uptime=${android.os.SystemClock.elapsedRealtime() / 1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
// Run logcat on current dispatcher thread (no withContext) with file redirect
// Capture only this process and redirect through a temp file to avoid blocking on pipe backpressure.
val logResult =
try {
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
@@ -123,6 +131,7 @@ class DebugHandler(
if (line.isBlank()) continue
if (spamPatterns.any { line.contains(it) }) continue
if (sb.length + line.length > 16000) {
// Keep debug.invoke responses small enough for the gateway WebSocket frame budget.
sb.append("\n(truncated)")
break
}
@@ -133,7 +142,7 @@ class DebugHandler(
} catch (e: Throwable) {
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
}
// Also include camera debug log if it exists
// Camera capture writes a separate debug file because CameraX failures often happen off logcat's hot path.
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
val camLog =
if (camLogFile.exists() && camLogFile.length() > 0) {

View File

@@ -24,6 +24,9 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.Locale
/**
* Gateway device command adapter for Android status, info, permission, and health snapshots.
*/
class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
@@ -31,6 +34,9 @@ class DeviceHandler(
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
) {
companion object {
/**
* SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align.
*/
internal fun hasAnySmsCapability(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
@@ -38,6 +44,9 @@ class DeviceHandler(
smsReadGranted: Boolean,
): Boolean = smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
/**
* Prompt only when Android can grant a missing SMS permission that this build can use.
*/
internal fun isSmsPromptable(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
@@ -53,12 +62,16 @@ class DeviceHandler(
val temperatureC: Double?,
)
/** Returns battery, storage, network, and uptime state for device.status. */
fun handleDeviceStatus(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(statusPayloadJson())
/** Returns stable Android hardware, OS, app, and locale metadata for device.info. */
fun handleDeviceInfo(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(infoPayloadJson())
/** Returns permission and promptability state for Android capabilities exposed to the gateway. */
fun handleDevicePermissions(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(permissionsPayloadJson())
/** Returns coarse device health for memory, power, thermal, battery, and security patch state. */
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
private fun statusPayloadJson(): String {
@@ -71,6 +84,7 @@ class DeviceHandler(
val connectivity = appContext.getSystemService(ConnectivityManager::class.java)
val activeNetwork = connectivity?.activeNetwork
val caps = activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
// elapsedRealtime is monotonic device uptime, not wall-clock time.
val uptimeSeconds = SystemClock.elapsedRealtime() / 1_000.0
return buildJsonObject {
@@ -154,6 +168,7 @@ class DeviceHandler(
if (!photosEnabled) {
false
} else if (Build.VERSION.SDK_INT >= 33) {
// Android 13 split media permissions; earlier versions use external storage.
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
} else {
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
@@ -161,6 +176,7 @@ class DeviceHandler(
val motionGranted = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)
val notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
// POST_NOTIFICATIONS exists only on Android 13+.
hasPermission(Manifest.permission.POST_NOTIFICATIONS)
} else {
true
@@ -295,6 +311,7 @@ class DeviceHandler(
if (currentNowUa == null || currentNowUa == Long.MIN_VALUE) {
null
} else {
// BatteryManager reports microamps; expose milliamps in the gateway payload.
currentNowUa.toDouble() / 1_000.0
}
@@ -349,6 +366,7 @@ class DeviceHandler(
}
private fun readBatterySnapshot(): BatterySnapshot {
// ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot.
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val status =
intent?.getIntExtra(BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN)
@@ -410,6 +428,7 @@ class DeviceHandler(
if (caps == null) return "unsatisfied"
return when {
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> "satisfied"
// Internet without validation mirrors iOS "requiresConnection" for captive or unproven networks.
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "requiresConnection"
else -> "unsatisfied"
}
@@ -436,6 +455,7 @@ class DeviceHandler(
if (totalBytes <= 0L) return if (lowMemory) "critical" else "unknown"
if (lowMemory) return "critical"
val freeRatio = availableBytes.toDouble() / totalBytes.toDouble()
// Thresholds intentionally mirror coarse OS health labels instead of exact memory pressure.
return when {
freeRatio <= 0.05 -> "critical"
freeRatio <= 0.15 -> "high"

View File

@@ -21,11 +21,18 @@ import kotlinx.serialization.json.put
private const val MAX_NOTIFICATION_TEXT_CHARS = 512
private const val NOTIFICATIONS_CHANGED_EVENT = "notifications.changed"
/**
* Trims notification text and caps payload size before it enters gateway-visible state.
*/
internal fun sanitizeNotificationText(value: CharSequence?): String? {
val normalized = value?.toString()?.trim().orEmpty()
// Notification extras can include long previews; cap before sending over node events.
return normalized.take(MAX_NOTIFICATION_TEXT_CHARS).ifEmpty { null }
}
/**
* Stable notification snapshot entry exposed through the Android notifications command.
*/
data class DeviceNotificationEntry(
val key: String,
val packageName: String,
@@ -53,24 +60,36 @@ internal fun DeviceNotificationEntry.toJsonObject(): JsonObject =
channelId?.let { put("channelId", JsonPrimitive(it)) }
}
/**
* Listener state exposed to the gateway, including whether Android has connected the service.
*/
data class DeviceNotificationSnapshot(
val enabled: Boolean,
val connected: Boolean,
val notifications: List<DeviceNotificationEntry>,
)
/**
* Gateway-supported notification actions mapped to Android listener operations.
*/
enum class NotificationActionKind {
Open,
Dismiss,
Reply,
}
/**
* Gateway action request; [key] must match Android's StatusBarNotification key.
*/
data class NotificationActionRequest(
val key: String,
val kind: NotificationActionKind,
val replyText: String? = null,
)
/**
* Normalized notification action result returned through node.invoke.
*/
data class NotificationActionResult(
val ok: Boolean,
val code: String? = null,
@@ -79,6 +98,9 @@ data class NotificationActionResult(
internal fun actionRequiresClearableNotification(kind: NotificationActionKind): Boolean = kind == NotificationActionKind.Dismiss
/**
* Process-local cache of active notifications mirrored from Android listener callbacks.
*/
private object DeviceNotificationStore {
private val lock = Any()
private var connected = false
@@ -109,6 +131,7 @@ private object DeviceNotificationStore {
synchronized(lock) {
connected = value
if (!value) {
// Android invalidates activeNotifications when the listener disconnects.
byKey.clear()
}
}
@@ -127,6 +150,9 @@ private object DeviceNotificationStore {
}
}
/**
* Android notification listener that mirrors notification state and executes gateway actions.
*/
class DeviceNotificationListenerService : NotificationListenerService() {
private val securePrefs by lazy { SecurePrefs(applicationContext) }
private val forwardingLimiter = NotificationBurstLimiter()
@@ -226,6 +252,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
if (policy.isWithinQuietHours(nowEpochMs = nowEpochMs)) {
return null
}
// Apply burst limits after package/quiet-hour filters so blocked notifications do not consume quota.
if (!forwardingLimiter.allow(nowEpochMs, policy.maxEventsPerMinute)) {
return null
}
@@ -288,6 +315,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
private fun serviceComponent(context: Context): ComponentName = ComponentName(context, DeviceNotificationListenerService::class.java)
/** Installs the node event sink used to emit filtered notification change events. */
fun setNodeEventSink(sink: ((event: String, payloadJson: String?) -> Unit)?) {
nodeEventSink = sink
}
@@ -299,6 +327,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
val hasNew = prefs.contains(recentPackagesPref)
val legacy = prefs.getString(legacyRecentPackagesPref, null)?.trim().orEmpty()
if (!hasNew && legacy.isNotEmpty()) {
// Keep recent package suggestions across the preference-key rename.
prefs.edit {
putString(recentPackagesPref, legacy)
remove(legacyRecentPackagesPref)
@@ -308,6 +337,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
}
}
/** Returns recent third-party packages seen by the listener for settings suggestions. */
fun recentPackages(context: Context): List<String> {
migrateLegacyRecentPackagesIfNeeded(context)
val prefs = recentPackagesPrefs(context)
@@ -319,22 +349,26 @@ class DeviceNotificationListenerService : NotificationListenerService() {
.distinct()
}
/** Checks whether Android has granted listener access to this service component. */
fun isAccessEnabled(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
return manager.isNotificationListenerAccessGranted(serviceComponent(context))
}
/** Reads the current mirrored notification snapshot without forcing service startup. */
fun snapshot(
context: Context,
enabled: Boolean = isAccessEnabled(context),
): DeviceNotificationSnapshot = DeviceNotificationStore.snapshot(enabled = enabled)
/** Asks Android to rebind the listener after settings grant access but callbacks have not arrived. */
fun requestServiceRebind(context: Context) {
runCatching {
NotificationListenerService.requestRebind(serviceComponent(context))
}
}
/** Executes an open, dismiss, or reply action through the active listener instance. */
fun executeAction(
context: Context,
request: NotificationActionRequest,
@@ -376,6 +410,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
.map { it.trim() }
.filter { it.isNotEmpty() && it != normalized }
.take(recentPackagesLimit - 1)
// Most recent package first keeps settings suggestions useful without storing notification content.
val updated = listOf(normalized) + existing
prefs.edit { putString(recentPackagesPref, updated.joinToString(",")) }
}
@@ -449,6 +484,7 @@ class DeviceNotificationListenerService : NotificationListenerService() {
val action =
sbn.notification.actions
?.firstOrNull { candidate ->
// Android reply actions are identified by RemoteInput, not by a stable action title.
candidate.actionIntent != null && !candidate.remoteInputs.isNullOrEmpty()
}
?: return NotificationActionResult(

View File

@@ -9,6 +9,9 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
/**
* Handles gateway-originated events that need to update local Android preferences.
*/
class GatewayEventHandler(
private val scope: CoroutineScope,
private val prefs: SecurePrefs,
@@ -19,12 +22,14 @@ class GatewayEventHandler(
private var suppressWakeWordsSync = false
private var wakeWordsSyncJob: Job? = null
/** Applies gateway wake words locally without echoing the same change back to the gateway. */
fun applyWakeWordsFromGateway(words: List<String>) {
suppressWakeWordsSync = true
prefs.setWakeWords(words)
suppressWakeWordsSync = false
}
/** Debounces local wake-word edits before sending voicewake.set to the operator session. */
fun scheduleWakeWordsSyncIfNeeded() {
if (suppressWakeWordsSync) return
if (!isConnected()) return
@@ -44,6 +49,7 @@ class GatewayEventHandler(
}
}
/** Loads gateway wake words on connect so Android settings show server truth. */
suspend fun refreshWakeWordsFromGateway() {
if (!isConnected()) return
try {
@@ -57,6 +63,7 @@ class GatewayEventHandler(
}
}
/** Applies voicewake.changed event payloads emitted by the gateway. */
fun handleVoiceWakeChangedEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
try {

View File

@@ -16,6 +16,7 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
import ai.openclaw.app.protocol.OpenClawTalkCommand
/** Runtime feature flags used to decide which node tools are advertised. */
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
@@ -30,6 +31,7 @@ data class NodeRuntimeFlags(
val debugBuild: Boolean,
)
/** Per-command availability gates checked before advertising invoke methods. */
enum class InvokeCommandAvailability {
Always,
CameraEnabled,
@@ -44,6 +46,7 @@ enum class InvokeCommandAvailability {
DebugBuild,
}
/** Per-capability availability gates for the node capabilities manifest. */
enum class NodeCapabilityAvailability {
Always,
CameraEnabled,
@@ -55,11 +58,13 @@ enum class NodeCapabilityAvailability {
MotionAvailable,
}
/** Capability entry reported to the gateway when its availability gate passes. */
data class NodeCapabilitySpec(
val name: String,
val availability: NodeCapabilityAvailability = NodeCapabilityAvailability.Always,
)
/** Invoke method entry advertised to gateway plus foreground routing metadata. */
data class InvokeCommandSpec(
val name: String,
val requiresForeground: Boolean = false,
@@ -67,6 +72,7 @@ data class InvokeCommandSpec(
)
object InvokeCommandRegistry {
/** Capabilities mirror gateway protocol ids and are filtered by device state. */
val capabilityManifest: List<NodeCapabilitySpec> =
listOf(
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
@@ -106,6 +112,7 @@ object InvokeCommandRegistry {
),
)
/** Complete Android node command catalog before runtime availability filtering. */
val all: List<InvokeCommandSpec> =
listOf(
InvokeCommandSpec(
@@ -240,8 +247,10 @@ object InvokeCommandRegistry {
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }
/** Finds the command metadata used by dispatch and advertised-method builders. */
fun find(command: String): InvokeCommandSpec? = byNameInternal[command]
/** Returns gateway capability ids the current Android device can actually serve. */
fun advertisedCapabilities(flags: NodeRuntimeFlags): List<String> =
capabilityManifest
.filter { spec ->
@@ -257,6 +266,7 @@ object InvokeCommandRegistry {
}
}.map { it.name }
/** Returns gateway invoke method ids available under current permissions/build flags. */
fun advertisedCommands(flags: NodeRuntimeFlags): List<String> =
all
.filter { spec ->

View File

@@ -15,12 +15,16 @@ import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
import ai.openclaw.app.protocol.OpenClawTalkCommand
/** Runtime state for SMS search, split so permission prompts are not reported as hard unavailability. */
internal enum class SmsSearchAvailabilityReason {
Available,
PermissionRequired,
Unavailable,
}
/**
* Distinguish permanent SMS search unavailability from permission-gated search.
*/
internal fun classifySmsSearchAvailability(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
@@ -53,6 +57,9 @@ internal fun smsSearchAvailabilityError(
)
}
/**
* Gateway node.invoke command router for Android-owned capabilities.
*/
class InvokeDispatcher(
private val canvas: CanvasController,
private val cameraHandler: CameraHandler,
@@ -85,6 +92,7 @@ class InvokeDispatcher(
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
) {
/** Dispatches one gateway node.invoke command after foreground and availability gates pass. */
suspend fun handleInvoke(
command: String,
paramsJson: String?,
@@ -96,6 +104,7 @@ class InvokeDispatcher(
message = "INVALID_REQUEST: unknown command",
)
if (spec.requiresForeground && !isForeground()) {
// Canvas, camera, and screen-backed commands need an active Activity/WebView surface.
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
@@ -103,6 +112,7 @@ class InvokeDispatcher(
}
availabilityError(spec.availability)?.let { return it }
// Command strings come from OpenClawProtocolConstants; the registry above owns advertised availability.
return when (command) {
// Canvas commands
OpenClawCanvasCommand.Present.rawValue -> {
@@ -239,6 +249,7 @@ class InvokeDispatcher(
)
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
if (!readyOnFirstCheck) {
// Gateway canvas host metadata can lag reconnects; refresh once before failing the command.
refreshCanvasHostUrl()
a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: a2uiUrl
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
@@ -255,6 +266,7 @@ class InvokeDispatcher(
try {
block()
} catch (_: Throwable) {
// WebView calls throw when the Activity is backgrounded between the foreground check and execution.
GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
@@ -312,6 +324,7 @@ class InvokeDispatcher(
InvokeCommandAvailability.ReadSmsAvailable,
InvokeCommandAvailability.RequestableSmsSearchAvailable,
->
// SMS search may still be advertised as promptable; runtime invoke fails only on permanent unavailability.
smsSearchAvailabilityError(
readSmsAvailable = readSmsAvailable(),
smsFeatureEnabled = smsFeatureEnabled(),
@@ -347,12 +360,19 @@ class InvokeDispatcher(
}
}
/**
* Talk-mode command adapter implemented by the voice subsystem.
*/
interface TalkHandler {
/** Starts a push-to-talk capture session and keeps it open until stop or cancel. */
suspend fun handlePttStart(paramsJson: String?): GatewaySession.InvokeResult
/** Finishes the active push-to-talk capture and submits recognized speech. */
suspend fun handlePttStop(paramsJson: String?): GatewaySession.InvokeResult
/** Aborts the active push-to-talk capture without submitting speech. */
suspend fun handlePttCancel(paramsJson: String?): GatewaySession.InvokeResult
/** Runs a bounded one-shot push-to-talk capture. */
suspend fun handlePttOnce(paramsJson: String?): GatewaySession.InvokeResult
}

View File

@@ -4,6 +4,9 @@ import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Result of a JPEG compression attempt after quality and scale reductions.
*/
internal data class JpegSizeLimiterResult(
val bytes: ByteArray,
val width: Int,
@@ -11,7 +14,11 @@ internal data class JpegSizeLimiterResult(
val quality: Int,
)
/**
* Utility that searches quality/scale combinations until a JPEG fits a byte budget.
*/
internal object JpegSizeLimiter {
/** Compresses with the caller-provided encoder, reducing quality before image dimensions. */
fun compressToLimit(
initialWidth: Int,
initialHeight: Int,

View File

@@ -14,6 +14,9 @@ import kotlinx.coroutines.withTimeout
import java.time.Instant
import java.time.format.DateTimeFormatter
/**
* Android LocationManager-backed capture used by gateway location commands.
*/
class LocationCaptureManager(
private val context: Context,
) {
@@ -35,6 +38,7 @@ class LocationCaptureManager(
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
}
// Prefer a recent cached fix before waking GPS/network providers.
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
val location =
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
@@ -81,6 +85,7 @@ class LocationCaptureManager(
val candidates =
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
val freshest = candidates.maxByOrNull { it.time } ?: return null
// maxAgeMs is a caller contract; stale cached fixes force a live provider request.
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
return freshest
}
@@ -102,6 +107,7 @@ class LocationCaptureManager(
val resolved =
providers.firstOrNull { manager.isProviderEnabled(it) }
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
// getCurrentLocation can return null; the handler maps timeout/null fixes to gateway error shapes.
val location =
withTimeout(timeoutMs.coerceAtLeast(1)) {
suspendCancellableCoroutine<Location?> { cont ->

View File

@@ -10,6 +10,9 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
/**
* Injectable location facade for command tests and Android runtime access.
*/
internal interface LocationDataSource {
fun hasFinePermission(context: Context): Boolean
@@ -69,11 +72,14 @@ class LocationHandler private constructor(
locationPreciseEnabled = locationPreciseEnabled,
)
/** Reports whether precise GPS-backed location can be requested from Android. */
fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext)
/** Reports whether network/coarse location can be requested from Android. */
fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext)
companion object {
/** Creates a handler with injected location state for permission and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: LocationDataSource,
@@ -90,8 +96,10 @@ class LocationHandler private constructor(
)
}
/** Handles location.get with foreground, permission, and user precision gates applied. */
suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult {
if (!isForeground()) {
// Android foreground restrictions and user expectation keep live location tied to the visible app.
return GatewaySession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
@@ -105,6 +113,8 @@ class LocationHandler private constructor(
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled()
// Gateway requests are advisory; Android permission and user settings decide
// whether precise capture is actually allowed for this invocation.
val accuracy =
when (desiredAccuracy) {
"precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced"
@@ -113,6 +123,7 @@ class LocationHandler private constructor(
}
val providers =
when (accuracy) {
// Provider order is part of the accuracy policy: GPS first for precise, network first otherwise.
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
@@ -151,6 +162,7 @@ class LocationHandler private constructor(
val timeoutMs =
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
?: 10_000L
// desiredAccuracy is advisory; invalid values fall through to the default policy.
val desiredAccuracy =
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)

View File

@@ -25,17 +25,20 @@ import kotlin.math.sqrt
private const val ACCELEROMETER_SAMPLE_TARGET = 20
private const val ACCELEROMETER_SAMPLE_TIMEOUT_MS = 6_000L
/** Gateway request for motion.activity after parsing and limit bounds. */
internal data class MotionActivityRequest(
val startISO: String?,
val endISO: String?,
val limit: Int,
)
/** Gateway request for motion.pedometer. */
internal data class MotionPedometerRequest(
val startISO: String?,
val endISO: String?,
)
/** Motion activity sample returned in gateway-compatible boolean flags. */
internal data class MotionActivityRecord(
val startISO: String,
val endISO: String,
@@ -48,6 +51,7 @@ internal data class MotionActivityRecord(
val isUnknown: Boolean,
)
/** Pedometer sample returned from Android's cumulative step counter. */
internal data class PedometerRecord(
val startISO: String,
val endISO: String,
@@ -57,6 +61,7 @@ internal data class PedometerRecord(
val floorsDescended: Int?,
)
/** Motion data seam for Android sensors and tests. */
internal interface MotionDataSource {
fun isActivityAvailable(context: Context): Boolean
@@ -97,6 +102,8 @@ private object SystemMotionDataSource : MotionDataSource {
request: MotionActivityRequest,
): MotionActivityRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
// Android does not expose historical activity samples here; fail with a
// stable gateway code instead of pretending the range is empty.
throw IllegalArgumentException("MOTION_RANGE_UNAVAILABLE: historical activity range not supported on Android")
}
val sensorManager =
@@ -130,6 +137,7 @@ private object SystemMotionDataSource : MotionDataSource {
request: MotionPedometerRequest,
): PedometerRecord {
if (!request.startISO.isNullOrBlank() || !request.endISO.isNullOrBlank()) {
// TYPE_STEP_COUNTER is cumulative since boot, not a historical query API.
throw IllegalArgumentException("PEDOMETER_RANGE_UNAVAILABLE: historical pedometer range not supported on Android")
}
val sensorManager =
@@ -216,6 +224,8 @@ private object SystemMotionDataSource : MotionDataSource {
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
count += 1
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
// Average gravity-adjusted magnitude across a short window so
// one noisy sensor event cannot decide the activity label.
val result =
AccelerometerSample(
samples = count,
@@ -260,12 +270,14 @@ private object SystemMotionDataSource : MotionDataSource {
}
}
/** Handles Android motion-related node.invoke commands backed by live sensors. */
class MotionHandler private constructor(
private val appContext: Context,
private val dataSource: MotionDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemMotionDataSource)
/** Classifies a short accelerometer sample into the gateway activity shape. */
suspend fun handleMotionActivity(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -313,6 +325,7 @@ class MotionHandler private constructor(
}
}
/** Returns the current boot-scoped Android step-counter reading. */
suspend fun handleMotionPedometer(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -350,8 +363,10 @@ class MotionHandler private constructor(
fun isAvailable(): Boolean = dataSource.isAvailable(appContext)
/** Returns true when live accelerometer classification can be sampled. */
fun isActivityAvailable(): Boolean = dataSource.isActivityAvailable(appContext)
/** Returns true when Android exposes a cumulative step-counter sensor. */
fun isPedometerAvailable(): Boolean = dataSource.isPedometerAvailable(appContext)
private fun parseActivityRequest(paramsJson: String?): MotionActivityRequest? {
@@ -364,6 +379,8 @@ class MotionHandler private constructor(
} catch (_: Throwable) {
null
} ?: return null
// Keep the accepted gateway parameter even though Android can only return
// one live classification sample for now.
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 200).coerceIn(1, 1000)
return MotionActivityRequest(
startISO = (params["startISO"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
@@ -389,8 +406,10 @@ class MotionHandler private constructor(
}
companion object {
/** Static capability probe used before a MotionHandler instance is needed. */
fun isMotionCapabilityAvailable(context: Context): Boolean = SystemMotionDataSource.isAvailable(context)
/** Creates a handler with an injected sensor source for parser and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: MotionDataSource,

View File

@@ -6,10 +6,16 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
internal object NodePresenceAliveBeacon {
/** Gateway event emitted by Android when background execution confirms liveness. */
const val EVENT_NAME: String = "node.presence.alive"
/** Avoids spamming presence when multiple background triggers fire together. */
const val MIN_SUCCESS_INTERVAL_MS: Long = 10 * 60 * 1000
private const val MAX_RESPONSE_JSON_CHARS: Int = 16 * 1024
/**
* Source of the liveness event, serialized as gateway-stable wire values.
*/
enum class Trigger(
val rawValue: String,
) {
@@ -21,6 +27,9 @@ internal object NodePresenceAliveBeacon {
Connect("connect"),
}
/**
* Minimal gateway response fields used to decide whether a liveness event was accepted.
*/
data class ResponsePayload(
val ok: Boolean?,
val event: String?,
@@ -30,6 +39,7 @@ internal object NodePresenceAliveBeacon {
private val json = Json { ignoreUnknownKeys = true }
/** Skips sends after a recent successful presence update. */
fun shouldSkipRecentSuccess(
nowMs: Long,
lastSuccessAtMs: Long?,
@@ -41,6 +51,7 @@ internal object NodePresenceAliveBeacon {
return elapsed >= 0 && elapsed < minIntervalMs
}
/** Human-readable Android version label included in presence payloads. */
fun androidPlatformLabel(): String {
val release =
Build.VERSION.RELEASE
@@ -50,6 +61,7 @@ internal object NodePresenceAliveBeacon {
return "Android $release (SDK ${Build.VERSION.SDK_INT})"
}
/** Builds the compact JSON payload consumed by gateway node-presence handlers. */
fun makePayloadJson(
trigger: Trigger,
sentAtMs: Long,
@@ -71,8 +83,11 @@ internal object NodePresenceAliveBeacon {
pushTransport?.trim()?.takeIf { it.isNotEmpty() }?.let { put("pushTransport", JsonPrimitive(it)) }
}.toString()
/** Parses the gateway response while rejecting empty, oversized, or malformed payloads. */
fun decodeResponse(payloadJson: String?): ResponsePayload? {
val raw = payloadJson?.trim()?.takeIf { it.isNotEmpty() } ?: return null
// Bound log/IPC responses before JSON parsing to avoid memory spikes from
// malformed gateway replies.
if (raw.length > MAX_RESPONSE_JSON_CHARS) return null
val obj =
try {
@@ -88,6 +103,7 @@ internal object NodePresenceAliveBeacon {
)
}
/** Sanitizes gateway response reasons before writing them into Android logs. */
fun sanitizeReasonForLog(raw: String?): String {
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: "unsupported"
return value

View File

@@ -8,8 +8,10 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/** Default canvas seam color used when gateway/user params omit a hex color. */
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
/** Small tuple used by Android node handlers that need four return values. */
data class Quad<A, B, C, D>(
val first: A,
val second: B,
@@ -17,6 +19,7 @@ data class Quad<A, B, C, D>(
val fourth: D,
)
/** Escapes a Kotlin string into a JSON string literal without building a JsonElement. */
fun String.toJsonString(): String {
val escaped =
this
@@ -29,6 +32,7 @@ fun String.toJsonString(): String {
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
/** Parses invoke params into a JSON object, returning null for absent/malformed input. */
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
if (paramsJson.isNullOrBlank()) return null
return try {
@@ -38,26 +42,31 @@ fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
}
}
/** Reads a primitive field from invoke params without accepting arrays/objects. */
fun readJsonPrimitive(
params: JsonObject?,
key: String,
): JsonPrimitive? = params?.get(key) as? JsonPrimitive
/** Parses an optional integer invoke param. */
fun parseJsonInt(
params: JsonObject?,
key: String,
): Int? = readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
/** Parses an optional decimal invoke param. */
fun parseJsonDouble(
params: JsonObject?,
key: String,
): Double? = readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
/** Parses an optional string invoke param. */
fun parseJsonString(
params: JsonObject?,
key: String,
): String? = readJsonPrimitive(params, key)?.contentOrNull
/** Parses strict true/false flags from string-like JSON primitives. */
fun parseJsonBooleanFlag(
params: JsonObject?,
key: String,
@@ -70,6 +79,7 @@ fun parseJsonBooleanFlag(
}
}
/** Converts JSON null to Kotlin null while preserving primitive text content. */
fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
@@ -77,6 +87,7 @@ fun JsonElement?.asStringOrNull(): String? =
else -> null
}
/** Parses #RRGGBB or RRGGBB into opaque ARGB. */
fun parseHexColorArgb(raw: String?): Long? {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return null
@@ -86,15 +97,18 @@ fun parseHexColorArgb(raw: String?): Long? {
return 0xFF000000L or rgb
}
/** Converts gateway invocation throwables into protocol code/message pairs. */
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
val parsed = parseInvokeErrorFromThrowable(err, fallbackMessage = "UNAVAILABLE: error")
val message = if (parsed.hadExplicitCode) parsed.prefixedMessage else parsed.message
return parsed.code to message
}
/** Normalizes user/session keys while preserving main as the canonical session id. */
fun normalizeMainKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
return if (trimmed.isEmpty()) null else trimmed
}
/** Returns true only for the canonical main-session key understood by gateway UI. */
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"

View File

@@ -10,6 +10,9 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
/**
* Injectable notification listener facade so command parsing can be tested without Android service state.
*/
internal interface NotificationsStateProvider {
fun readSnapshot(context: Context): DeviceNotificationSnapshot
@@ -22,6 +25,7 @@ internal interface NotificationsStateProvider {
}
private object SystemNotificationsStateProvider : NotificationsStateProvider {
/** Reads listener state through Android APIs and returns a disabled snapshot when access is missing. */
override fun readSnapshot(context: Context): DeviceNotificationSnapshot {
val enabled = DeviceNotificationListenerService.isAccessEnabled(context)
if (!enabled) {
@@ -34,27 +38,32 @@ private object SystemNotificationsStateProvider : NotificationsStateProvider {
return DeviceNotificationListenerService.snapshot(context, enabled = true)
}
/** Requests a platform listener rebind after access has been granted. */
override fun requestServiceRebind(context: Context) {
DeviceNotificationListenerService.requestServiceRebind(context)
}
/** Delegates actions to the active listener service instance. */
override fun executeAction(
context: Context,
request: NotificationActionRequest,
): NotificationActionResult = DeviceNotificationListenerService.executeAction(context, request)
}
/** Handles notification listing and actions via the Android listener service. */
class NotificationsHandler private constructor(
private val appContext: Context,
private val stateProvider: NotificationsStateProvider,
) {
constructor(appContext: Context) : this(appContext = appContext, stateProvider = SystemNotificationsStateProvider)
/** Lists the current listener snapshot after nudging Android to reconnect if needed. */
suspend fun handleNotificationsList(_paramsJson: String?): GatewaySession.InvokeResult {
val snapshot = readSnapshotWithRebind()
return GatewaySession.InvokeResult.ok(snapshotPayloadJson(snapshot))
}
/** Executes an action against a notification key from the current listener snapshot. */
suspend fun handleNotificationsActions(paramsJson: String?): GatewaySession.InvokeResult {
readSnapshotWithRebind()
@@ -76,6 +85,8 @@ class NotificationsHandler private constructor(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: action required (open|dismiss|reply)",
)
// Keep accepted action names aligned with the cross-platform notification
// command contract rather than Android-specific PendingIntent labels.
val action =
when (actionRaw) {
"open" -> NotificationActionKind.Open
@@ -123,6 +134,7 @@ class NotificationsHandler private constructor(
private fun readSnapshotWithRebind(): DeviceNotificationSnapshot {
val snapshot = stateProvider.readSnapshot(appContext)
if (snapshot.enabled && !snapshot.connected) {
// Access can be granted while Android has not rebound the listener yet.
stateProvider.requestServiceRebind(appContext)
}
return snapshot

View File

@@ -29,12 +29,14 @@ private const val DEFAULT_PHOTOS_QUALITY = 0.85
private const val MAX_TOTAL_BASE64_CHARS = 340 * 1024
private const val MAX_PER_PHOTO_BASE64_CHARS = 300 * 1024
/** Request shape for photos.latest after defaults and bounds are applied. */
internal data class PhotosLatestRequest(
val limit: Int,
val maxWidth: Int,
val quality: Double,
)
/** Encoded photo payload returned to the gateway. */
internal data class EncodedPhotoPayload(
val format: String,
val base64: String,
@@ -43,6 +45,7 @@ internal data class EncodedPhotoPayload(
val createdAt: String?,
)
/** Photo access seam for Android MediaStore and tests. */
internal interface PhotosDataSource {
fun hasPermission(context: Context): Boolean
@@ -53,6 +56,7 @@ internal interface PhotosDataSource {
}
private object SystemPhotosDataSource : PhotosDataSource {
/** Checks the API-specific image read permission used by MediaStore image access. */
override fun hasPermission(context: Context): Boolean {
val permission =
if (Build.VERSION.SDK_INT >= 33) {
@@ -77,6 +81,8 @@ private object SystemPhotosDataSource : PhotosDataSource {
if (remainingBudget <= 0) break
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
try {
// Enforce both per-photo and total payload budgets before returning
// base64 data through the gateway invoke response.
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS)
if (encoded == null) continue
if (encoded.base64.length > remainingBudget) break
@@ -172,6 +178,8 @@ private object SystemPhotosDataSource : PhotosDataSource {
} ?: return null
if (decoded.width <= maxWidth) return decoded
// Decode sampling is power-of-two only; finish with exact scaling when the
// sampled bitmap is still wider than the requested max width.
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
return try {
decoded.scale(maxWidth, targetHeight, true)
@@ -215,6 +223,7 @@ private object SystemPhotosDataSource : PhotosDataSource {
)
}
if (jpegQuality > 35) {
// Try quality reduction before resizing so small images keep detail.
jpegQuality = max(25, jpegQuality - 15)
return@repeat
}
@@ -232,12 +241,14 @@ private object SystemPhotosDataSource : PhotosDataSource {
}
}
/** Handles photos.latest by querying MediaStore and returning bounded JPEG payloads. */
class PhotosHandler private constructor(
private val appContext: Context,
private val dataSource: PhotosDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemPhotosDataSource)
/** Returns the newest accessible photos as gateway-sized base64 JPEGs. */
fun handlePhotosLatest(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasPermission(appContext)) {
return GatewaySession.InvokeResult.error(
@@ -300,6 +311,7 @@ class PhotosHandler private constructor(
val maxWidthRaw = (params["maxWidth"] as? JsonPrimitive)?.content?.toIntOrNull()
val qualityRaw = (params["quality"] as? JsonPrimitive)?.content?.toDoubleOrNull()
// Clamp model-supplied values to protect memory and response-size limits.
val limit = (limitRaw ?: DEFAULT_PHOTOS_LIMIT).coerceIn(1, 20)
val maxWidth = (maxWidthRaw ?: DEFAULT_PHOTOS_MAX_WIDTH).coerceIn(240, 4096)
val quality = (qualityRaw ?: DEFAULT_PHOTOS_QUALITY).coerceIn(0.1, 1.0)
@@ -307,6 +319,7 @@ class PhotosHandler private constructor(
}
companion object {
/** Creates a handler with an injected photo source for parser and payload tests. */
internal fun forTesting(
appContext: Context,
dataSource: PhotosDataSource,

View File

@@ -17,6 +17,7 @@ import kotlinx.serialization.json.contentOrNull
private const val NOTIFICATION_CHANNEL_BASE_ID = "openclaw.system.notify"
/** Parsed payload for system.notify invocations. */
internal data class SystemNotifyRequest(
val title: String,
val body: String,
@@ -24,6 +25,7 @@ internal data class SystemNotifyRequest(
val priority: String?,
)
/** Notification posting seam used by production Android and unit tests. */
internal interface SystemNotificationPoster {
fun isAuthorized(): Boolean
@@ -33,6 +35,7 @@ internal interface SystemNotificationPoster {
private class AndroidSystemNotificationPoster(
private val appContext: Context,
) : SystemNotificationPoster {
/** Checks both Android 13 runtime permission and app-level notification enablement. */
override fun isAuthorized(): Boolean {
if (Build.VERSION.SDK_INT >= 33) {
val granted =
@@ -43,6 +46,7 @@ private class AndroidSystemNotificationPoster(
return NotificationManagerCompat.from(appContext).areNotificationsEnabled()
}
/** Posts through a priority-specific channel so Android's immutable channel importance is respected. */
override fun post(request: SystemNotifyRequest) {
val channelId = ensureChannel(request.priority)
val silent = isSilentSound(request.sound)
@@ -69,6 +73,8 @@ private class AndroidSystemNotificationPoster(
private fun ensureChannel(priority: String?): String {
val normalizedPriority = priority.orEmpty().trim().lowercase()
// Android channel importance is immutable after creation, so priority maps
// to stable channel ids instead of mutating one shared channel.
val (suffix, importance, name) =
when (normalizedPriority) {
"passive" -> Triple("passive", NotificationManager.IMPORTANCE_LOW, "OpenClaw Passive")
@@ -97,11 +103,13 @@ private class AndroidSystemNotificationPoster(
}
}
/** Handles system-level node.invoke commands implemented by Android services. */
class SystemHandler private constructor(
private val poster: SystemNotificationPoster,
) {
constructor(appContext: Context) : this(poster = AndroidSystemNotificationPoster(appContext))
/** Posts an Android notification from the gateway system.notify command. */
fun handleSystemNotify(paramsJson: String?): GatewaySession.InvokeResult {
val params =
parseNotifyRequest(paramsJson)
@@ -139,6 +147,8 @@ class SystemHandler private constructor(
private fun parseNotifyRequest(paramsJson: String?): SystemNotifyRequest? {
val params = parseParamsObject(paramsJson) ?: return null
// title/body are required by the gateway contract; optional fields only
// influence Android channel/silence behavior.
val rawTitle =
(params["title"] as? JsonPrimitive)
?.contentOrNull
@@ -167,6 +177,7 @@ class SystemHandler private constructor(
}
companion object {
/** Creates a handler with a fake poster for parser and authorization tests. */
internal fun forTesting(poster: SystemNotificationPoster): SystemHandler = SystemHandler(poster)
}
}

View File

@@ -4,6 +4,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
object OpenClawCanvasA2UIAction {
/** Reads the agent-facing action name from either the modern name field or legacy action field. */
fun extractActionName(userAction: JsonObject): String? {
val name =
(userAction["name"] as? JsonPrimitive)
@@ -19,6 +20,7 @@ object OpenClawCanvasA2UIAction {
return action.ifEmpty { null }
}
/** Normalizes prompt tag values so the compact CANVAS_A2UI envelope stays parser-friendly. */
fun sanitizeTagValue(value: String): String {
val trimmed = value.trim().ifEmpty { "-" }
val normalized = trimmed.replace(" ", "_")
@@ -35,6 +37,7 @@ object OpenClawCanvasA2UIAction {
return out.toString()
}
/** Formats the compact text envelope sent to the agent when a canvas UI action fires. */
fun formatAgentMessage(
actionName: String,
sessionKey: String,
@@ -57,6 +60,7 @@ object OpenClawCanvasA2UIAction {
).joinToString(separator = " ")
}
/** Builds JS that reports an agent action result back to the canvas runtime. */
fun jsDispatchA2UIActionStatus(
actionId: String,
ok: Boolean,

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.protocol
/** Capability ids advertised by the Android node to the OpenClaw gateway. */
enum class OpenClawCapability(
val rawValue: String,
) {
@@ -19,6 +20,7 @@ enum class OpenClawCapability(
CallLog("callLog"),
}
/** Canvas command ids mirrored from the gateway tool namespace. */
enum class OpenClawCanvasCommand(
val rawValue: String,
) {
@@ -34,6 +36,7 @@ enum class OpenClawCanvasCommand(
}
}
/** Streaming canvas commands sent from agents back into the Android UI. */
enum class OpenClawCanvasA2UICommand(
val rawValue: String,
) {
@@ -47,6 +50,7 @@ enum class OpenClawCanvasA2UICommand(
}
}
/** Camera command ids accepted by the Android node. */
enum class OpenClawCameraCommand(
val rawValue: String,
) {
@@ -60,6 +64,7 @@ enum class OpenClawCameraCommand(
}
}
/** SMS command ids accepted by the Android node. */
enum class OpenClawSmsCommand(
val rawValue: String,
) {
@@ -72,6 +77,7 @@ enum class OpenClawSmsCommand(
}
}
/** Push-to-talk command ids accepted by the Android node. */
enum class OpenClawTalkCommand(
val rawValue: String,
) {
@@ -86,6 +92,7 @@ enum class OpenClawTalkCommand(
}
}
/** Location command ids accepted by the Android node. */
enum class OpenClawLocationCommand(
val rawValue: String,
) {
@@ -97,6 +104,7 @@ enum class OpenClawLocationCommand(
}
}
/** Device status and metadata command ids accepted by the Android node. */
enum class OpenClawDeviceCommand(
val rawValue: String,
) {
@@ -111,6 +119,7 @@ enum class OpenClawDeviceCommand(
}
}
/** Notification command ids accepted by the Android node. */
enum class OpenClawNotificationsCommand(
val rawValue: String,
) {
@@ -123,6 +132,7 @@ enum class OpenClawNotificationsCommand(
}
}
/** System command ids accepted by the Android node. */
enum class OpenClawSystemCommand(
val rawValue: String,
) {
@@ -134,6 +144,7 @@ enum class OpenClawSystemCommand(
}
}
/** Photos command ids accepted by the Android node. */
enum class OpenClawPhotosCommand(
val rawValue: String,
) {
@@ -145,6 +156,7 @@ enum class OpenClawPhotosCommand(
}
}
/** Contacts command ids accepted by the Android node. */
enum class OpenClawContactsCommand(
val rawValue: String,
) {
@@ -157,6 +169,7 @@ enum class OpenClawContactsCommand(
}
}
/** Calendar command ids accepted by the Android node. */
enum class OpenClawCalendarCommand(
val rawValue: String,
) {
@@ -169,6 +182,7 @@ enum class OpenClawCalendarCommand(
}
}
/** Motion sensor command ids accepted by the Android node. */
enum class OpenClawMotionCommand(
val rawValue: String,
) {
@@ -181,6 +195,7 @@ enum class OpenClawMotionCommand(
}
}
/** Call-log command ids accepted by the Android node. */
enum class OpenClawCallLogCommand(
val rawValue: String,
) {

View File

@@ -31,6 +31,7 @@ private data class ToolDisplayConfig(
val tools: Map<String, ToolDisplaySpec>? = null,
)
/** Compact UI summary for a running or pending tool call. */
data class ToolDisplaySummary(
val name: String,
val emoji: String,
@@ -39,6 +40,7 @@ data class ToolDisplaySummary(
val verb: String?,
val detail: String?,
) {
/** Optional second-line detail assembled from the action verb and best argument preview. */
val detailLine: String?
get() {
val parts = mutableListOf<String>()
@@ -47,10 +49,12 @@ data class ToolDisplaySummary(
return if (parts.isEmpty()) null else parts.joinToString(" · ")
}
/** Single-line fallback for compact tool rows that do not render detail separately. */
val summaryLine: String
get() = if (detailLine != null) "$emoji $label: $detailLine" else "$emoji $label"
}
/** Resolves tool-call names and args into user-facing Android display text. */
object ToolDisplayRegistry {
private const val CONFIG_ASSET = "tool-display.json"
@@ -58,6 +62,7 @@ object ToolDisplayRegistry {
@Volatile private var cachedConfig: ToolDisplayConfig? = null
/** Resolves a raw tool call into stable, bounded UI text for pending-tool surfaces. */
fun resolve(
context: Context,
name: String?,
@@ -86,6 +91,8 @@ object ToolDisplayRegistry {
detail = pathDetail(args)
}
// Action-specific detail keys win over tool defaults so commands like
// read/write can surface the most useful argument for that action.
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
if (detail == null) {
detail = firstValue(args, detailKeys)
@@ -122,6 +129,8 @@ object ToolDisplayRegistry {
cachedConfig = decoded
decoded
} catch (_: Throwable) {
// The chat UI should still render pending tools if the asset is absent or
// malformed in debug builds.
val fallback = ToolDisplayConfig()
cachedConfig = fallback
fallback

View File

@@ -14,6 +14,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.delay
/** Full-screen white flash keyed by camera capture tokens. */
@Composable
fun CameraFlashOverlay(
token: Long,
@@ -29,6 +30,8 @@ private fun CameraFlash(token: Long) {
var alpha by remember { mutableFloatStateOf(0f) }
LaunchedEffect(token) {
if (token == 0L) return@LaunchedEffect
// Token changes replay the animation even when consecutive captures use
// the same HUD message.
alpha = 0.85f
delay(110)
alpha = 0f

View File

@@ -26,6 +26,7 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import java.util.concurrent.atomic.AtomicReference
/** Hosts the gateway canvas WebView and attaches it to the runtime canvas controller. */
@SuppressLint("SetJavaScriptEnabled")
@Suppress("DEPRECATION")
@Composable
@@ -151,6 +152,9 @@ fun CanvasScreen(
}
}
// The listener accepts any WebView origin at registration time because
// gateway A2UI URLs are dynamic; CanvasActionTrust validates the live URL
// before forwarding each message.
val bridge =
CanvasA2UIActionBridge(
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
@@ -184,6 +188,7 @@ fun CanvasScreen(
)
}
/** Filters WebView postMessage payloads before they enter the A2UI action handler. */
internal class CanvasA2UIActionBridge(
private val isTrustedPage: () -> Boolean,
private val onMessage: (String) -> Unit,

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Settings detail surface for live canvas status, refresh, and embedded preview. */
@Composable
internal fun CanvasSettingsScreen(
viewModel: MainViewModel,
@@ -47,6 +48,8 @@ internal fun CanvasSettingsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Refresh once when the gateway comes online so the settings preview is
// populated before the user manually asks for a rehydrate.
viewModel.refreshHomeCanvasOverviewIfConnected()
}
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Settings screen for gateway channel readiness and account status. */
@Composable
internal fun ChannelsSettingsScreen(
viewModel: MainViewModel,
@@ -71,6 +72,8 @@ internal fun ChannelsSettingsScreen(
}
}
if (summary.partial || summary.warnings.isNotEmpty()) {
// Partial channel scans still include useful rows; surface the warning
// without hiding successful channel status.
ClawPanel {
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
@@ -156,4 +159,5 @@ private fun channelBadge(label: String): String =
.joinToString("")
.ifBlank { "C" }
/** Chooses the first gateway warning or a generic partial-scan message. */
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.chat.ChatSheetContent
import androidx.compose.runtime.Composable
/** Keeps the public shell entry point stable while chat internals live under ui.chat. */
@Composable
fun ChatSheet(viewModel: MainViewModel) {
ChatSheetContent(viewModel = viewModel)

View File

@@ -50,6 +50,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Full-screen command palette for navigation and recent-session search. */
@Composable
internal fun CommandPalette(
viewModel: MainViewModel,
@@ -158,6 +159,7 @@ private data class CommandItem(
val icon: ImageVector,
val onClick: () -> Unit,
) {
/** Matches palette queries against both action title and explanatory subtitle. */
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
}
@@ -295,6 +297,7 @@ private fun CommandSectionLabel(title: String) {
}
}
/** Builds provider quick-action metadata from current gateway/catalog state. */
private fun providerCommandSubtitle(
isConnected: Boolean,
providers: List<GatewayModelProviderSummary>,
@@ -307,8 +310,10 @@ private fun providerCommandSubtitle(
return "Configure model access"
}
/** Falls back to the canonical main-session label when gateway display names are blank. */
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
/** Formats command-palette session timestamps for compact rows. */
private fun commandRelativeTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L

View File

@@ -61,6 +61,7 @@ private enum class ConnectInputMode {
Manual,
}
/** Gateway connection screen for setup-code and manual endpoint pairing. */
@Composable
fun ConnectTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
@@ -291,6 +292,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
validationText = null
if (inputMode == ConnectInputMode.SetupCode) {
// Setup-code auth should replace old bootstrap/shared credentials;
// manual reconnects keep existing typed credentials.
viewModel.resetGatewaySetupAuth()
}
viewModel.setManualEnabled(true)

View File

@@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Settings screen for gateway dreaming state and recent dream diary entries. */
@Composable
internal fun DreamingSettingsScreen(
viewModel: MainViewModel,
@@ -187,6 +188,7 @@ private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
}
}
/** Formats the next dreaming cycle as a compact relative label. */
private fun formatDreamingNextRun(nextRunAtMs: Long?): String {
val next = nextRunAtMs ?: return "Not scheduled"
val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L)

View File

@@ -10,6 +10,7 @@ import java.net.URI
import java.util.Base64
import java.util.Locale
/** Parsed endpoint fields after URL validation and cleartext-safety checks. */
internal data class GatewayEndpointConfig(
val host: String,
val port: Int,
@@ -17,6 +18,7 @@ internal data class GatewayEndpointConfig(
val displayUrl: String,
)
/** Decoded setup-code payload; only one credential family is expected to be populated. */
internal data class GatewaySetupCode(
val url: String,
val bootstrapToken: String?,
@@ -24,6 +26,7 @@ internal data class GatewaySetupCode(
val password: String?,
)
/** Final gateway connection fields selected from setup-code or manual UI input. */
internal data class GatewayConnectConfig(
val host: String,
val port: Int,
@@ -33,22 +36,26 @@ internal data class GatewayConnectConfig(
val password: String,
)
/** Validation reason used by setup, QR, and manual endpoint copy. */
internal enum class GatewayEndpointValidationError {
INVALID_URL,
INSECURE_REMOTE_URL,
}
/** User input source used to choose endpoint-validation wording. */
internal enum class GatewayEndpointInputSource {
SETUP_CODE,
MANUAL,
QR_SCAN,
}
/** Endpoint parse result that preserves the reason when no usable config exists. */
internal data class GatewayEndpointParseResult(
val config: GatewayEndpointConfig? = null,
val error: GatewayEndpointValidationError? = null,
)
/** QR scan result that separates a usable setup code from validation copy. */
internal data class GatewayScannedSetupCodeResult(
val setupCode: String? = null,
val error: GatewayEndpointValidationError? = null,
@@ -60,6 +67,7 @@ private const val remoteGatewaySecurityRule =
private const val remoteGatewaySecurityFix =
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
/** Resolves setup-code or manual UI fields into a connection config. */
internal fun resolveGatewayConnectConfig(
useSetupCode: Boolean,
setupCode: String,
@@ -77,6 +85,8 @@ internal fun resolveGatewayConnectConfig(
val setup = decodeGatewaySetupCode(setupCode) ?: return null
val parsed = parseGatewayEndpointResult(setup.url).config ?: return null
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
// Bootstrap setup codes intentionally suppress stale shared credentials;
// the bootstrap token owns the first authenticated pairing exchange.
val sharedToken =
when {
!setup.token.isNullOrBlank() -> setup.token.trim()
@@ -121,8 +131,10 @@ internal fun resolveGatewayConnectConfig(
)
}
/** Parses an endpoint string and returns only the valid connection config. */
internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config
/** Parses and validates gateway endpoint input with user-facing error reasons. */
internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseResult {
val raw = rawInput.trim()
if (raw.isEmpty()) return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
@@ -166,6 +178,7 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
)
}
/** Decodes base64url setup-code payloads produced by gateway onboarding. */
internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
val trimmed = rawInput.trim()
if (trimmed.isEmpty()) return null
@@ -193,8 +206,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
}
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =
resolveSetupCodeCandidate(rawInput)
@@ -209,6 +224,7 @@ internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetu
return GatewayScannedSetupCodeResult(setupCode = setupCode)
}
/** Converts endpoint validation errors into setup-source-specific UI copy. */
internal fun gatewayEndpointValidationMessage(
error: GatewayEndpointValidationError,
source: GatewayEndpointInputSource,
@@ -231,6 +247,7 @@ internal fun gatewayEndpointValidationMessage(
}
}
/** Builds a URL from manual host/port/tls fields for shared endpoint parsing. */
internal fun composeGatewayManualUrl(
hostInput: String,
portInput: String,

View File

@@ -7,6 +7,7 @@ import android.content.Context
import android.os.Build
import android.widget.Toast
/** App version label shared by diagnostics and gateway-facing Android metadata. */
internal fun openClawAndroidVersionLabel(): String {
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
@@ -16,18 +17,22 @@ internal fun openClawAndroidVersionLabel(): String {
}
}
/** Normalizes blank gateway status text for display and diagnostics copy. */
internal fun gatewayStatusForDisplay(statusText: String): String = statusText.trim().ifEmpty { "Offline" }
/** Returns true when the status has enough signal to show diagnostics affordances. */
internal fun gatewayStatusHasDiagnostics(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower != "offline" && !lower.contains("connecting")
}
/** Detects pairing/approval status text so UI can offer pairing-specific actions. */
internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower.contains("pair") || lower.contains("approve")
}
/** Builds the copyable support prompt with device, endpoint, and exact status context. */
internal fun buildGatewayDiagnosticsReport(
screen: String,
gatewayAddress: String,
@@ -67,6 +72,7 @@ internal fun buildGatewayDiagnosticsReport(
""".trimIndent()
}
/** Copies the diagnostics report to Android clipboard and shows a short confirmation toast. */
internal fun copyGatewayDiagnosticsReport(
context: Context,
screen: String,

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.delay
internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L
internal const val PAIRING_AUTO_RETRY_MS = 4_000L
/** Retries pairing-only gateway refreshes while the screen is visible and started. */
@Composable
internal fun PairingAutoRetryEffect(
enabled: Boolean,
@@ -41,6 +42,8 @@ internal fun PairingAutoRetryEffect(
if (!enabled || !lifecycleStarted) {
return@LaunchedEffect
}
// Give the gateway a short settling window before the first retry so an
// approval response is not immediately chased by a redundant reconnect.
delay(PAIRING_INITIAL_AUTO_RETRY_MS)
while (true) {
onRetry()

View File

@@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Settings health screen for gateway/node status and recent gateway logs. */
@Composable
internal fun HealthLogsSettingsScreen(
viewModel: MainViewModel,
@@ -45,6 +46,8 @@ internal fun HealthLogsSettingsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Load logs when the gateway becomes available; manual refresh covers
// later updates so this screen does not poll.
viewModel.refreshHealthLogs()
}
}
@@ -202,6 +205,8 @@ private fun GatewayLogRow(entry: GatewayLogEntry) {
private fun compactLogTime(value: String?): String {
val raw = value?.trim().orEmpty()
if (raw.isEmpty()) return "--:--"
// Gateway log timestamps may be ISO strings or already-compact fragments;
// keep only the HH:mm portion when present.
val time =
raw
.substringAfter('T', raw)

View File

@@ -97,6 +97,8 @@ internal fun darkMobileColors() =
chipBorderError = Color(0xFF3E1E1E),
)
// Defaulting to light tokens keeps previews/tests usable when a screen forgets to
// provide the app theme; production roots override this composition local.
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
internal object MobileColorsAccessor {
@@ -104,9 +106,8 @@ internal object MobileColorsAccessor {
@Composable get() = LocalMobileColors.current
}
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
// without converting every file at once. Each resolves to the themed value.
// Keep these accessors while screens migrate to `MobileColorsAccessor.current`.
// Each getter must stay composable so callers always read the active theme.
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
@@ -129,7 +130,8 @@ internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
// Background gradient light fades white→gray, dark fades near-black→dark-gray
// Build the page backdrop from semantic surfaces so light/dark palettes keep
// their contrast relationship without duplicating raw color stops.
internal val mobileBackgroundGradient: Brush
@Composable get() {
val colors = LocalMobileColors.current

View File

@@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Settings screen for gateway nodes, paired devices, and pending pairing requests. */
@Composable
internal fun NodesDevicesSettingsScreen(
viewModel: MainViewModel,
@@ -41,6 +42,8 @@ internal fun NodesDevicesSettingsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Refresh once on connection; user-triggered refresh handles later changes
// so device admin state is not polled from Compose.
viewModel.refreshNodesDevices()
}
}
@@ -195,6 +198,7 @@ private fun DeviceListRow(
)
}
/** True when the gateway returned no node or device rows to render. */
private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty()
private fun nodeSubtitle(node: GatewayNodeSummary): String {

View File

@@ -113,6 +113,7 @@ private enum class OnboardingStep {
private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L
/** First-run Android onboarding flow for gateway pairing and permission setup. */
@Composable
fun OnboardingFlow(
viewModel: MainViewModel,
@@ -273,6 +274,8 @@ fun OnboardingFlow(
setupError = null
attemptedConnect = true
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
// Setup-code pairing replaces any stale shared credentials before
// the bootstrap token is stored for the first authenticated connect.
viewModel.resetGatewaySetupAuth()
viewModel.setManualEnabled(true)
viewModel.setManualHost(config.host)
@@ -905,6 +908,7 @@ internal enum class GatewayRecoveryUiState(
),
}
/** Derives recovery screen state from gateway/node readiness and transient status text. */
internal fun gatewayRecoveryUiState(
ready: Boolean,
statusText: String,
@@ -918,6 +922,7 @@ internal fun gatewayRecoveryUiState(
else -> GatewayRecoveryUiState.Failed
}
/** Detects gateway-approved states where the Android node is still coming online. */
internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean {
val lower = gatewayStatusForDisplay(statusText).lowercase()
return lower.contains("operator offline") || lower.contains("node offline")
@@ -932,6 +937,7 @@ private data class GatewayConfig(
val password: String,
)
/** Resolves setup-code or manual fields into the gateway config used for first connect. */
private fun resolveGatewayConfig(
setupCode: String,
manualHost: String,
@@ -944,6 +950,8 @@ private fun resolveGatewayConfig(
if (setup != null) {
val endpoint = parseGatewayEndpointResult(setup.url).config ?: return null
val bootstrapToken = setup.bootstrapToken?.trim().orEmpty()
// Bootstrap setup codes own first-pairing auth; fall back to typed token or
// password only for non-bootstrap setup payloads.
return GatewayConfig(
host = endpoint.host,
port = endpoint.port,
@@ -974,6 +982,7 @@ private fun resolveGatewayConfig(
)
}
/** Selects the recovery detail line from endpoint metadata and transient gateway status. */
private fun recoveryGatewayDetail(
ready: Boolean,
remoteAddress: String?,
@@ -991,6 +1000,7 @@ private fun recoveryGatewayDetail(
"Gateway unreachable"
}
/** Copies the onboarding recovery snapshot for support without including credentials. */
private fun copyGatewayDiagnostic(
context: Context,
statusText: String,
@@ -1011,6 +1021,7 @@ private fun copyGatewayDiagnostic(
Toast.makeText(context, "Diagnostic copied", Toast.LENGTH_SHORT).show()
}
/** One permission row plus launcher callback for onboarding's final setup step. */
private data class PermissionRowModel(
val title: String,
val subtitle: String,
@@ -1019,16 +1030,19 @@ private data class PermissionRowModel(
val onClick: () -> Unit,
)
/** Permission screen model plus a commit hook that persists granted feature toggles. */
private class PermissionState(
val rows: List<PermissionRowModel>,
val applyToViewModel: () -> Unit,
)
/** Onboarding can finish only after gateway and node channels are both ready. */
internal fun canFinishOnboarding(
isConnected: Boolean,
isNodeConnected: Boolean,
): Boolean = isConnected && isNodeConnected
/** Builds permission rows and applies granted feature toggles after onboarding. */
@Composable
private fun rememberPermissionState(
context: Context,
@@ -1170,6 +1184,7 @@ private fun hasPermission(
permission: String,
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Returns true when Android exposes any motion sensor that can back node motion commands. */
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||

View File

@@ -13,6 +13,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
*/
@Composable
fun OpenClawTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
@@ -35,6 +38,9 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
}
}
/**
* Overlay background token tuned for panels floating over the mobile canvas.
*/
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
@@ -44,5 +50,8 @@ fun overlayContainerColor(): Color {
return if (isDark) base else base.copy(alpha = 0.88f)
}
/**
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
*/
@Composable
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -68,6 +68,7 @@ private enum class StatusVisual {
Offline,
}
/** Legacy tab scaffold used by the mobile post-onboarding experience. */
@Composable
fun PostOnboardingTabs(
viewModel: MainViewModel,
@@ -150,6 +151,8 @@ fun PostOnboardingTabs(
.background(mobileBackgroundGradient),
) {
if (chatTabStarted) {
// Keep chat mounted after first use so session state and scroll position
// survive tab switches.
Box(
modifier =
Modifier
@@ -162,6 +165,8 @@ fun PostOnboardingTabs(
}
if (screenTabStarted) {
// Canvas can be expensive to initialize; keep it mounted once visited
// and hide it by alpha/z-order instead of destroying the view tree.
ScreenTabScreen(
viewModel = viewModel,
visible = activeTab == HomeTab.Screen,
@@ -184,6 +189,7 @@ fun PostOnboardingTabs(
}
}
/** Screen tab wrapper that refreshes canvas data once per gateway connection. */
@Composable
private fun ScreenTabScreen(
viewModel: MainViewModel,
@@ -205,6 +211,7 @@ private fun ScreenTabScreen(
}
}
/** Top status chip derived from gateway connection text. */
@Composable
private fun TopStatusBar(
statusText: String,
@@ -295,6 +302,7 @@ private fun TopStatusBar(
}
}
/** Bottom navigation for the legacy tab scaffold. */
@Composable
private fun BottomTabBar(
activeTab: HomeTab,

View File

@@ -55,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Android providers/models browser backed by the gateway catalog. */
@Composable
internal fun ProvidersModelsScreen(
viewModel: MainViewModel,
@@ -190,6 +191,7 @@ private data class ProviderRow(
val modelCount: Int,
)
/** Combines auth-provider readiness rows with catalog-only providers. */
private fun providerRows(
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
@@ -206,6 +208,8 @@ private fun providerRows(
modelCount = modelCounts[provider.id] ?: 0,
)
}
// Static/catalog-only providers may expose models without a matching auth
// provider row; keep them visible as ready providers.
val missingAuthRows =
modelCounts.keys
.filter { provider -> authRows.none { it.id == provider } }
@@ -245,6 +249,7 @@ private fun providerSetupSubtitle(
else -> "Add provider credentials on your Gateway"
}
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
internal fun modelProviderReady(status: String): Boolean {
val normalized = status.trim().lowercase()
return normalized == "ok" ||
@@ -254,6 +259,7 @@ internal fun modelProviderReady(status: String): Boolean {
normalized == "static"
}
/** Groups models by provider using the same display priority as provider rows. */
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
models
.groupBy { it.provider }
@@ -483,6 +489,7 @@ private fun ModelRow(model: GatewayModelSummary) {
}
}
/** Derives compact capability chips for model catalog rows. */
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
buildList {
if (model.supportsReasoning) add("Reasoning")

View File

@@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
/** Chooses onboarding or the authenticated app shell from persisted app state. */
@Composable
fun RootScreen(viewModel: MainViewModel) {
val onboardingCompleted by viewModel.onboardingCompleted.collectAsState()

View File

@@ -52,6 +52,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Session browser for recent and currently-live chat sessions. */
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
@@ -81,6 +82,8 @@ internal fun SessionsScreen(
LaunchedEffect(isConnected) {
if (isConnected) {
// Sessions are cheap to refresh on entry; subsequent sorting/filtering is
// local to avoid re-querying while the user explores the list.
viewModel.refreshChatSessions(limit = 200)
}
}
@@ -309,18 +312,21 @@ private enum class SessionFilter {
Live,
}
/** Empty-state title selected by the active session browser filter. */
private fun emptySessionTitle(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "No sessions yet"
SessionFilter.Live -> "No live session"
}
/** Empty-state body selected by the active session browser filter. */
private fun emptySessionBody(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "Start a new conversation and it will show up here."
SessionFilter.Live -> "Open Chat to start or resume the current session."
}
/** Formats session timestamps for compact mobile metadata. */
private fun relativeSessionTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L
@@ -331,4 +337,5 @@ private fun relativeSessionTime(updatedAtMs: Long): String {
return "${hours / 24}d"
}
/** Falls back to the canonical main-session label when gateway display names are blank. */
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"

View File

@@ -94,6 +94,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
/**
* Detail routes reachable from the Android settings home surface.
*/
internal enum class SettingsRoute {
Home,
Profile,
@@ -115,6 +118,9 @@ internal enum class SettingsRoute {
About,
}
/**
* Dispatches a selected settings route to its detail screen without changing navigation ownership.
*/
@Composable
internal fun SettingsDetailScreen(
viewModel: MainViewModel,
@@ -784,6 +790,7 @@ private fun AppearanceSettingsScreen(onBack: () -> Unit) {
}
}
/** Converts raw gateway connection text into stable settings metric labels. */
private fun gatewayStatusLabel(
statusText: String,
isConnected: Boolean,
@@ -861,6 +868,7 @@ private fun AboutStatusRow(
}
}
/** Chooses about-screen copy based on whether the gateway advertises an update. */
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."
@@ -868,6 +876,9 @@ private fun aboutUpdateText(latestVersion: String?): String =
"A Gateway update is available. Run the update from the Web UI or CLI when you are ready."
}
/**
* Shared settings detail shell with back navigation, title, subtitle, and section content.
*/
@Composable
internal fun SettingsDetailFrame(
title: String,
@@ -900,6 +911,9 @@ internal fun SettingsDetailFrame(
}
}
/**
* Toggle row model reused by settings sections that render simple on/off controls.
*/
private data class SettingsToggleRow(
val title: String,
val subtitle: String,
@@ -908,6 +922,9 @@ private data class SettingsToggleRow(
val onCheckedChange: (Boolean) -> Unit,
)
/**
* Compact metric row model for connected gateway summaries.
*/
internal data class SettingsMetric(
val title: String,
val value: String,
@@ -989,6 +1006,9 @@ private fun AgentListRow(
)
}
/**
* Chooses a display name for the configured default agent, falling back to any available agent.
*/
private fun defaultAgentName(
agents: List<GatewayAgentSummary>,
defaultAgentId: String?,
@@ -998,6 +1018,9 @@ private fun defaultAgentName(
return agent?.name?.takeIf { it.isNotBlank() } ?: agent?.id ?: "None"
}
/**
* Builds a short stable badge from agent emoji/name/id for dense lists.
*/
private fun agentBadge(agent: GatewayAgentSummary): String {
agent.emoji
?.trim()
@@ -1013,6 +1036,9 @@ private fun agentBadge(agent: GatewayAgentSummary): String {
.ifBlank { "A" }
}
/**
* Normalizes tool-call names into readable approval action labels.
*/
private fun approvalActionName(name: String): String {
val cleaned =
name
@@ -1027,6 +1053,7 @@ private fun approvalActionName(name: String): String {
.ifBlank { "Action Request" }
}
/** Builds approval row age/error copy without exposing raw tool arguments. */
private fun approvalSubtitle(
toolCall: ChatPendingToolCall,
hasIssue: Boolean,
@@ -1037,8 +1064,10 @@ private fun approvalSubtitle(
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
}
/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */
private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}"
/** Summarizes a provider plan and most-used quota window for usage rows. */
private fun usageProviderSubtitle(provider: GatewayUsageProviderSummary): String {
provider.error?.let { return it }
val window = provider.windows.maxByOrNull { it.usedPercent }
@@ -1046,6 +1075,9 @@ private fun usageProviderSubtitle(provider: GatewayUsageProviderSummary): String
return listOfNotNull(provider.plan, quota).joinToString(" · ").ifBlank { "No limits reported" }
}
/**
* Converts usage timestamps into short relative labels for metric panels.
*/
private fun formatUsageUpdated(updatedAtMs: Long?): String {
val updated = updatedAtMs ?: return "Never"
val deltaMs = (System.currentTimeMillis() - updated).coerceAtLeast(0L)
@@ -1059,6 +1091,7 @@ private fun formatUsageUpdated(updatedAtMs: Long?): String {
}
}
/** Converts gateway cron status text into the short row badge label. */
private fun cronJobStatusText(job: GatewayCronJobSummary): String {
if (!job.enabled) return "Off"
return when (job.lastRunStatus?.lowercase()) {
@@ -1069,6 +1102,7 @@ private fun cronJobStatusText(job: GatewayCronJobSummary): String {
}
}
/** Maps gateway cron status text to app status colors. */
private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
if (!job.enabled) return ClawStatus.Neutral
return when (job.lastRunStatus?.lowercase()) {
@@ -1078,6 +1112,9 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
}
}
/**
* Converts cron wake times into short relative labels for scheduled-work rows.
*/
private fun formatCronWake(timeMs: Long?): String {
val target = timeMs ?: return "None"
val deltaMs = target - System.currentTimeMillis()
@@ -1123,6 +1160,9 @@ private fun SettingsToggleListRow(row: SettingsToggleRow) {
}
}
/**
* Reusable metric panel for settings screens with compact title/value rows.
*/
@Composable
internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp)) {
@@ -1159,11 +1199,15 @@ private fun SettingsIconMark(icon: ImageVector) {
}
}
/**
* Checks an exact Android runtime permission for settings enablement.
*/
private fun hasPermission(
context: Context,
permission: String,
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Returns true when either fine or coarse location is available to settings callers. */
private fun hasLocationPermission(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)

View File

@@ -72,6 +72,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/** Mobile settings surface for device permissions, forwarding, location, and app preferences. */
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current
@@ -131,6 +132,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
val quietHoursCanEnable = notificationForwardingEnabled && quietHoursDraftValid
// Compare stored values against normalized drafts so equivalent HH:mm input
// does not keep the save button enabled.
val quietHoursDraftDirty =
notificationForwardingQuietStart != (normalizedQuietStartDraft ?: notificationQuietStartDraft.trim()) ||
notificationForwardingQuietEnd != (normalizedQuietEndDraft ?: notificationQuietEndDraft.trim())
@@ -344,6 +347,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// Permission and role screens live outside Compose; refresh all derived
// toggles whenever Android returns to this settings surface.
micPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
@@ -1217,12 +1222,14 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
/** App entry shown in the notification-forwarding package picker. */
data class InstalledApp(
val label: String,
val packageName: String,
val isSystemApp: Boolean,
)
/** Reads launcher, recent-notification, and configured packages for the picker. */
private fun queryInstalledApps(
context: Context,
configuredPackages: Set<String>,
@@ -1273,6 +1280,7 @@ private fun queryInstalledApps(
.toList()
}
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
internal fun resolveNotificationCandidatePackages(
launcherPackages: Set<String>,
recentPackages: List<String>,
@@ -1290,6 +1298,7 @@ internal fun resolveNotificationCandidatePackages(
.toSet()
}
/** Shared Material text-field colors for the legacy mobile settings sheet. */
@Composable
private fun settingsTextFieldColors() =
OutlinedTextFieldDefaults.colors(
@@ -1302,6 +1311,7 @@ private fun settingsTextFieldColors() =
cursorColor = mobileAccent,
)
/** Applies the legacy mobile card border/background used by settings rows. */
@Composable
private fun Modifier.settingsRowModifier() =
this
@@ -1309,6 +1319,7 @@ private fun Modifier.settingsRowModifier() =
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(mobileCardSurface, RoundedCornerShape(14.dp))
/** Primary button colors for the legacy mobile settings sheet. */
@Composable
private fun settingsPrimaryButtonColors() =
ButtonDefaults.buttonColors(
@@ -1318,6 +1329,7 @@ private fun settingsPrimaryButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Destructive button colors for permission and capability settings actions. */
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
@@ -1327,6 +1339,7 @@ private fun settingsDangerButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Opens this app's Android settings page for permissions that require system UI. */
private fun openAppSettings(context: Context) {
val intent =
Intent(
@@ -1336,6 +1349,7 @@ private fun openAppSettings(context: Context) {
context.startActivity(intent)
}
/** Opens notification-listener settings, falling back to app settings if the intent is unavailable. */
private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
runCatching {
@@ -1345,14 +1359,17 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
/** Android 13+ notification permission check; earlier versions grant posting at install time. */
private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
/** Mirrors the notification listener service access check for UI enablement. */
private fun isNotificationListenerEnabled(context: Context): Boolean = DeviceNotificationListenerService.isAccessEnabled(context)
/** Checks whether the device exposes motion sensors needed by motion-related capabilities. */
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||

View File

@@ -86,6 +86,7 @@ private enum class Tab(
ProvidersModels(key = "providers-models", label = "Providers"),
}
/** Main post-onboarding shell that owns top-level Android navigation state. */
@Composable
fun ShellScreen(
viewModel: MainViewModel,
@@ -101,6 +102,8 @@ fun ShellScreen(
LaunchedEffect(requestedHomeDestination) {
val destination = requestedHomeDestination ?: return@LaunchedEffect
// HomeDestination is a one-shot command from launch intents and settings
// actions; consume it after translating to local shell state.
activeTab =
when (destination) {
HomeDestination.Connect -> Tab.Overview
@@ -232,6 +235,8 @@ fun ShellScreen(
}
pendingTrust?.let { prompt ->
// Gateway certificate trust is modal across the shell so navigation
// cannot hide a changed TLS identity prompt.
GatewayTrustDialog(
prompt = prompt,
onAccept = viewModel::acceptGatewayTrustPrompt,
@@ -242,6 +247,7 @@ fun ShellScreen(
}
}
/** Modal trust decision for first-seen or changed gateway TLS fingerprints. */
@Composable
private fun GatewayTrustDialog(
prompt: NodeRuntime.GatewayTrustPrompt,
@@ -421,6 +427,7 @@ private data class ModuleRow(
val settingsRoute: SettingsRoute? = null,
)
/** Floating overview shortcut that keeps chat one tap away from module lists. */
@Composable
private fun OverviewChatButton(
onClick: () -> Unit,
@@ -561,6 +568,7 @@ private data class RecentSessionListItem(
val metadata: String,
)
/** Recent sessions panel that preserves the session key behind display labels. */
@Composable
private fun RecentSessionList(
rows: List<RecentSessionListItem>,
@@ -780,6 +788,7 @@ private fun approvalsSummary(count: Int): String =
private fun approvalsStatus(count: Int): Boolean? = if (count > 0) true else null
/** Summarizes scheduled gateway jobs for overview and settings rows. */
private fun cronJobsSummary(count: Int): String =
when (count) {
0 -> "No scheduled jobs"
@@ -787,6 +796,7 @@ private fun cronJobsSummary(count: Int): String =
else -> "$count scheduled"
}
/** Summarizes provider usage buckets without exposing detailed billing data. */
private fun usageSummaryText(count: Int): String =
when (count) {
0 -> "No provider usage"
@@ -794,11 +804,13 @@ private fun usageSummaryText(count: Int): String =
else -> "$count providers"
}
/** Reports how many gateway skills are enabled, eligible, and dependency-complete. */
private fun skillsSummaryText(skills: List<GatewaySkillSummary>): String {
val ready = skills.count { !it.disabled && it.eligible && it.missingCount == 0 }
return if (skills.isEmpty()) "No skills" else "$ready/${skills.size} ready"
}
/** Converts gateway skill health into a tri-state settings status dot. */
private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
when {
skills.isEmpty() -> null
@@ -806,6 +818,7 @@ private fun skillsStatus(skills: List<GatewaySkillSummary>): Boolean? =
else -> true
}
/** Prioritizes pending pairings over online counts for compact node/device summaries. */
private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String {
val online = summary.nodes.count { it.connected }
val devices = summary.pairedDevices.size
@@ -817,6 +830,7 @@ private fun nodesDevicesSummaryText(summary: GatewayNodesDevicesSummary): String
}
}
/** Maps node/device state to a settings status dot, treating pending pairings as attention-needed. */
private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
when {
summary.pendingDevices.isNotEmpty() -> false
@@ -825,6 +839,7 @@ private fun nodesDevicesStatus(summary: GatewayNodesDevicesSummary): Boolean? =
else -> null
}
/** Summarizes channel connection state, surfacing errors before connected counts. */
private fun channelsSummaryText(summary: GatewayChannelsSummary): String {
val connected = summary.channels.count { it.connected }
return when {
@@ -834,6 +849,7 @@ private fun channelsSummaryText(summary: GatewayChannelsSummary): String {
}
}
/** Maps channel health to the settings status dot shown in the shell. */
private fun channelsStatus(summary: GatewayChannelsSummary): Boolean? =
when {
summary.channels.any { it.error != null } -> false
@@ -842,6 +858,7 @@ private fun channelsStatus(summary: GatewayChannelsSummary): Boolean? =
else -> null
}
/** Summarizes dreaming memory health before enabled/off state. */
private fun dreamingSummaryText(summary: GatewayDreamingSummary): String =
when {
!summary.storeHealthy || !summary.phaseSignalHealthy -> "Needs attention"
@@ -849,6 +866,7 @@ private fun dreamingSummaryText(summary: GatewayDreamingSummary): String =
else -> "Off"
}
/** Maps dreaming store/phase health and enabled state to a settings status dot. */
private fun dreamingStatus(summary: GatewayDreamingSummary): Boolean? =
when {
!summary.storeHealthy || !summary.phaseSignalHealthy -> false

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Settings screen for gateway skills and their readiness state. */
@Composable
internal fun SkillsSettingsScreen(
viewModel: MainViewModel,

View File

@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Full-screen talk-mode presence indicator with pulsing rings and status fallback text.
*/
@Composable
fun TalkOrbOverlay(
seamColor: Color,

View File

@@ -75,6 +75,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
/** Voice home screen that routes between talk mode, dictation, and idle setup. */
@Composable
fun VoiceScreen(
viewModel: MainViewModel,
@@ -113,6 +114,8 @@ fun VoiceScreen(
pendingAction = null
}
// Talk mode and dictation use different managers, so choose the transcript
// from the mode the user is actually seeing.
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
val voiceActive = micEnabled || micIsSending || talkModeEnabled
val gatewayReady = gatewayStatus.isVoiceGatewayReady()
@@ -141,6 +144,8 @@ fun VoiceScreen(
}
if (voiceCaptureMode == VoiceCaptureMode.ManualMic || micEnabled || micIsSending) {
// Manual mic mode owns the whole screen while a turn is being captured or
// delivered, even after the user releases the mic.
DictationScreen(
liveTranscript = micLiveTranscript,
conversation = micConversation,
@@ -222,6 +227,7 @@ fun VoiceScreen(
}
}
/** Full-screen dictation capture and send state. */
@Composable
private fun DictationScreen(
liveTranscript: String?,

View File

@@ -75,6 +75,9 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlin.math.max
/**
* Voice tab that switches between push-to-send mic capture and continuous Talk Mode.
*/
@Composable
fun VoiceTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
@@ -115,7 +118,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
// Manual mic is tied to the Voice tab; Talk Mode is explicit and can continue.
// Manual mic is tab-scoped; Talk Mode is user-enabled and can continue elsewhere.
viewModel.setVoiceScreenActive(false)
}
}
@@ -402,11 +405,17 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
}
/**
* Permission continuation captured before Android's runtime permission dialog suspends the action.
*/
private enum class PendingVoicePermissionAction {
ManualMic,
TalkMode,
}
/**
* Renders one transcript turn, preserving side and color by speaker role.
*/
@Composable
private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
val isUser = entry.role == VoiceConversationRole.User
@@ -439,6 +448,9 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
}
}
/**
* Placeholder assistant turn shown while a manual mic request is queued but not streaming yet.
*/
@Composable
private fun VoiceThinkingBubble() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
@@ -460,6 +472,9 @@ private fun VoiceThinkingBubble() {
}
}
/**
* Static dot cluster used by VoiceThinkingBubble to avoid starting another animation loop.
*/
@Composable
private fun ThinkingDots(color: Color) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -481,12 +496,18 @@ private fun ThinkingDot(
) {}
}
/**
* Checks RECORD_AUDIO using ContextCompat so wrapped activity contexts behave the same.
*/
private fun Context.hasRecordAudioPermission(): Boolean =
(
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
/**
* Walks ContextWrappers until an Activity is found for permission rationale checks.
*/
private fun Context.findActivity(): Activity? =
when (this) {
is Activity -> this
@@ -494,6 +515,9 @@ private fun Context.findActivity(): Activity? =
else -> null
}
/**
* Opens this app's settings page after Android reports the mic permission as blocked.
*/
private fun openAppSettings(context: Context) {
val intent =
Intent(

View File

@@ -11,11 +11,13 @@ import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Compose state for async base64 image decoding. */
internal data class Base64ImageState(
val image: ImageBitmap?,
val failed: Boolean,
)
/** Decodes a base64 image off the UI thread and reports failure state. */
@Composable
internal fun rememberBase64ImageState(base64: String): Base64ImageState {
var image by remember(base64) { mutableStateOf<ImageBitmap?>(null) }

View File

@@ -60,12 +60,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Result of applying a stored chat draft to the current composer input. */
internal data class DraftApplication(
val input: String,
val lastAppliedDraft: String?,
val consumed: Boolean,
)
/** Applies a draft exactly once so restored prompts do not overwrite user edits. */
internal fun applyDraftText(
draftText: String?,
currentInput: String,
@@ -91,6 +93,7 @@ internal fun applyDraftText(
)
}
/** Chat input surface for text, image attachments, thinking level, and run controls. */
@Composable
fun ChatComposer(
draftText: String?,
@@ -115,10 +118,14 @@ fun ChatComposer(
input = next.input
lastAppliedDraft = next.lastAppliedDraft
if (next.consumed) {
// Consume only after the composer state has accepted the draft so
// recomposition cannot reapply it over user edits.
onDraftApplied()
}
}
// One in-flight run owns the composer actions; attachments alone are enough
// to send when the gateway is healthy.
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
val sendBusy = pendingRunCount > 0

View File

@@ -26,6 +26,7 @@ private val decodedBitmapCache =
): Int = value.byteCount.coerceAtLeast(1)
}
/** Loads a picked image URI into the bounded JPEG attachment shape sent to chat. */
internal fun loadSizedImageAttachment(
resolver: ContentResolver,
uri: Uri,
@@ -36,6 +37,8 @@ internal fun loadSizedImageAttachment(
throw IllegalStateException("unsupported attachment")
}
val maxBytes = (CHAT_IMAGE_MAX_BASE64_CHARS / 4) * 3
// Reuse the node JPEG limiter so chat attachments and node photo payloads
// stay within the same gateway frame budget.
val encoded =
JpegSizeLimiter.compressToLimit(
initialWidth = bitmap.width,
@@ -72,6 +75,7 @@ internal fun loadSizedImageAttachment(
)
}
/** Decodes chat image payloads into display-sized bitmaps with an LRU cache. */
internal fun decodeBase64Bitmap(
base64: String,
maxDimension: Int = CHAT_DECODE_MAX_DIMENSION,
@@ -101,6 +105,7 @@ internal fun decodeBase64Bitmap(
return bitmap
}
/** Computes Android's power-of-two bitmap sampling size for bounded decode. */
internal fun computeInSampleSize(
width: Int,
height: Int,
@@ -117,6 +122,7 @@ internal fun computeInSampleSize(
return sample.coerceAtLeast(1)
}
/** Normalizes arbitrary picked-image names to the JPEG file name sent upstream. */
internal fun normalizeAttachmentFileName(raw: String): String {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return "image.jpg"

View File

@@ -98,6 +98,7 @@ private val markdownParser: Parser by lazy {
.build()
}
/** Renders gateway/chat Markdown using the restricted mobile-safe feature set. */
@Composable
fun ChatMarkdown(
text: String,
@@ -234,6 +235,7 @@ private fun RenderParagraph(
) {
val standaloneImage = remember(paragraph) { standaloneDataImage(paragraph) }
if (standaloneImage != null) {
// Render a paragraph that is only a data image as media, not as an inline alt label.
InlineBase64Image(base64 = standaloneImage.base64, mimeType = standaloneImage.mimeType)
return
}
@@ -551,6 +553,7 @@ private fun AnnotatedString.Builder.appendLinkNode(
textDecoration = TextDecoration.Underline,
)
if (destination.isEmpty() || !isSafeMarkdownLinkDestination(destination)) {
// Drop unsafe schemes while preserving visible link text.
appendInlineNode(
link.firstChild,
inlineCodeBg = inlineCodeBg,
@@ -575,9 +578,12 @@ private fun isSafeMarkdownLinkDestination(destination: String): Boolean {
runCatching { URI(destination).scheme?.lowercase(Locale.US) }
.getOrNull()
?: return false
// Chat markdown links are user/model supplied; keep navigation limited to
// browser-safe web URLs instead of custom Android intents or file URLs.
return scheme == "http" || scheme == "https"
}
/** Builds styled inline markdown for compact chat labels and preview text. */
internal fun buildChatInlineMarkdown(
text: String,
linkColor: Color = Color.Blue,
@@ -615,9 +621,11 @@ private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
return parseDataImageDestination(only.destination)
}
/** Parses a data:image Markdown destination when it is safe to render inline. */
internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
val raw = destination?.trim().orEmpty()
if (raw.isEmpty()) return null
// Bound the full URI before regex parsing so pasted data images cannot allocate huge match buffers.
if (raw.length > CHAT_IMAGE_MAX_BASE64_CHARS + DATA_IMAGE_HEADER_MAX_CHARS) return null
val match = dataImageRegex.matchEntire(raw) ?: return null
val subtype =
@@ -661,6 +669,9 @@ private data class TableRenderRow(
val cells: List<AnnotatedString>,
)
/**
* Parsed bounded data-image payload for chat markdown rendering.
*/
internal data class ParsedDataImage(
val mimeType: String,
val base64: String,

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/** Renders chat history newest-first while preserving stable scroll behavior during streaming. */
@Composable
fun ChatMessageListCard(
messages: List<ChatMessage>,

View File

@@ -52,6 +52,7 @@ private data class ChatBubbleStyle(
val roleColor: Color,
)
/** Renders one persisted chat message as text and image parts. */
@Composable
fun ChatMessageBubble(message: ChatMessage) {
val role = message.role.trim().lowercase(Locale.US)
@@ -129,6 +130,7 @@ private fun ChatMessageBody(
}
}
/** Assistant placeholder shown while a run is active but no text has streamed yet. */
@Composable
fun ChatTypingIndicatorBubble() {
ChatBubbleContainer(
@@ -145,6 +147,7 @@ fun ChatTypingIndicatorBubble() {
}
}
/** Tool progress bubble resolved through Android's tool display registry. */
@Composable
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
val context = LocalContext.current
@@ -188,6 +191,7 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
}
}
/** Live assistant stream bubble shown before the final message is committed. */
@Composable
fun ChatStreamingAssistantBubble(text: String) {
ChatBubbleContainer(
@@ -281,6 +285,7 @@ private fun PulseDot(
) {}
}
/** Shared code block renderer used by chat Markdown. */
@Composable
fun ChatCodeBlock(
code: String,

View File

@@ -74,6 +74,7 @@ import java.text.DateFormat
import java.util.Date
import java.util.Locale
/** Full chat surface that wires MainViewModel state to messages, attachments, voice, and composer actions. */
@Composable
fun ChatScreen(
viewModel: MainViewModel,
@@ -452,6 +453,7 @@ private data class StarterPrompt(
val message: String,
)
/** Default prompts shown only for an empty, connected session. */
private val starterPrompts =
listOf(
StarterPrompt(mark = "1", title = "Catch me up", subtitle = "Summarize recent sessions and next steps.", message = "Catch me up on my recent OpenClaw sessions and suggest next steps."),
@@ -820,6 +822,7 @@ private fun userFacingChatError(error: String): String {
}
}
/** Normalizes persisted thinking values into compact UI labels. */
private fun thinkingDisplay(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "Low"
@@ -828,6 +831,7 @@ private fun thinkingDisplay(value: String): String =
else -> "Off"
}
/** Converts displayed thinking labels back to gateway request values. */
private fun thinkingValue(display: String): String =
when (display.lowercase(Locale.US)) {
"low" -> "low"
@@ -836,6 +840,7 @@ private fun thinkingValue(display: String): String =
else -> "off"
}
/** Cycles through context budget presets from the compact composer control. */
private fun nextThinkingValue(value: String): String =
when (value.lowercase(Locale.US)) {
"off" -> "low"
@@ -844,6 +849,7 @@ private fun nextThinkingValue(value: String): String =
else -> "off"
}
/** Maps thinking presets to the visual context meter fill fraction. */
private fun thinkingMeterWidth(value: String): Float =
when (value.lowercase(Locale.US)) {
"low" -> 0.34f
@@ -856,6 +862,7 @@ private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 10
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
/** Quick markdown detector used to avoid routing plain chat text through the markdown renderer. */
private fun String.hasMarkdownSyntax(): Boolean =
any { it == '#' || it == '*' || it == '`' || it == '[' || it == '|' } ||
contains("\n- ") ||

View File

@@ -46,6 +46,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Returns a pending assistant prompt only when chat can accept it immediately. */
internal fun resolvePendingAssistantAutoSend(
pendingPrompt: String?,
healthOk: Boolean,
@@ -56,6 +57,7 @@ internal fun resolvePendingAssistantAutoSend(
return prompt
}
/** Dispatches a pending assistant prompt once and reports whether it was accepted. */
internal suspend fun dispatchPendingAssistantAutoSend(
pendingPrompt: String?,
healthOk: Boolean,
@@ -71,6 +73,7 @@ internal suspend fun dispatchPendingAssistantAutoSend(
return dispatch(prompt)
}
/** Chooses the session key to load for initial chat hydration, if any. */
internal fun resolveInitialChatLoadSessionKey(
sessionKey: String,
mainSessionKey: String,
@@ -81,6 +84,7 @@ internal fun resolveInitialChatLoadSessionKey(
return main
}
/** Main Android chat sheet content: session picker, message list, and composer. */
@Composable
fun ChatSheetContent(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState()
@@ -105,6 +109,8 @@ fun ChatSheetContent(viewModel: MainViewModel) {
}
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
// Assistant-launch prompts should wait for a healthy idle chat so they do
// not race an already-running turn.
val accepted =
dispatchPendingAssistantAutoSend(
pendingPrompt = pendingAssistantAutoSend,
@@ -131,6 +137,8 @@ fun ChatSheetContent(viewModel: MainViewModel) {
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
// Bound both count and encoded size before attachments enter Compose
// state; sending uses these already-compressed payloads directly.
val next =
uris.take(8).mapNotNull { uri ->
try {
@@ -265,6 +273,9 @@ private fun ChatErrorRail(errorText: String) {
}
}
/**
* Image selected in the composer and held in memory until the next chat.send call.
*/
data class PendingImageAttachment(
val id: String,
val fileName: String,

View File

@@ -32,6 +32,7 @@ fun friendlySessionName(key: String): String {
return result.ifBlank { key }
}
/** Builds the selectable recent-session list while preserving the active session. */
fun resolveSessionChoices(
currentSessionKey: String,
sessions: List<ChatSessionEntry>,
@@ -46,6 +47,7 @@ fun resolveSessionChoices(
val recent = mutableListOf<ChatSessionEntry>()
val seen = mutableSetOf<String>()
for (entry in sorted) {
// Hide the legacy main alias when the gateway has supplied a canonical main session key.
if (aliasKey != null && entry.key == aliasKey) continue
if (!seen.add(entry.key)) continue
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
@@ -70,6 +72,7 @@ fun resolveSessionChoices(
}
if (current.isNotEmpty() && !included.contains(current)) {
// Keep the active session selectable even if it is old or missing from the recent list.
result.add(ChatSessionEntry(key = current, updatedAtMs = null))
}

View File

@@ -56,6 +56,7 @@ internal enum class ClawStatus {
Danger,
}
/** Full-screen mobile scaffold that applies OpenClaw safe-area and canvas tokens. */
@Composable
internal fun ClawScaffold(
modifier: Modifier = Modifier,
@@ -74,6 +75,7 @@ internal fun ClawScaffold(
}
}
/** Section title row with an optional trailing action slot. */
@Composable
internal fun ClawSectionHeader(
title: String,
@@ -94,6 +96,7 @@ internal fun ClawSectionHeader(
}
}
/** Primary call-to-action button using the mobile design token set. */
@Composable
internal fun ClawPrimaryButton(
text: String,
@@ -125,6 +128,7 @@ internal fun ClawPrimaryButton(
}
}
/** Secondary action button for non-default commands. */
@Composable
internal fun ClawSecondaryButton(
text: String,
@@ -156,6 +160,7 @@ internal fun ClawSecondaryButton(
}
}
/** Fixed-size circular icon button for toolbar actions. */
@Composable
internal fun ClawIconButton(
icon: ImageVector,
@@ -179,6 +184,7 @@ internal fun ClawIconButton(
}
}
/** Compact status chip with a semantic color dot. */
@Composable
internal fun ClawStatusPill(
text: String,
@@ -217,6 +223,7 @@ internal fun ClawStatusPill(
}
}
/** Small optional-selectable pill used for filters and metadata chips. */
@Composable
internal fun ClawPill(
text: String,
@@ -248,6 +255,7 @@ internal fun ClawPill(
}
}
/** Panel wrapper for homogeneous lists with standard row separators. */
@Composable
internal fun <T> ClawListPanel(
items: List<T>,
@@ -259,6 +267,7 @@ internal fun <T> ClawListPanel(
}
}
/** Column helper that inserts standard dividers between rendered rows. */
@Composable
internal fun <T> ClawSeparatedColumn(
items: List<T>,
@@ -275,6 +284,7 @@ internal fun <T> ClawSeparatedColumn(
}
}
/** Two-line settings/detail row with caller-provided leading and trailing slots. */
@Composable
internal fun ClawDetailRow(
title: String,
@@ -301,6 +311,7 @@ internal fun ClawDetailRow(
}
}
/** Circular text badge used for compact numeric or initials-style row marks. */
@Composable
internal fun ClawTextBadge(
text: String,
@@ -319,6 +330,7 @@ internal fun ClawTextBadge(
}
}
/** Circular icon badge used as a neutral leading marker in list rows. */
@Composable
internal fun ClawIconBadge(
icon: ImageVector,
@@ -337,6 +349,7 @@ internal fun ClawIconBadge(
}
}
/** Reusable one-line list row with optional subtitle, metadata, slots, and click handling. */
@Composable
internal fun ClawListItem(
title: String,
@@ -390,6 +403,7 @@ internal fun ClawListItem(
}
}
/** Equal-width segmented control for small mode/filter sets. */
@Composable
internal fun ClawSegmentedControl(
options: List<String>,
@@ -429,6 +443,7 @@ internal fun ClawSegmentedControl(
}
}
/** Token-styled text field used by settings and prototype screens. */
@Composable
internal fun ClawTextField(
value: String,
@@ -461,6 +476,7 @@ internal fun ClawTextField(
)
}
/** Local design-system preview surface for visual smoke checks. */
@Composable
internal fun ClawComponentShowcase(modifier: Modifier = Modifier) {
var selected by rememberSaveable { mutableStateOf("Chat") }

View File

@@ -27,6 +27,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Stable bottom-navigation destination descriptor.
*/
@Immutable
internal data class ClawNavItem(
val key: String,
@@ -34,6 +37,9 @@ internal data class ClawNavItem(
val icon: ImageVector,
)
/**
* Compact app bar that keeps title, optional subtitle, navigation, and actions aligned.
*/
@Composable
internal fun ClawTopBar(
title: String,
@@ -73,6 +79,9 @@ internal fun ClawTopBar(
}
}
/**
* Bottom navigation shell that applies navigation-bar insets before laying out destinations.
*/
@Composable
internal fun ClawBottomNav(
items: List<ClawNavItem>,
@@ -133,6 +142,9 @@ private fun ClawBottomNavItem(
}
}
/**
* Two-character identity mark for users, agents, or nodes in compact UI rows.
*/
@Composable
internal fun ClawAvatarMark(
text: String,

View File

@@ -10,6 +10,7 @@ import androidx.compose.ui.tooling.preview.Preview
)
@Composable
private fun ClawComponentShowcasePreview() {
// Preview uses the design-system theme directly so token regressions show up in isolation.
ClawDesignTheme {
ClawComponentShowcase()
}

View File

@@ -15,6 +15,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Standard inset panel for grouped Android app content.
*/
@Composable
internal fun ClawPanel(
modifier: Modifier = Modifier,
@@ -34,6 +37,9 @@ internal fun ClawPanel(
}
}
/**
* Bottom-sheet container with the app surface treatment and top-only rounding.
*/
@Composable
internal fun ClawSheetSurface(
modifier: Modifier = Modifier,
@@ -53,6 +59,9 @@ internal fun ClawSheetSurface(
}
}
/**
* Shared empty state used when a screen has no records but can still offer an action.
*/
@Composable
internal fun ClawEmptyState(
title: String,
@@ -73,6 +82,9 @@ internal fun ClawEmptyState(
}
}
/**
* Shared loading placeholder that keeps async screen states visually consistent.
*/
@Composable
internal fun ClawLoadingState(
title: String,
@@ -90,6 +102,9 @@ internal fun ClawLoadingState(
}
}
/**
* Shared recoverable error block with the app's attention styling.
*/
@Composable
internal fun ClawErrorState(
title: String,

View File

@@ -20,6 +20,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* App color tokens consumed by ClawTheme and bridged into Material components.
*/
@Immutable
internal data class ClawColors(
val canvas: Color,
@@ -41,6 +44,9 @@ internal data class ClawColors(
val dangerSoft: Color,
)
/**
* App spacing scale for Compose screens and shared controls.
*/
@Immutable
internal data class ClawSpacing(
val xxxs: Dp = 4.dp,
@@ -54,6 +60,9 @@ internal data class ClawSpacing(
val touchTarget: Dp = 48.dp,
)
/**
* Radius scale for rows, panels, controls, sheets, and status pills.
*/
@Immutable
internal data class ClawRadii(
val row: Dp = 4.dp,
@@ -64,6 +73,9 @@ internal data class ClawRadii(
val pill: Dp = 12.dp,
)
/**
* App text styles kept independent from Material typography names.
*/
@Immutable
internal data class ClawTypography(
val display: TextStyle,
@@ -122,6 +134,9 @@ private val LocalClawSpacing = staticCompositionLocalOf { ClawSpacing() }
private val LocalClawRadii = staticCompositionLocalOf { ClawRadii() }
private val LocalClawTypography = staticCompositionLocalOf { clawTypography(mobileFontFamily) }
/**
* Composition-local access point for OpenClaw Android design tokens.
*/
internal object ClawTheme {
val colors: ClawColors
@Composable
@@ -144,6 +159,9 @@ internal object ClawTheme {
get() = LocalClawTypography.current
}
/**
* Installs OpenClaw design tokens and maps them into MaterialTheme for Material3 controls.
*/
@Composable
internal fun ClawDesignTheme(
dark: Boolean = true,
@@ -167,6 +185,9 @@ internal fun ClawDesignTheme(
}
}
/**
* Returns the system dark-mode preference for callers that expose theme selection.
*/
@Composable
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()

View File

@@ -6,8 +6,10 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal object ChatEventText {
/** Extracts assistant reply text from a gateway chat event payload. */
fun assistantTextFromPayload(payload: JsonObject): String? = assistantTextFromMessage(payload["message"])
/** Extracts text from assistant messages while ignoring non-assistant roles. */
fun assistantTextFromMessage(messageEl: JsonElement?): String? {
val message = messageEl.asObjectOrNull() ?: return null
val role = message["role"].asStringOrNull()
@@ -19,6 +21,8 @@ internal object ChatEventText {
when (content) {
is JsonPrimitive -> content.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
is JsonArray ->
// Gateway content can be either bare strings or text-part objects;
// preserve part ordering when composing the spoken reply.
content
.mapNotNull(::textFromContentPart)
.filter { it.isNotEmpty() }

View File

@@ -26,11 +26,15 @@ import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
import kotlin.coroutines.coroutineContext
/**
* UI transcript role emitted by microphone capture and assistant streaming.
*/
enum class VoiceConversationRole {
User,
Assistant,
}
/** UI transcript entry retained for recent voice turns. */
data class VoiceConversationEntry(
val id: String,
val role: VoiceConversationRole,
@@ -38,6 +42,7 @@ data class VoiceConversationEntry(
val isStreaming: Boolean = false,
)
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
@@ -99,6 +104,7 @@ class MicCaptureManager(
private val messageQueue = ArrayDeque<String>()
private val messageQueueLock = Any()
private var flushedPartialTranscript: String? = null
// Correlates chat events with the idempotency key generated before sendChat returns.
private var pendingRunId: String? = null
private var pendingAssistantEntryId: String? = null
private var gatewayConnected = false
@@ -146,6 +152,7 @@ class MicCaptureManager(
messageQueue.size
}
/** Toggles manual microphone capture, draining partial transcripts when capture turns off. */
fun setMicEnabled(enabled: Boolean) {
if (_micEnabled.value == enabled) return
_micEnabled.value = enabled
@@ -186,6 +193,7 @@ class MicCaptureManager(
}
}
/** Immediately stops capture and drops any unsent partial transcript. */
fun cancelMicCapture() {
transcriptionDrainJob?.cancel()
transcriptionDrainJob = null
@@ -195,6 +203,7 @@ class MicCaptureManager(
stop()
}
/** Pauses capture while local TTS plays so speaker output is not transcribed as user speech. */
suspend fun pauseForTts() {
val shouldPause =
synchronized(ttsPauseLock) {
@@ -216,6 +225,7 @@ class MicCaptureManager(
stopTranscription(preserveStatus = true)
}
/** Resumes capture after all nested TTS playback pauses have completed. */
suspend fun resumeAfterTts() {
val shouldResume =
synchronized(ttsPauseLock) {
@@ -241,6 +251,7 @@ class MicCaptureManager(
sendQueuedIfIdle()
}
/** Starts or stops gateway-dependent capture/send work when the operator session changes state. */
fun onGatewayConnectionChanged(connected: Boolean) {
gatewayConnected = connected
if (connected) {
@@ -267,6 +278,7 @@ class MicCaptureManager(
sendQueuedIfIdle()
}
/** Handles transcription and chat events that update live voice transcript/reply state. */
fun handleGatewayEvent(
event: String,
payloadJson: String?,
@@ -611,6 +623,8 @@ class MicCaptureManager(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
// Drop oldest frames under network backpressure so the live transcription
// session stays close to real time instead of replaying stale audio.
transcriptionAppendJob =
scope.launch(Dispatchers.IO) {
for (frame in audioFrames) {

View File

@@ -13,11 +13,14 @@ import kotlinx.coroutines.withContext
import java.io.File
internal interface TalkAudioPlaying {
/** Plays one assistant reply, replacing any active playback. */
suspend fun play(audio: TalkSpeakAudio)
/** Cancels any active assistant reply playback. */
fun stop()
}
/** Android playback adapter for remote talk.speak audio payloads. */
internal class TalkAudioPlayer(
private val context: Context,
) : TalkAudioPlaying {
@@ -38,6 +41,7 @@ internal class TalkAudioPlayer(
}
}
/** Resolves playback mode from the metadata carried with a talk.speak response. */
internal fun resolvePlaybackMode(audio: TalkSpeakAudio): TalkPlaybackMode =
resolvePlaybackMode(
outputFormat = audio.outputFormat,
@@ -46,6 +50,7 @@ internal class TalkAudioPlayer(
)
companion object {
/** Chooses PCM streaming or MediaPlayer-backed playback from provider metadata. */
internal fun resolvePlaybackMode(
outputFormat: String?,
mimeType: String?,
@@ -173,6 +178,8 @@ internal class TalkAudioPlayer(
bytes: ByteArray,
fileExtension: String,
) {
// MediaPlayer needs a seekable data source for several compressed formats,
// so cache the response bytes briefly instead of streaming from memory.
val tempFile =
withContext(Dispatchers.IO) {
File.createTempFile("talk-audio-", fileExtension, context.cacheDir).apply {
@@ -246,10 +253,12 @@ internal class TalkAudioPlayer(
}
internal sealed interface TalkPlaybackMode {
/** Raw signed 16-bit mono PCM returned by providers that support low-latency output. */
data class Pcm(
val sampleRate: Int,
) : TalkPlaybackMode
/** Compressed audio that Android decodes through MediaPlayer. */
data class Compressed(
val fileExtension: String,
) : TalkPlaybackMode

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.voice
/** Shared Talk-mode timing defaults used by capture, parser, and UI fallback paths. */
internal object TalkDefaults {
const val defaultSilenceTimeoutMs = 700L
}

Some files were not shown because too many files have changed in this diff Show More