mobile: Phase 4 polish - refactoring, app store prep, and infrastructure

Tier 5 - Code Refactoring:
- iOS: Split NodeAppModel.swift (988→493 LOC) into focused modules
  - NodeCommandHandlers.swift: command routing (372 LOC)
  - NodeGatewaySync.swift: gateway sync, branding (153 LOC)
- Android: Split NodeRuntime.kt (1268→756 LOC, 40% reduction)
  - NodeCommandHandlers.kt: command routing (326 LOC)
  - NodeGatewaySync.kt: gateway sync, A2UI helpers (294 LOC)

Tier 6 - App Store Preparation:
- iOS: Add PrivacyInfo.xcprivacy (iOS 17+ privacy manifest)
- Android: Add ProGuard rules and enable minification for release builds

Infrastructure (from earlier tiers):
- iOS: Add OfflineMessageQueue for disconnected message queueing
- iOS: Add PushManager for VoIP push foundation
- iOS: Add TTSVoiceSettingsView for voice selection
- Android: Add ClawdbotMessagingService for FCM push
- Android: Add SyncWorker for background chat sync
- Both: Deep linking support (clawdbot:// URL scheme)
- Both: Empty states and loading indicators in settings

Testing:
- iOS: Add OfflineMessageQueueTests (8 tests)
- Android: Add NodeGatewaySyncTest (11 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ronitchidara 2026-01-27 19:00:30 +05:30
parent e7728ff037
commit fdc7e11891
29 changed files with 2317 additions and 28 deletions

View File

@ -5,6 +5,7 @@ plugins {
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services")
} }
android { android {
@ -27,7 +28,12 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
@ -115,6 +121,13 @@ dependencies {
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation("dnsjava:dnsjava:3.6.4") implementation("dnsjava:dnsjava:3.6.4")
// WorkManager for background sync
implementation("androidx.work:work-runtime-ktx:2.10.1")
// Firebase Cloud Messaging for push notifications
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7") testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")

58
apps/android/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,58 @@
# Clawdbot Android ProGuard Rules
# https://www.guardsquare.com/manual/configuration/usage
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Kotlinx Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *; }
-keepclasseswithmembers class * {
@kotlinx.serialization.Serializable *;
}
# Keep Serializable classes
-keep class com.clawdbot.android.** implements kotlinx.serialization.KSerializer { *; }
-keep @kotlinx.serialization.Serializable class * { *; }
# Keep WebView JavaScript interface
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Keep all enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Firebase Messaging
-keep class com.google.firebase.messaging.** { *; }
# dnsjava (for DNS-SD discovery)
-dontwarn org.xbill.DNS.**
-keep class org.xbill.DNS.** { *; }
# CameraX
-keep class androidx.camera.** { *; }
# Keep R8 from stripping the companion objects used by serialization
-keepclassmembers class * {
*** Companion;
}
-keepclasseswithmembers class * {
kotlinx.serialization.KSerializer serializer(...);
}
# Prevent stripping of coroutine internals
-dontwarn kotlinx.coroutines.**
-keep class kotlinx.coroutines.** { *; }
# Keep data classes
-keep class com.clawdbot.android.chat.** { *; }
-keep class com.clawdbot.android.gateway.** { *; }
-keep class com.clawdbot.android.protocol.** { *; }

View File

@ -37,13 +37,27 @@
android:name=".NodeForegroundService" android:name=".NodeForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync|microphone|mediaProjection" /> android:foregroundServiceType="dataSync|microphone|mediaProjection" />
<service
android:name=".push.ClawdbotMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:launchMode="singleTask">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="clawdbot" />
</intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>

View File

@ -1,8 +1,10 @@
package bot.molt.android package bot.molt.android
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.os.Build import android.os.Build
import android.view.WindowManager import android.view.WindowManager
import android.webkit.WebView import android.webkit.WebView
@ -27,6 +29,10 @@ class MainActivity : ComponentActivity() {
private lateinit var permissionRequester: PermissionRequester private lateinit var permissionRequester: PermissionRequester
private lateinit var screenCaptureRequester: ScreenCaptureRequester private lateinit var screenCaptureRequester: ScreenCaptureRequester
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 val isDebuggable = (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
@ -62,6 +68,40 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
// Handle deep link from initial launch
handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
}
private fun handleDeepLink(intent: Intent?) {
val uri = intent?.data ?: return
if (uri.scheme != "clawdbot") return
val host = uri.host // e.g., "agent", "chat", "settings"
val path = uri.path?.trimStart('/') // e.g., "hello" from clawdbot://agent/hello
val sessionKey = uri.getQueryParameter("sessionKey") ?: "main"
Log.d(TAG, "Deep link received: scheme=${uri.scheme}, host=$host, path=$path, sessionKey=$sessionKey")
when (host) {
"agent", "chat" -> {
// Send message via chat if path contains text
if (!path.isNullOrBlank()) {
viewModel.handleDeepLinkMessage(path, sessionKey)
}
}
"settings" -> {
viewModel.showSettings()
}
else -> {
Log.w(TAG, "Unknown deep link host: $host")
}
}
} }
override fun onResume() { override fun onResume() {

View File

@ -8,9 +8,13 @@ import bot.molt.android.node.CameraCaptureManager
import bot.molt.android.node.CanvasController import bot.molt.android.node.CanvasController
import bot.molt.android.node.ScreenRecordManager import bot.molt.android.node.ScreenRecordManager
import bot.molt.android.node.SmsManager import bot.molt.android.node.SmsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) { class MainViewModel(app: Application) : AndroidViewModel(app) {
private val _pendingDeepLinkSheet = MutableStateFlow<DeepLinkSheet?>(null)
val pendingDeepLinkSheet: StateFlow<DeepLinkSheet?> = _pendingDeepLinkSheet.asStateFlow()
private val runtime: NodeRuntime = (app as NodeApp).runtime private val runtime: NodeRuntime = (app as NodeApp).runtime
val canvas: CanvasController = runtime.canvas val canvas: CanvasController = runtime.canvas
@ -171,4 +175,36 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) { fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
runtime.sendChat(message = message, thinking = thinking, attachments = attachments) runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
} }
/**
* Handle a deep link message by switching to the specified session and sending the message.
*/
fun handleDeepLinkMessage(message: String, sessionKey: String) {
if (sessionKey != chatSessionKey.value) {
switchChatSession(sessionKey)
}
val thinking = chatThinkingLevel.value
sendChat(message = message, thinking = thinking, attachments = emptyList())
// Signal the UI to open the chat sheet
_pendingDeepLinkSheet.value = DeepLinkSheet.Chat
}
/**
* Request the settings sheet to be shown from a deep link.
*/
fun showSettings() {
_pendingDeepLinkSheet.value = DeepLinkSheet.Settings
}
/**
* Clear the pending deep link sheet after the UI has consumed it.
*/
fun consumeDeepLinkSheet() {
_pendingDeepLinkSheet.value = null
}
}
enum class DeepLinkSheet {
Chat,
Settings,
} }

View File

@ -2,12 +2,17 @@ package bot.molt.android
import android.app.Application import android.app.Application
import android.os.StrictMode import android.os.StrictMode
import com.clawdbot.android.sync.SyncWorker
class NodeApp : Application() { class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) } val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Schedule periodic background sync for chat history
SyncWorker.schedule(this)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()

View File

