mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
75e0053cf9
commit
85beee613c
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(" ")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/**
|
||||
* Persisted voice capture mode that controls foreground-service microphone requirements.
|
||||
*/
|
||||
enum class VoiceCaptureMode {
|
||||
Off,
|
||||
ManualMic,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 == '.'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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- ") ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user