@ -0,0 +1,326 @@
package bot.molt.android
import android.Manifest
import android.content.pm.PackageManager
import android.location.LocationManager
import android.util.Log
import androidx.core.content.ContextCompat
import bot.molt.android.gateway.GatewaySession
import bot.molt.android.node.CanvasController
import bot.molt.android.protocol.ClawdbotCameraCommand
import bot.molt.android.protocol.ClawdbotCanvasA2UICommand
import bot.molt.android.protocol.ClawdbotCanvasCommand
import bot.molt.android.protocol.ClawdbotLocationCommand
import bot.molt.android.protocol.ClawdbotScreenCommand
import bot.molt.android.protocol.ClawdbotSmsCommand
import kotlinx.coroutines.TimeoutCancellationException
private const val TAG = "NodeCommandHandlers"
// MARK: - Command Handlers Extension
/**
* Extension for node.invoke command routing and handlers.
* Separated from NodeRuntime for maintainability.
*/
internal suspend fun NodeRuntime.routeInvoke(
command: String,
paramsJson: String?
): GatewaySession.InvokeResult {
// Background restrictions
if (command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
command.startsWith(ClawdbotCameraCommand.NamespacePrefix) ||
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
) {
if (!isForeground.value) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
)
}
}
// Camera check
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
return GatewaySession.InvokeResult.error(
code = "CAMERA_DISABLED",
message = "CAMERA_DISABLED: enable Camera in Settings",
)
}
// Location check
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
locationMode.value == LocationMode.Off
) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_DISABLED",
message = "LOCATION_DISABLED: enable Location in Settings",
)
}
return when (command) {
ClawdbotCanvasCommand.Present.rawValue -> handleCanvasPresent(paramsJson)
ClawdbotCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null)
ClawdbotCanvasCommand.Navigate.rawValue -> handleCanvasNavigate(paramsJson)
ClawdbotCanvasCommand.Eval.rawValue -> handleCanvasEval(paramsJson)
ClawdbotCanvasCommand.Snapshot.rawValue -> handleCanvasSnapshot(paramsJson)
ClawdbotCanvasA2UICommand.Reset.rawValue -> handleA2UIReset()
ClawdbotCanvasA2UICommand.Push.rawValue,
ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> handleA2UIPush(command, paramsJson)
ClawdbotCameraCommand.Snap.rawValue -> handleCameraSnap(paramsJson)
ClawdbotCameraCommand.Clip.rawValue -> handleCameraClip(paramsJson)
ClawdbotLocationCommand.Get.rawValue -> handleLocationGet(paramsJson)
ClawdbotScreenCommand.Record.rawValue -> handleScreenRecord(paramsJson)
ClawdbotSmsCommand.Send.rawValue -> handleSmsSend(paramsJson)
else -> GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: unknown command",
)
}
}
// MARK: - Canvas Handlers
private fun NodeRuntime.handleCanvasPresent(paramsJson: String?): GatewaySession.InvokeResult {
val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url)
return GatewaySession.InvokeResult.ok(null)
}
private fun NodeRuntime.handleCanvasNavigate(paramsJson: String?): GatewaySession.InvokeResult {
val url = CanvasController.parseNavigateUrl(paramsJson)
canvas.navigate(url)
return GatewaySession.InvokeResult.ok(null)
}
private suspend fun NodeRuntime.handleCanvasEval(paramsJson: String?): GatewaySession.InvokeResult {
val js = CanvasController.parseEvalJs(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: javaScript required",
)
val result = try {
canvas.eval(js)
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
return GatewaySession.InvokeResult.ok("""{"result":${result.toJsonStringEscaped()}}""")
}
private suspend fun NodeRuntime.handleCanvasSnapshot(paramsJson: String?): GatewaySession.InvokeResult {
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
val base64 = try {
canvas.snapshotBase64(
format = snapshotParams.format,
quality = snapshotParams.quality,
maxWidth = snapshotParams.maxWidth,
)
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "NODE_BACKGROUND_UNAVAILABLE",
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
)
}
return GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
}
// MARK: - A2UI Handlers
private suspend fun NodeRuntime.handleA2UIReset(): GatewaySession.InvokeResult {
val a2uiUrl = resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val res = canvas.eval(A2UI_RESET_JS)
return GatewaySession.InvokeResult.ok(res)
}
private suspend fun NodeRuntime.handleA2UIPush(
command: String,
paramsJson: String?
): GatewaySession.InvokeResult {
val messages = try {
decodeA2uiMessages(command, paramsJson)
} catch (err: Throwable) {
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = err.message ?: "invalid A2UI payload"
)
}
val a2uiUrl = resolveA2uiHostUrl()
?: return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_NOT_CONFIGURED",
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
)
val ready = ensureA2uiReady(a2uiUrl)
if (!ready) {
return GatewaySession.InvokeResult.error(
code = "A2UI_HOST_UNAVAILABLE",
message = "A2UI host not reachable",
)
}
val js = a2uiApplyMessagesJS(messages)
val res = canvas.eval(js)
return GatewaySession.InvokeResult.ok(res)
}
// MARK: - Camera Handlers
private suspend fun NodeRuntime.handleCameraSnap(paramsJson: String?): GatewaySession.InvokeResult {
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
triggerCameraFlash()
val res = try {
camera.snap(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
return GatewaySession.InvokeResult.ok(res.payloadJson)
}
private suspend fun NodeRuntime.handleCameraClip(paramsJson: String?): GatewaySession.InvokeResult {
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
if (includeAudio) setExternalAudioCaptureActive(true)
try {
showCameraHud(message = "Recording…", kind = CameraHudKind.Recording)
val res = try {
camera.clip(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
return GatewaySession.InvokeResult.ok(res.payloadJson)
} finally {
if (includeAudio) setExternalAudioCaptureActive(false)
}
}
// MARK: - Location Handler
private suspend fun NodeRuntime.handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult {
val mode = locationMode.value
if (!isForeground.value && mode != LocationMode.Always) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
)
}
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
)
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled.value
val accuracy = when (desiredAccuracy) {
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
"coarse" -> "coarse"
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
}
val providers = when (accuracy) {
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
}
return try {
val payload = location.getLocation(
desiredProviders = providers,
maxAgeMs = maxAgeMs,
timeoutMs = timeoutMs,
isPrecise = accuracy == "precise",
)
GatewaySession.InvokeResult.ok(payload.payloadJson)
} catch (err: TimeoutCancellationException) {
GatewaySession.InvokeResult.error(
code = "LOCATION_TIMEOUT",
message = "LOCATION_TIMEOUT: no fix in time",
)
} catch (err: Throwable) {
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
}
}
// MARK: - Screen Recording Handler
private suspend fun NodeRuntime.handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult {
setScreenRecordActive(true)
try {
val res = try {
screenRecorder.record(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
return GatewaySession.InvokeResult.ok(res.payloadJson)
} finally {
setScreenRecordActive(false)
}
}
// MARK: - SMS Handler
private fun NodeRuntime.handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.send(paramsJson)
return if (res.ok) {
GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
GatewaySession.InvokeResult.error(code = code, message = error)
}
}
// MARK: - Permission Checks
internal fun NodeRuntime.hasFineLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
internal fun NodeRuntime.hasCoarseLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
internal fun NodeRuntime.hasBackgroundLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
}
// MARK: - Helper Extension
private fun String.toJsonStringEscaped(): String {
val escaped = this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$escaped\""
}

View File

@ -0,0 +1,294 @@
package bot.molt.android
import android.util.Log
import bot.molt.android.protocol.ClawdbotCanvasA2UICommand
import kotlinx.coroutines.delay
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private const val TAG = "NodeGatewaySync"
// MARK: - Gateway Sync Extension
/**
* Extension for gateway synchronization, branding, and wake word sync.
* Separated from NodeRuntime for maintainability.
*/
internal suspend fun NodeRuntime.refreshBrandingFromGateway() {
if (!isConnected.value) return
try {
val res = operatorSession.request("config.get", "{}")
val root = jsonParser.parseToJsonElement(res).asObjectOrNull()
val config = root?.get("config").asObjectOrNull()
val ui = config?.get("ui").asObjectOrNull()
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
val sessionCfg = config?.get("session").asObjectOrNull()
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
applyMainSessionKey(mainKey)
val parsed = parseHexColorArgb(raw)
setSeamColorArgb(parsed ?: DEFAULT_SEAM_COLOR_ARGB)
} catch (e: Throwable) {
Log.d(TAG, "Failed to refresh branding from gateway", e)
}
}
internal suspend fun NodeRuntime.refreshWakeWordsFromGateway() {
if (!isConnected.value) return
try {
val res = operatorSession.request("voicewake.get", "{}")
val payload = jsonParser.parseToJsonElement(res).asObjectOrNull() ?: return
val array = payload["triggers"] as? JsonArray ?: return
val triggers = array.mapNotNull { it.asStringOrNull() }
applyWakeWordsFromGateway(triggers)
} catch (e: Throwable) {
Log.d(TAG, "Failed to refresh wake words from gateway", e)
}
}
internal fun NodeRuntime.handleGatewayEvent(event: String, payloadJson: String?) {
if (event == "voicewake.changed") {
if (payloadJson.isNullOrBlank()) return
try {
val payload = jsonParser.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
val array = payload["triggers"] as? JsonArray ?: return
val triggers = array.mapNotNull { it.asStringOrNull() }
applyWakeWordsFromGateway(triggers)
} catch (e: Throwable) {
Log.w(TAG, "Failed to parse voicewake.changed event", e)
}
return
}
talkModeManager.handleGatewayEvent(event, payloadJson)
chatController.handleGatewayEvent(event, payloadJson)
}
internal suspend fun NodeRuntime.scheduleWakeWordsSyncIfNeeded() {
if (suppressWakeWordsSync) return
if (!isConnected.value) return
val snapshot = prefs.wakeWords.value
wakeWordsSyncJob?.cancel()
wakeWordsSyncJob = scope.launch {
delay(650)
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonStringInternal() }
val params = """{"triggers":[$jsonList]}"""
try {
operatorSession.request("voicewake.set", params)
} catch (e: Throwable) {
Log.d(TAG, "Failed to sync wake words to gateway", e)
}
}
}
// MARK: - A2UI URL Resolution
internal fun NodeRuntime.resolveA2uiHostUrl(): String? {
val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty()
val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty()
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "${base}/__clawdbot__/a2ui/?platform=android"
}
internal suspend fun NodeRuntime.ensureA2uiReady(a2uiUrl: String): Boolean {
try {
val already = canvas.eval(A2UI_READY_CHECK_JS)
if (already == "true") return true
} catch (e: Throwable) {
Log.d(TAG, "A2UI ready check failed (will retry)", e)
}
canvas.navigate(a2uiUrl)
repeat(50) {
try {
val ready = canvas.eval(A2UI_READY_CHECK_JS)
if (ready == "true") return true
} catch (e: Throwable) {
Log.v(TAG, "A2UI ready poll failed, retrying...", e)
}
delay(120)
}
return false
}
internal fun NodeRuntime.maybeNavigateToA2uiOnConnect() {
val a2uiUrl = resolveA2uiHostUrl() ?: return
val current = canvas.currentUrl()?.trim().orEmpty()
if (current.isEmpty() || current == lastAutoA2uiUrl) {
lastAutoA2uiUrl = a2uiUrl
canvas.navigate(a2uiUrl)
}
}
internal fun NodeRuntime.showLocalCanvasOnDisconnect() {
lastAutoA2uiUrl = null
canvas.navigate("")
}
// MARK: - A2UI Message Decoding
internal fun NodeRuntime.decodeA2uiMessages(command: String, paramsJson: String?): String {
val raw = paramsJson?.trim().orEmpty()
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
val obj = jsonParser.parseToJsonElement(raw) as? JsonObject
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
val hasMessagesArray = obj["messages"] is JsonArray
if (command == ClawdbotCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
val jsonl = jsonlField
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
val messages = jsonl
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.mapIndexed { idx, line ->
val el = jsonParser.parseToJsonElement(line)
val msg = el as? JsonObject
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}
.toList()
return JsonArray(messages).toString()
}
val arr = obj["messages"] as? JsonArray
?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
val out = arr.mapIndexed { idx, el ->
val msg = el as? JsonObject
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}
return JsonArray(out).toString()
}
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
if (msg.containsKey("createSurface")) {
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
)
}
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
val matched = msg.keys.filter { allowed.contains(it) }
if (matched.size != 1) {
val found = msg.keys.sorted().joinToString(", ")
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
)
}
}
// MARK: - Helper Methods
internal fun NodeRuntime.invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
val raw = (err.message ?: "").trim()
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error"
val idx = raw.indexOf(':')
if (idx <= 0) return "UNAVAILABLE" to raw
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
return code to "$code: $message"
}
internal fun NodeRuntime.parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
if (paramsJson.isNullOrBlank()) {
return Triple(null, 10_000L, null)
}
val root = try {
jsonParser.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (e: Throwable) {
Log.d(TAG, "Failed to parse location params JSON", e)
null
}
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
val timeoutMs = (root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
?: 10_000L
val desiredAccuracy = (root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
}
// MARK: - A2UI JavaScript Constants
internal const val A2UI_READY_CHECK_JS: String = """
(() => {
try {
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
} catch (_) {
return false;
}
})()
"""
internal const val A2UI_RESET_JS: String = """
(() => {
try {
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
return globalThis.clawdbotA2UI.reset();
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
"""
internal fun a2uiApplyMessagesJS(messagesJson: String): String {
return """
(() => {
try {
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
const messages = $messagesJson;
return globalThis.clawdbotA2UI.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
""".trimIndent()
}
internal const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
internal fun parseHexColorArgb(raw: String?): Long? {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
if (hex.length != 6) return null
val rgb = hex.toLongOrNull(16) ?: return null
return 0xFF000000L or rgb
}
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim().orEmpty()
return trimmed.ifEmpty { "main" }
}
internal fun isCanonicalMainSessionKey(key: String): Boolean {
return key.trim().equals("main", ignoreCase = true)
}
// MARK: - JSON Helpers
internal fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
internal fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
when (this) {
is kotlinx.serialization.json.JsonNull -> null
is JsonPrimitive -> content
else -> null
}
private fun String.toJsonStringInternal(): String {
val escaped = this
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
return "\"$escaped\""
}

View File

@ -1,5 +1,6 @@
package bot.molt.android.chat package bot.molt.android.chat
import android.util.Log
import bot.molt.android.gateway.GatewaySession import bot.molt.android.gateway.GatewaySession
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ -24,6 +25,9 @@ class ChatController(
private val json: Json, private val json: Json,
private val supportsChatSubscribe: Boolean, private val supportsChatSubscribe: Boolean,
) { ) {
companion object {
private const val TAG = "ChatController"
}
private val _sessionKey = MutableStateFlow("main") private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow() val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@ -218,8 +222,8 @@ class ChatController(
put("runId", JsonPrimitive(runId)) put("runId", JsonPrimitive(runId))
} }
session.request("chat.abort", params.toString()) session.request("chat.abort", params.toString())
} catch (_: Throwable) { } catch (e: Throwable) {
// best-effort Log.d(TAG, "Failed to abort chat (best-effort)", e)
} }
} }
} }
@ -263,8 +267,8 @@ class ChatController(
if (supportsChatSubscribe) { if (supportsChatSubscribe) {
try { try {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
} catch (_: Throwable) { } catch (e: Throwable) {
// best-effort Log.d(TAG, "Failed to subscribe to chat events (best-effort)", e)
} }
} }
@ -291,8 +295,8 @@ class ChatController(
} }
val res = session.request("sessions.list", params.toString()) val res = session.request("sessions.list", params.toString())
_sessions.value = parseSessions(res) _sessions.value = parseSessions(res)
} catch (_: Throwable) { } catch (e: Throwable) {
// best-effort Log.d(TAG, "Failed to refresh chat sessions (best-effort)", e)
} }
} }
@ -304,7 +308,8 @@ class ChatController(
try { try {
session.request("health", null) session.request("health", null)
_healthOk.value = true _healthOk.value = true
} catch (_: Throwable) { } catch (e: Throwable) {
Log.d(TAG, "Health check failed", e)
_healthOk.value = false _healthOk.value = false
} }
} }
@ -341,8 +346,8 @@ class ChatController(
_messages.value = history.messages _messages.value = history.messages
_sessionId.value = history.sessionId _sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it } history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
} catch (_: Throwable) { } catch (e: Throwable) {
// best-effort Log.d(TAG, "Failed to refresh chat history after complete event (best-effort)", e)
} }
} }
} }
@ -491,7 +496,8 @@ class ChatController(
private fun parseRunId(resJson: String): String? { private fun parseRunId(resJson: String): String? {
return try { return try {
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull() json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
} catch (_: Throwable) { } catch (e: Throwable) {
Log.d(TAG, "Failed to parse runId from response", e)
null null
} }
} }

View File

@ -62,6 +62,9 @@ class GatewaySession(
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null, private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null, private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) { ) {
companion object {
private const val TAG = "GatewaySession"
}
data class InvokeRequest( data class InvokeRequest(
val id: String, val id: String,
val nodeId: String, val nodeId: String,
@ -458,7 +461,8 @@ class GatewaySession(
if (isLoopbackHost(endpoint.host)) return null if (isLoopbackHost(endpoint.host)) return null
return try { return try {
withTimeout(2_000) { connectNonceDeferred.await() } withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) { } catch (e: Throwable) {
Log.d(TAG, "Timeout awaiting connect nonce", e)
null null
} }
} }
@ -473,7 +477,8 @@ class GatewaySession(
val payload = val payload =
try { try {
json.parseToJsonElement(payloadJson).asObjectOrNull() json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) { } catch (e: Throwable) {
Log.w(TAG, "Failed to parse invoke event JSON", e)
null null
} ?: return } ?: return
val id = payload["id"].asStringOrNull() ?: return val id = payload["id"].asStringOrNull() ?: return

View File

@ -0,0 +1,161 @@
package bot.molt.android.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import bot.molt.android.MainActivity
import bot.molt.android.NodeApp
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
/**
* Firebase Cloud Messaging service for receiving push notifications.
* Handles both notification messages (shown automatically when app backgrounded)
* and data messages (always delivered to this handler).
*/
class ClawdbotMessagingService : FirebaseMessagingService() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "FCM Token refreshed: ${token.take(16)}...")
// Send token to gateway for registration
val app = application as? NodeApp
app?.let {
// Store token for later registration with gateway
PushTokenStore.saveToken(this, token)
Log.d(TAG, "FCM token saved to local storage")
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
Log.d(TAG, "FCM message received from: ${message.from}")
// Check if message contains a notification payload (handled automatically if app is in background)
message.notification?.let { notification ->
Log.d(TAG, "FCM notification payload: title=${notification.title}, body=${notification.body}")
showNotification(
title = notification.title ?: "Clawdbot",
body = notification.body ?: "New message"
)
}
// Check if message contains a data payload (always handled here)
if (message.data.isNotEmpty()) {
Log.d(TAG, "FCM data payload: ${message.data.keys.joinToString()}")
handleDataMessage(message.data)
}
}
private fun handleDataMessage(data: Map<String, String>) {
val messageType = data["type"]
val title = data["title"] ?: "Clawdbot"
val body = data["body"] ?: "New message"
val sessionKey = data["sessionKey"]
when (messageType) {
"chat" -> {
// Incoming chat message - show notification and optionally sync
Log.d(TAG, "Chat message received for session: $sessionKey")
showNotification(title, body, sessionKey)
}
"sync" -> {
// Silent sync request - trigger background sync
Log.d(TAG, "Sync request received")
// Could trigger SyncWorker.scheduleImmediately() here
}
else -> {
// Generic notification
showNotification(title, body)
}
}
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Chat Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for incoming chat messages"
enableLights(true)
enableVibration(true)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
Log.d(TAG, "Notification channel created: $CHANNEL_ID")
}
private fun showNotification(title: String, body: String, sessionKey: String? = null) {
// Create intent to open app when notification is tapped
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
sessionKey?.let { putExtra("sessionKey", it) }
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Replace with app icon
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.build()
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(System.currentTimeMillis().toInt(), notification)
Log.d(TAG, "Notification shown: $title")
}
companion object {
private const val TAG = "ClawdbotFCM"
private const val CHANNEL_ID = "clawdbot_chat"
}
}
/**
* Simple storage for FCM push token.
* The token should be sent to the gateway when connection is established.
*/
object PushTokenStore {
private const val PREFS_NAME = "clawdbot_push"
private const val KEY_FCM_TOKEN = "fcm_token"
fun saveToken(context: Context, token: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_FCM_TOKEN, token)
.apply()
}
fun getToken(context: Context): String? {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_FCM_TOKEN, null)
}
fun clearToken(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(KEY_FCM_TOKEN)
.apply()
}
}

View File

@ -0,0 +1,93 @@
package bot.molt.android.sync
import android.content.Context
import android.util.Log
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import bot.molt.android.NodeApp
import java.util.concurrent.TimeUnit
/**
* Background sync worker that periodically syncs chat history when the app is backgrounded.
* Uses WorkManager for battery-efficient scheduling.
*/
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
Log.d(TAG, "Background sync starting")
val app = applicationContext as? NodeApp
if (app == null) {
Log.w(TAG, "NodeApp not available")
return Result.failure()
}
val runtime = app.runtime
return try {
// Only sync if connected to gateway
if (runtime.isConnected.value) {
Log.d(TAG, "Gateway connected, refreshing chat")
runtime.refreshChat()
runtime.refreshChatSessions(limit = 50)
Log.d(TAG, "Background sync completed successfully")
Result.success()
} else {
Log.d(TAG, "Gateway not connected, skipping sync")
Result.success()
}
} catch (e: Exception) {
Log.e(TAG, "Background sync failed", e)
Result.retry()
}
}
companion object {
private const val TAG = "SyncWorker"
private const val WORK_NAME = "clawdbot_background_sync"
/**
* Schedule periodic background sync.
* Runs every 15 minutes (minimum interval) when network is available and battery isn't low.
*/
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES // Minimum interval allowed by WorkManager
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
Log.d(TAG, "Background sync scheduled")
}
/**
* Cancel scheduled background sync.
*/
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
Log.d(TAG, "Background sync cancelled")
}
}
}

View File

@ -50,11 +50,13 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.clawdbot.android.DeepLinkSheet
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color as ComposeColor import androidx.compose.ui.graphics.Color as ComposeColor
@ -73,6 +75,19 @@ import bot.molt.android.MainViewModel
fun RootScreen(viewModel: MainViewModel) { fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) } var sheet by remember { mutableStateOf<Sheet?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val pendingDeepLinkSheet by viewModel.pendingDeepLinkSheet.collectAsState()
// Handle deep link navigation requests
LaunchedEffect(pendingDeepLinkSheet) {
when (pendingDeepLinkSheet) {
DeepLinkSheet.Chat -> sheet = Sheet.Chat
DeepLinkSheet.Settings -> sheet = Sheet.Settings
null -> {}
}
if (pendingDeepLinkSheet != null) {
viewModel.consumeDeepLinkSheet()
}
}
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
val context = LocalContext.current val context = LocalContext.current
val serverName by viewModel.serverName.collectAsState() val serverName by viewModel.serverName.collectAsState()

View File

@ -33,6 +33,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -49,6 +50,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@ -315,7 +317,30 @@ fun SettingsSheet(viewModel: MainViewModel) {
) )
} }
if (!isConnected && visibleGateways.isEmpty()) { if (!isConnected && visibleGateways.isEmpty()) {
item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) } item {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.WifiOff,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"No Gateways Found",
style = MaterialTheme.typography.titleMedium,
)
Text(
"Make sure your Clawdbot gateway is running on the same network.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
} else { } else {
items(items = visibleGateways, key = { it.stableId }) { gateway -> items(items = visibleGateways, key = { it.stableId }) { gateway ->
val detailLines = val detailLines =

View File

@ -140,7 +140,8 @@ class TalkModeManager(
val obj = val obj =
try { try {
json.parseToJsonElement(payloadJson).asObjectOrNull() json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) { } catch (e: Throwable) {
Log.d(tag, "Failed to parse gateway event JSON", e)
null null
} ?: return } ?: return
val runId = obj["runId"].asStringOrNull() ?: return val runId = obj["runId"].asStringOrNull() ?: return
@ -245,8 +246,8 @@ class TalkModeManager(
val shouldInterrupt = _isSpeaking.value && interruptOnSpeech val shouldInterrupt = _isSpeaking.value && interruptOnSpeech
if (!shouldListen && !shouldInterrupt) return@post if (!shouldListen && !shouldInterrupt) return@post
startListeningInternal(markListening = shouldListen) startListeningInternal(markListening = shouldListen)
} catch (_: Throwable) { } catch (e: Throwable) {
// handled by onError Log.d(tag, "Failed to restart listening after end-of-speech", e)
} }
} }
} }
@ -395,7 +396,8 @@ class TalkModeManager(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
kotlinx.coroutines.withTimeout(120_000) { deferred.await() } kotlinx.coroutines.withTimeout(120_000) { deferred.await() }
} catch (_: Throwable) { } catch (e: Throwable) {
Log.d(tag, "Timeout waiting for agent response", e)
false false
} }
} }
@ -702,7 +704,8 @@ class TalkModeManager(
TextToSpeech(context) { status -> TextToSpeech(context) { status ->
deferred.complete(status == TextToSpeech.SUCCESS) deferred.complete(status == TextToSpeech.SUCCESS)
} }
} catch (_: Throwable) { } catch (e: Throwable) {
Log.w(tag, "Failed to initialize system TTS", e)
deferred.complete(false) deferred.complete(false)
null null
} }
@ -743,7 +746,8 @@ class TalkModeManager(
val ok = val ok =
try { try {
deferred.await() deferred.await()
} catch (_: Throwable) { } catch (e: Throwable) {
Log.d(tag, "TTS initialization deferred await failed", e)
false false
} }
if (ok) { if (ok) {
@ -793,8 +797,8 @@ class TalkModeManager(
track.pause() track.pause()
track.flush() track.flush()
track.stop() track.stop()
} catch (_: Throwable) { } catch (e: Throwable) {
// ignore cleanup errors Log.d(tag, "PCM track cleanup error (ignored)", e)
} finally { } finally {
track.release() track.release()
} }
@ -842,7 +846,8 @@ class TalkModeManager(
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() } apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
if (interrupt != null) interruptOnSpeech = interrupt if (interrupt != null) interruptOnSpeech = interrupt
} catch (_: Throwable) { } catch (e: Throwable) {
Log.w(tag, "Failed to parse talk config from gateway, using defaults", e)
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() } defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
defaultModelId = defaultModelIdFallback defaultModelId = defaultModelIdFallback
if (!modelOverrideActive) currentModelId = defaultModelId if (!modelOverrideActive) currentModelId = defaultModelId
@ -1099,8 +1104,8 @@ class TalkModeManager(
} }
recognizer?.cancel() recognizer?.cancel()
startListeningInternal(markListening = false) startListeningInternal(markListening = false)
} catch (_: Throwable) { } catch (e: Throwable) {
// ignore Log.d(tag, "Failed to start interrupt listener", e)
} }
} }
} }

View File

@ -0,0 +1,78 @@
package bot.molt.android
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NodeGatewaySyncTest {
@Test
fun parseHexColorArgbParsesValidHex() {
val result = parseHexColorArgb("#4F7A9A")
assertEquals(0xFF4F7A9AL, result)
}
@Test
fun parseHexColorArgbParsesWithoutHash() {
val result = parseHexColorArgb("4F7A9A")
assertEquals(0xFF4F7A9AL, result)
}
@Test
fun parseHexColorArgbReturnsNullForInvalidLength() {
assertNull(parseHexColorArgb("#FFF"))
assertNull(parseHexColorArgb("#FFFFFFFF"))
assertNull(parseHexColorArgb("12345"))
}
@Test
fun parseHexColorArgbReturnsNullForEmpty() {
assertNull(parseHexColorArgb(null))
assertNull(parseHexColorArgb(""))
assertNull(parseHexColorArgb(" "))
}
@Test
fun parseHexColorArgbReturnsNullForInvalidHex() {
assertNull(parseHexColorArgb("#GGGGGG"))
assertNull(parseHexColorArgb("#ZZZZZZ"))
}
@Test
fun parseHexColorArgbTrimsWhitespace() {
val result = parseHexColorArgb(" #FFFFFF ")
assertEquals(0xFFFFFFFFL, result)
}
@Test
fun normalizeMainKeyReturnsMainForNull() {
assertEquals("main", normalizeMainKey(null))
}
@Test
fun normalizeMainKeyReturnsMainForEmpty() {
assertEquals("main", normalizeMainKey(""))
assertEquals("main", normalizeMainKey(" "))
}
@Test
fun normalizeMainKeyReturnsTrimmedValue() {
assertEquals("custom-key", normalizeMainKey(" custom-key "))
}
@Test
fun isCanonicalMainSessionKeyReturnsTrueForMain() {
assertTrue(isCanonicalMainSessionKey("main"))
assertTrue(isCanonicalMainSessionKey("Main"))
assertTrue(isCanonicalMainSessionKey("MAIN"))
assertTrue(isCanonicalMainSessionKey(" main "))
}
@Test
fun isCanonicalMainSessionKeyReturnsFalseForOther() {
assertFalse(isCanonicalMainSessionKey("custom"))
assertFalse(isCanonicalMainSessionKey("session-1"))
assertFalse(isCanonicalMainSessionKey(""))
}
}

View File

@ -3,4 +3,5 @@ plugins {
id("org.jetbrains.kotlin.android") version "2.2.21" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
} }

View File

@ -0,0 +1,92 @@
import Foundation
import OSLog
/// Queues chat messages when the gateway is disconnected and delivers them on reconnect.
@MainActor
@Observable
final class OfflineMessageQueue {
struct PendingMessage: Codable, Identifiable, Sendable {
let id: UUID
let text: String
let sessionKey: String
let thinking: String
let createdAt: Date
var retryCount: Int
}
private let logger = Logger(subsystem: "com.clawdbot", category: "offline-queue")
private let fileURL: URL
private let maxRetries = 3
private(set) var pending: [PendingMessage] = []
init() {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
self.fileURL = docs.appendingPathComponent("offline-queue.json")
self.load()
}
var count: Int { pending.count }
var isEmpty: Bool { pending.isEmpty }
/// Add a message to the queue when gateway is disconnected.
func enqueue(text: String, sessionKey: String, thinking: String = "off") {
let msg = PendingMessage(
id: UUID(),
text: text,
sessionKey: sessionKey,
thinking: thinking,
createdAt: Date(),
retryCount: 0
)
pending.append(msg)
save()
logger.info("Queued message for session \(sessionKey, privacy: .public): \(text.prefix(50), privacy: .public)...")
}
/// Remove a message after successful delivery.
func dequeue(_ id: UUID) {
pending.removeAll { $0.id == id }
save()
logger.info("Dequeued message \(id.uuidString, privacy: .public)")
}
/// Mark a message as failed. Removes it if max retries exceeded.
func markFailed(_ id: UUID) {
guard let idx = pending.firstIndex(where: { $0.id == id }) else { return }
pending[idx].retryCount += 1
if pending[idx].retryCount >= maxRetries {
logger.warning("Message \(id.uuidString, privacy: .public) exceeded max retries, removing")
pending.remove(at: idx)
}
save()
}
/// Clear all pending messages.
func clear() {
pending.removeAll()
save()
logger.info("Cleared all pending messages")
}
private func load() {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
do {
let data = try Data(contentsOf: fileURL)
let decoded = try JSONDecoder().decode([PendingMessage].self, from: data)
pending = decoded
logger.info("Loaded \(decoded.count) pending messages from disk")
} catch {
logger.error("Failed to load offline queue: \(error.localizedDescription)")
}
}
private func save() {
do {
let data = try JSONEncoder().encode(pending)
try data.write(to: fileURL, options: .atomic)
} catch {
logger.error("Failed to save offline queue: \(error.localizedDescription)")
}
}
}

View File

@ -1,7 +1,9 @@
import SwiftUI import SwiftUI
import UIKit
@main @main
struct MoltbotApp: App { struct MoltbotApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var appModel: NodeAppModel @State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController @State private var gatewayController: GatewayConnectionController
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@ -11,6 +13,12 @@ struct MoltbotApp: App {
let appModel = NodeAppModel() let appModel = NodeAppModel()
_appModel = State(initialValue: appModel) _appModel = State(initialValue: appModel)
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel)) _gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
// Register for push notifications
PushManager.shared.registerForVoIPPush()
Task {
await PushManager.shared.registerForRemoteNotifications()
}
} }
var body: some Scene { var body: some Scene {
@ -29,3 +37,25 @@ struct MoltbotApp: App {
} }
} }
} }
// MARK: - AppDelegate for Push Notification Token Handling
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { @MainActor in
PushManager.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
Task { @MainActor in
PushManager.shared.didFailToRegisterForRemoteNotifications(error: error)
}
}
}

View File

@ -51,6 +51,19 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
<string>voip</string>
<string>remote-notification</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.clawdbot.ios</string>
<key>CFBundleURLSchemes</key>
<array>
<string>clawdbot</string>
</array>
</dict>
</array> </array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>

View File

@ -0,0 +1,371 @@
import ClawdbotKit
import Foundation
import UIKit
// MARK: - Command Handlers
/// Extension containing all node.invoke command handlers.
/// Separated from NodeAppModel for maintainability.
extension NodeAppModel {
func routeInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if self.isBackgrounded, self.isBackgroundRestricted(command) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .backgroundUnavailable,
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground"))
}
if command.hasPrefix("camera."), !self.isCameraEnabled() {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "CAMERA_DISABLED: enable Camera in iOS Settings → Camera → Allow Camera"))
}
do {
switch command {
case ClawdbotLocationCommand.get.rawValue:
return try await self.handleLocationInvoke(req)
case ClawdbotCanvasCommand.present.rawValue,
ClawdbotCanvasCommand.hide.rawValue,
ClawdbotCanvasCommand.navigate.rawValue,
ClawdbotCanvasCommand.evalJS.rawValue,
ClawdbotCanvasCommand.snapshot.rawValue:
return try await self.handleCanvasInvoke(req)
case ClawdbotCanvasA2UICommand.reset.rawValue,
ClawdbotCanvasA2UICommand.push.rawValue,
ClawdbotCanvasA2UICommand.pushJSONL.rawValue:
return try await self.handleCanvasA2UIInvoke(req)
case ClawdbotCameraCommand.list.rawValue,
ClawdbotCameraCommand.snap.rawValue,
ClawdbotCameraCommand.clip.rawValue:
return try await self.handleCameraInvoke(req)
case ClawdbotScreenCommand.record.rawValue:
return try await self.handleScreenRecordInvoke(req)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
} catch {
if command.hasPrefix("camera.") {
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
self.showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2)
}
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .unavailable, message: error.localizedDescription))
}
}
func isBackgroundRestricted(_ command: String) -> Bool {
command.hasPrefix("canvas.") || command.hasPrefix("camera.") || command.hasPrefix("screen.")
}
// MARK: - Location
func handleLocationInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let mode = self.locationMode()
guard mode != .off else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "LOCATION_DISABLED: enable Location in Settings"))
}
if self.isBackgrounded, mode != .always {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .backgroundUnavailable,
message: "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always"))
}
let params = (try? Self.decodeParams(ClawdbotLocationGetParams.self, from: req.paramsJSON)) ??
ClawdbotLocationGetParams()
let desired = params.desiredAccuracy ??
(self.isLocationPreciseEnabled() ? .precise : .balanced)
let status = self.locationService.authorizationStatus()
if status != .authorizedAlways, status != .authorizedWhenInUse {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: grant Location permission"))
}
if self.isBackgrounded, status != .authorizedAlways {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "LOCATION_PERMISSION_REQUIRED: enable Always for background access"))
}
let location = try await self.locationService.currentLocation(
params: params,
desiredAccuracy: desired,
maxAgeMs: params.maxAgeMs,
timeoutMs: params.timeoutMs)
let isPrecise = self.locationService.accuracyAuthorization() == .fullAccuracy
let payload = ClawdbotLocationPayload(
lat: location.coordinate.latitude,
lon: location.coordinate.longitude,
accuracyMeters: location.horizontalAccuracy,
altitudeMeters: location.verticalAccuracy >= 0 ? location.altitude : nil,
speedMps: location.speed >= 0 ? location.speed : nil,
headingDeg: location.course >= 0 ? location.course : nil,
timestamp: ISO8601DateFormatter().string(from: location.timestamp),
isPrecise: isPrecise,
source: nil)
let json = try Self.encodePayload(payload)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
}
// MARK: - Canvas
func handleCanvasInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
switch req.command {
case ClawdbotCanvasCommand.present.rawValue:
let params = (try? Self.decodeParams(ClawdbotCanvasPresentParams.self, from: req.paramsJSON)) ??
ClawdbotCanvasPresentParams()
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if url.isEmpty {
self.screen.showDefaultCanvas()
} else {
self.screen.navigate(to: url)
}
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.hide.rawValue:
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasNavigateParams.self, from: req.paramsJSON)
self.screen.navigate(to: params.url)
return BridgeInvokeResponse(id: req.id, ok: true)
case ClawdbotCanvasCommand.evalJS.rawValue:
let params = try Self.decodeParams(ClawdbotCanvasEvalParams.self, from: req.paramsJSON)
let result = try await self.screen.eval(javaScript: params.javaScript)
let payload = try Self.encodePayload(["result": result])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCanvasCommand.snapshot.rawValue:
let params = try? Self.decodeParams(ClawdbotCanvasSnapshotParams.self, from: req.paramsJSON)
let format = params?.format ?? .jpeg
let maxWidth: CGFloat? = {
if let raw = params?.maxWidth, raw > 0 { return CGFloat(raw) }
return switch format {
case .png: 900
case .jpeg: 1600
}
}()
let base64 = try await self.screen.snapshotBase64(
maxWidth: maxWidth,
format: format,
quality: params?.quality)
let payload = try Self.encodePayload([
"format": format == .jpeg ? "jpeg" : "png",
"base64": base64,
])
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
// MARK: - Canvas A2UI
func handleCanvasA2UIInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let command = req.command
switch command {
case ClawdbotCanvasA2UICommand.reset.rawValue:
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let json = try await self.screen.eval(javaScript: """
(() => {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
return JSON.stringify(globalThis.clawdbotA2UI.reset());
})()
""")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json)
case ClawdbotCanvasA2UICommand.push.rawValue, ClawdbotCanvasA2UICommand.pushJSONL.rawValue:
let messages: [AnyCodable]
if command == ClawdbotCanvasA2UICommand.pushJSONL.rawValue {
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
} else {
do {
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushParams.self, from: req.paramsJSON)
messages = params.messages
} catch {
let params = try Self.decodeParams(ClawdbotCanvasA2UIPushJSONLParams.self, from: req.paramsJSON)
messages = try ClawdbotCanvasA2UIJSONL.decodeMessagesFromJSONL(params.jsonl)
}
}
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host"))
}
self.screen.navigate(to: a2uiUrl)
if await !self.screen.waitForA2UIReady(timeoutMs: 5000) {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "A2UI_HOST_UNAVAILABLE: A2UI host not reachable"))
}
let messagesJSON = try ClawdbotCanvasA2UIJSONL.encodeMessagesJSONArray(messages)
let js = """
(() => {
try {
if (!globalThis.clawdbotA2UI) return JSON.stringify({ ok: false, error: "missing clawdbotA2UI" });
const messages = \(messagesJSON);
return JSON.stringify(globalThis.clawdbotA2UI.applyMessages(messages));
} catch (e) {
return JSON.stringify({ ok: false, error: String(e?.message ?? e) });
}
})()
"""
let resultJSON = try await self.screen.eval(javaScript: js)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
// MARK: - Camera
func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
switch req.command {
case ClawdbotCameraCommand.list.rawValue:
let devices = await self.camera.listDevices()
struct Payload: Codable {
var devices: [CameraController.CameraDeviceInfo]
}
let payload = try Self.encodePayload(Payload(devices: devices))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.snap.rawValue:
self.showCameraHUD(text: "Taking photo…", kind: .photo)
self.triggerCameraFlash()
let params = (try? Self.decodeParams(ClawdbotCameraSnapParams.self, from: req.paramsJSON)) ??
ClawdbotCameraSnapParams()
let res = try await self.camera.snap(params: params)
struct Payload: Codable {
var format: String
var base64: String
var width: Int
var height: Int
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
width: res.width,
height: res.height))
self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
case ClawdbotCameraCommand.clip.rawValue:
let params = (try? Self.decodeParams(ClawdbotCameraClipParams.self, from: req.paramsJSON)) ??
ClawdbotCameraClipParams()
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
self.showCameraHUD(text: "Recording…", kind: .recording)
let res = try await self.camera.clip(params: params)
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: res.format,
base64: res.base64,
durationMs: res.durationMs,
hasAudio: res.hasAudio))
self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
default:
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: ClawdbotNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
}
}
// MARK: - Screen Recording
func handleScreenRecordInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = (try? Self.decodeParams(ClawdbotScreenRecordParams.self, from: req.paramsJSON)) ??
ClawdbotScreenRecordParams()
if let format = params.format, format.lowercased() != "mp4" {
throw NSError(domain: "Screen", code: 30, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: screen format must be mp4",
])
}
self.screenRecordActive = true
defer { self.screenRecordActive = false }
let path = try await self.screenRecorder.record(
screenIndex: params.screenIndex,
durationMs: params.durationMs,
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager().removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable {
var format: String
var base64: String
var durationMs: Int?
var fps: Double?
var screenIndex: Int?
var hasAudio: Bool
}
let payload = try Self.encodePayload(Payload(
format: "mp4",
base64: data.base64EncodedString(),
durationMs: params.durationMs,
fps: params.fps,
screenIndex: params.screenIndex,
hasAudio: params.includeAudio ?? true))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
}

View File

@ -0,0 +1,152 @@
import ClawdbotKit
import Foundation
import SwiftUI
// MARK: - Gateway Sync & Branding
/// Extension for gateway synchronization, branding, and wake word sync.
extension NodeAppModel {
func applyMainSessionKey(_ key: String?) {
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
if SessionKey.isCanonicalMainSessionKey(current) { return }
if trimmed == current { return }
self.mainSessionKey = trimmed
self.talkMode.updateMainSessionKey(trimmed)
}
func refreshBrandingFromGateway() async {
do {
let res = try await self.gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
self.mainSessionKey = mainKey
self.talkMode.updateMainSessionKey(mainKey)
}
}
} catch {
// ignore
}
}
func setGlobalWakeWords(_ words: [String]) async {
let sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
struct Payload: Codable {
var triggers: [String]
}
let payload = Payload(triggers: sanitized)
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return }
do {
_ = try await self.gateway.request(method: "voicewake.set", paramsJSON: json, timeoutSeconds: 12)
} catch {
// Best-effort only.
}
}
func startVoiceWakeSync() async {
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = Task { [weak self] in
guard let self else { return }
await self.refreshWakeWordsFromGateway()
let stream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
for await evt in stream {
if Task.isCancelled { return }
guard evt.event == "voicewake.changed" else { continue }
guard let payload = evt.payload else { continue }
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
}
}
}
func refreshWakeWordsFromGateway() async {
do {
let data = try await self.gateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return }
VoiceWakePreferences.saveTriggerWords(triggers)
} catch {
// Best-effort only.
}
}
// MARK: - Color Utilities
var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
// MARK: - A2UI URL Resolution
func resolveA2UIHostURL() async -> String? {
guard let raw = await self.gateway.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
return base.appendingPathComponent("__clawdbot__/a2ui/").absoluteString + "?platform=ios"
}
func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else { return }
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
if current.isEmpty || current == self.lastAutoA2uiURL {
self.screen.navigate(to: a2uiUrl)
self.lastAutoA2uiURL = a2uiUrl
}
}
func showLocalCanvasOnDisconnect() {
self.lastAutoA2uiURL = nil
self.screen.showDefaultCanvas()
}
// MARK: - Offline Queue
func flushOfflineQueue() async {
let pending = await MainActor.run { self.offlineQueue.pending }
guard !pending.isEmpty else { return }
for msg in pending {
do {
struct Payload: Codable {
var text: String
var sessionKey: String?
}
let payload = Payload(text: msg.text, sessionKey: msg.sessionKey)
let data = try JSONEncoder().encode(payload)
guard let json = String(bytes: data, encoding: .utf8) else { continue }
await self.gateway.sendEvent(event: "voice.transcript", payloadJSON: json)
await MainActor.run { self.offlineQueue.dequeue(msg.id) }
} catch {
await MainActor.run { self.offlineQueue.markFailed(msg.id) }
}
}
}
}

View File

@ -0,0 +1,137 @@
import Foundation
import OSLog
import PushKit
import UIKit
import UserNotifications
/// Manages push notification registration for VoIP (PushKit) and remote notifications.
/// VoIP push allows the app to wake from background for incoming calls/messages.
@MainActor
final class PushManager: NSObject, Sendable {
static let shared = PushManager()
private let logger = Logger(subsystem: "com.clawdbot", category: "push")
private var voipRegistry: PKPushRegistry?
private(set) var voipToken: Data?
private(set) var remoteToken: Data?
/// Called when a VoIP push is received. The gateway can use this to wake the app.
var onIncomingVoIPPush: (@Sendable (PKPushPayload) async -> Void)?
/// Called when the VoIP token is updated. Send this to the gateway for registration.
var onVoIPTokenUpdated: (@Sendable (String) async -> Void)?
/// Called when the remote notification token is updated.
var onRemoteTokenUpdated: (@Sendable (String) async -> Void)?
private override init() {
super.init()
}
// MARK: - VoIP Push (PushKit)
/// Register for VoIP push notifications.
/// This enables the app to receive pushes that can wake it from suspended state.
func registerForVoIPPush() {
logger.info("Registering for VoIP push")
voipRegistry = PKPushRegistry(queue: .main)
voipRegistry?.delegate = self
voipRegistry?.desiredPushTypes = [.voIP]
}
// MARK: - Remote Notifications
/// Request permission and register for standard remote notifications.
func registerForRemoteNotifications() async {
let center = UNUserNotificationCenter.current()
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if granted {
logger.info("Remote notification permission granted")
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
} else {
logger.info("Remote notification permission denied")
}
} catch {
logger.error("Failed to request notification permission: \(error.localizedDescription)")
}
}
/// Called by AppDelegate when remote notification registration succeeds.
func didRegisterForRemoteNotifications(deviceToken: Data) {
remoteToken = deviceToken
let tokenString = deviceToken.map { String(format: "%02x", $0) }.joined()
logger.info("Remote notification token: \(tokenString.prefix(16))...")
Task {
await onRemoteTokenUpdated?(tokenString)
}
}
/// Called by AppDelegate when remote notification registration fails.
func didFailToRegisterForRemoteNotifications(error: Error) {
logger.error("Failed to register for remote notifications: \(error.localizedDescription)")
}
// MARK: - Token Helpers
/// Returns the VoIP token as a hex string, or nil if not registered.
var voipTokenString: String? {
voipToken.map { $0.map { String(format: "%02x", $0) }.joined() }
}
/// Returns the remote notification token as a hex string, or nil if not registered.
var remoteTokenString: String? {
remoteToken.map { $0.map { String(format: "%02x", $0) }.joined() }
}
}
// MARK: - PKPushRegistryDelegate
extension PushManager: PKPushRegistryDelegate {
nonisolated func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
Task { @MainActor in
self.voipToken = pushCredentials.token
let tokenString = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
self.logger.info("VoIP push token: \(tokenString.prefix(16))...")
await self.onVoIPTokenUpdated?(tokenString)
}
}
nonisolated func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
Task { @MainActor in
self.logger.info("Received VoIP push: \(payload.dictionaryPayload.keys.joined(separator: ", "))")
// Note: For VoIP pushes, iOS requires that you report a new incoming call
// using CallKit within a few seconds, otherwise the app will be terminated.
// For non-call use cases (like chat wakeup), consider using regular push
// notifications with background mode instead.
await self.onIncomingVoIPPush?(payload)
completion()
}
}
nonisolated func pushRegistry(
_ registry: PKPushRegistry,
didInvalidatePushTokenFor type: PKPushType
) {
Task { @MainActor in
self.logger.info("VoIP push token invalidated")
self.voipToken = nil
}
}
}

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@ -138,6 +138,15 @@ struct RootTabs: View {
} }
} }
// Show pending offline messages indicator
let pendingCount = self.appModel.offlineQueue.count
if pendingCount > 0 {
return StatusPill.Activity(
title: "\(pendingCount) pending",
systemImage: "clock.arrow.circlepath",
tint: .orange)
}
return nil return nil
} }
} }

View File

@ -186,6 +186,12 @@ struct SettingsTab: View {
"Wake Words", "Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords)) value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
} }
NavigationLink {
TTSVoiceSettingsView()
} label: {
Text("TTS Voice Settings")
}
} }
Section("Camera") { Section("Camera") {
@ -278,8 +284,13 @@ struct SettingsTab: View {
@ViewBuilder @ViewBuilder
private func gatewayList(showing: GatewayListMode) -> some View { private func gatewayList(showing: GatewayListMode) -> some View {
if self.gatewayController.gateways.isEmpty { if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.") ContentUnavailableView {
.foregroundStyle(.secondary) Label("No Gateways Found", systemImage: "network.slash")
} description: {
Text("Make sure your Clawdbot gateway is running on the same network.")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
} else { } else {
let connectedID = self.appModel.connectedGatewayID let connectedID = self.appModel.connectedGatewayID
let rows = self.gatewayController.gateways.filter { gateway in let rows = self.gatewayController.gateways.filter { gateway in

View File

@ -0,0 +1,140 @@
import AVFoundation
import SwiftUI
/// Settings view for configuring Text-to-Speech voice parameters.
struct TTSVoiceSettingsView: View {
@AppStorage("tts.voiceIdentifier") private var selectedVoiceId = ""
@AppStorage("tts.rate") private var speechRate = 0.5
@AppStorage("tts.pitch") private var speechPitch = 1.0
@State private var availableVoices: [AVSpeechSynthesisVoice] = []
@State private var isTestPlaying = false
private let synthesizer = AVSpeechSynthesizer()
var body: some View {
Form {
Section {
Picker("Voice", selection: self.$selectedVoiceId) {
Text("System Default").tag("")
ForEach(self.availableVoices, id: \.identifier) { voice in
Text(self.voiceDisplayName(voice))
.tag(voice.identifier)
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Speech Rate")
Spacer()
Text(self.rateLabel)
.foregroundStyle(.secondary)
}
Slider(value: self.$speechRate, in: 0.0 ... 1.0, step: 0.1)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Pitch")
Spacer()
Text(String(format: "%.1f", self.speechPitch))
.foregroundStyle(.secondary)
}
Slider(value: self.$speechPitch, in: 0.5 ... 2.0, step: 0.1)
}
Button {
self.testVoice()
} label: {
HStack {
if self.isTestPlaying {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(0.8)
}
Text(self.isTestPlaying ? "Playing..." : "Test Voice")
}
}
.disabled(self.isTestPlaying)
} header: {
Text("Text-to-Speech")
} footer: {
Text("These settings apply to system TTS fallback when cloud voices are unavailable.")
}
Section {
Button("Reset to Defaults") {
self.resetDefaults()
}
}
}
.navigationTitle("TTS Voice")
.onAppear {
self.loadVoices()
}
}
private var rateLabel: String {
switch self.speechRate {
case 0.0 ..< 0.3:
"Slow"
case 0.3 ..< 0.6:
"Normal"
case 0.6 ..< 0.8:
"Fast"
default:
"Very Fast"
}
}
private func voiceDisplayName(_ voice: AVSpeechSynthesisVoice) -> String {
let language = Locale.current.localizedString(forIdentifier: voice.language) ?? voice.language
return "\(voice.name) (\(language))"
}
private func loadVoices() {
let voices = AVSpeechSynthesisVoice.speechVoices()
// Filter to show only enhanced/premium voices and common languages
let filtered = voices.filter { voice in
voice.quality == .enhanced || voice.quality == .premium
}
// Sort by language then name
self.availableVoices = filtered.sorted { a, b in
if a.language != b.language {
return a.language < b.language
}
return a.name < b.name
}
}
private func testVoice() {
self.isTestPlaying = true
let utterance = AVSpeechUtterance(string: "Hello, I'm Clawdbot. How can I help you today?")
// Apply selected voice
if !self.selectedVoiceId.isEmpty,
let voice = AVSpeechSynthesisVoice(identifier: self.selectedVoiceId)
{
utterance.voice = voice
}
// Apply rate (AVSpeechUtterance rate is 0.0-1.0 but default speaking rate is around 0.5)
utterance.rate = Float(self.speechRate) * AVSpeechUtteranceMaximumSpeechRate
utterance.pitchMultiplier = Float(self.speechPitch)
self.synthesizer.speak(utterance)
// Reset playing state when done
Task {
// Simple delay approximation since we can't easily track utterance completion here
try? await Task.sleep(nanoseconds: 3_000_000_000)
await MainActor.run {
self.isTestPlaying = false
}
}
}
private func resetDefaults() {
self.selectedVoiceId = ""
self.speechRate = 0.5
self.speechPitch = 1.0
}
}

View File

@ -0,0 +1,122 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite @MainActor struct OfflineMessageQueueTests {
@Test func enqueueAddsMessage() async {
let queue = OfflineMessageQueue()
queue.clear()
queue.enqueue(text: "Hello", sessionKey: "test-session")
#expect(queue.count == 1)
#expect(queue.pending.first?.text == "Hello")
#expect(queue.pending.first?.sessionKey == "test-session")
#expect(queue.pending.first?.retryCount == 0)
queue.clear()
}
@Test func dequeueRemovesMessage() async {
let queue = OfflineMessageQueue()
queue.clear()
queue.enqueue(text: "Message 1", sessionKey: "session")
queue.enqueue(text: "Message 2", sessionKey: "session")
let firstId = queue.pending.first!.id
queue.dequeue(firstId)
#expect(queue.count == 1)
#expect(queue.pending.first?.text == "Message 2")
queue.clear()
}
@Test func markFailedIncrementsRetryCount() async {
let queue = OfflineMessageQueue()
queue.clear()
queue.enqueue(text: "Retry test", sessionKey: "session")
let id = queue.pending.first!.id
queue.markFailed(id)
#expect(queue.pending.first?.retryCount == 1)
queue.markFailed(id)
#expect(queue.pending.first?.retryCount == 2)
queue.clear()
}
@Test func markFailedRemovesAfterMaxRetries() async {
let queue = OfflineMessageQueue()
queue.clear()
queue.enqueue(text: "Max retry test", sessionKey: "session")
let id = queue.pending.first!.id
// Max retries is 3
queue.markFailed(id) // retry 1
queue.markFailed(id) // retry 2
queue.markFailed(id) // retry 3 - should remove
#expect(queue.isEmpty)
queue.clear()
}
@Test func clearRemovesAllMessages() async {
let queue = OfflineMessageQueue()
queue.clear()
queue.enqueue(text: "Message 1", sessionKey: "s1")
queue.enqueue(text: "Message 2", sessionKey: "s2")
queue.enqueue(text: "Message 3", sessionKey: "s3")
#expect(queue.count == 3)
queue.clear()
#expect(queue.isEmpty)
#expect(queue.count == 0)
}
@Test func isEmptyReturnsCorrectValue() async {
let queue = OfflineMessageQueue()
queue.clear()
#expect(queue.isEmpty)
queue.enqueue(text: "Test", sessionKey: "session")
#expect(!queue.isEmpty)
queue.clear()
#expect(queue.isEmpty)
}
@Test func enqueueWithThinkingLevel() async {
let queue = OfflineMessageQueue()
queue.clear()
queue.enqueue(text: "Deep thought", sessionKey: "session", thinking: "high")
#expect(queue.pending.first?.thinking == "high")
queue.clear()
}
@Test func dequeueNonExistentIdDoesNothing() async {
let queue = OfflineMessageQueue()
queue.clear()
queue.enqueue(text: "Test", sessionKey: "session")
let originalCount = queue.count
queue.dequeue(UUID()) // Random ID that doesn't exist
#expect(queue.count == originalCount)
queue.clear()
}
}

View File

@ -88,6 +88,12 @@ targets:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
UIBackgroundModes: UIBackgroundModes:
- audio - audio
- voip
- remote-notification
CFBundleURLTypes:
- CFBundleURLName: bot.molt.ios
CFBundleURLSchemes:
- moltbot
NSLocalNetworkUsageDescription: Moltbot discovers and connects to your Moltbot gateway on the local network. NSLocalNetworkUsageDescription: Moltbot discovers and connects to your Moltbot gateway on the local network.
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true NSAllowsArbitraryLoadsInWebContent: true