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.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services")
}
android {
@ -27,7 +28,12 @@ android {
buildTypes {
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.
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("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
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:exported="false"
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
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</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>
</application>
</manifest>

View File

@ -1,8 +1,10 @@
package bot.molt.android
import android.Manifest
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.util.Log
import android.os.Build
import android.view.WindowManager
import android.webkit.WebView
@ -27,6 +29,10 @@ class MainActivity : ComponentActivity() {
private lateinit var permissionRequester: PermissionRequester
private lateinit var screenCaptureRequester: ScreenCaptureRequester
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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() {

View File

@ -8,9 +8,13 @@ import bot.molt.android.node.CameraCaptureManager
import bot.molt.android.node.CanvasController
import bot.molt.android.node.ScreenRecordManager
import bot.molt.android.node.SmsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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
val canvas: CanvasController = runtime.canvas
@ -171,4 +175,36 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
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.os.StrictMode
import com.clawdbot.android.sync.SyncWorker
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() {
super.onCreate()
// Schedule periodic background sync for chat history
SyncWorker.schedule(this)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
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
import android.util.Log
import bot.molt.android.gateway.GatewaySession
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@ -24,6 +25,9 @@ class ChatController(
private val json: Json,
private val supportsChatSubscribe: Boolean,
) {
companion object {
private const val TAG = "ChatController"
}
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@ -218,8 +222,8 @@ class ChatController(
put("runId", JsonPrimitive(runId))
}
session.request("chat.abort", params.toString())
} catch (_: Throwable) {
// best-effort
} catch (e: Throwable) {
Log.d(TAG, "Failed to abort chat (best-effort)", e)
}
}
}
@ -263,8 +267,8 @@ class ChatController(
if (supportsChatSubscribe) {
try {
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
} catch (_: Throwable) {
// best-effort
} catch (e: Throwable) {
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())
_sessions.value = parseSessions(res)
} catch (_: Throwable) {
// best-effort
} catch (e: Throwable) {
Log.d(TAG, "Failed to refresh chat sessions (best-effort)", e)
}
}
@ -304,7 +308,8 @@ class ChatController(
try {
session.request("health", null)
_healthOk.value = true
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.d(TAG, "Health check failed", e)
_healthOk.value = false
}
}
@ -341,8 +346,8 @@ class ChatController(
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
} catch (_: Throwable) {
// best-effort
} catch (e: Throwable) {
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? {
return try {
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.d(TAG, "Failed to parse runId from response", e)
null
}
}

View File

@ -62,6 +62,9 @@ class GatewaySession(
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
) {
companion object {
private const val TAG = "GatewaySession"
}
data class InvokeRequest(
val id: String,
val nodeId: String,
@ -458,7 +461,8 @@ class GatewaySession(
if (isLoopbackHost(endpoint.host)) return null
return try {
withTimeout(2_000) { connectNonceDeferred.await() }
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.d(TAG, "Timeout awaiting connect nonce", e)
null
}
}
@ -473,7 +477,8 @@ class GatewaySession(
val payload =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.w(TAG, "Failed to parse invoke event JSON", e)
null
} ?: 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.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.clawdbot.android.DeepLinkSheet
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color as ComposeColor
@ -73,6 +75,19 @@ import bot.molt.android.MainViewModel
fun RootScreen(viewModel: MainViewModel) {
var sheet by remember { mutableStateOf<Sheet?>(null) }
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 context = LocalContext.current
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.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@ -49,6 +50,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
@ -315,7 +317,30 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
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 {
items(items = visibleGateways, key = { it.stableId }) { gateway ->
val detailLines =

View File

@ -140,7 +140,8 @@ class TalkModeManager(
val obj =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.d(tag, "Failed to parse gateway event JSON", e)
null
} ?: return
val runId = obj["runId"].asStringOrNull() ?: return
@ -245,8 +246,8 @@ class TalkModeManager(
val shouldInterrupt = _isSpeaking.value && interruptOnSpeech
if (!shouldListen && !shouldInterrupt) return@post
startListeningInternal(markListening = shouldListen)
} catch (_: Throwable) {
// handled by onError
} catch (e: Throwable) {
Log.d(tag, "Failed to restart listening after end-of-speech", e)
}
}
}
@ -395,7 +396,8 @@ class TalkModeManager(
withContext(Dispatchers.IO) {
try {
kotlinx.coroutines.withTimeout(120_000) { deferred.await() }
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.d(tag, "Timeout waiting for agent response", e)
false
}
}
@ -702,7 +704,8 @@ class TalkModeManager(
TextToSpeech(context) { status ->
deferred.complete(status == TextToSpeech.SUCCESS)
}
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.w(tag, "Failed to initialize system TTS", e)
deferred.complete(false)
null
}
@ -743,7 +746,8 @@ class TalkModeManager(
val ok =
try {
deferred.await()
} catch (_: Throwable) {
} catch (e: Throwable) {
Log.d(tag, "TTS initialization deferred await failed", e)
false
}
if (ok) {
@ -793,8 +797,8 @@ class TalkModeManager(
track.pause()
track.flush()
track.stop()
} catch (_: Throwable) {
// ignore cleanup errors
} catch (e: Throwable) {
Log.d(tag, "PCM track cleanup error (ignored)", e)
} finally {
track.release()
}
@ -842,7 +846,8 @@ class TalkModeManager(
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
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() }
defaultModelId = defaultModelIdFallback
if (!modelOverrideActive) currentModelId = defaultModelId
@ -1099,8 +1104,8 @@ class TalkModeManager(
}
recognizer?.cancel()
startListeningInternal(markListening = false)
} catch (_: Throwable) {
// ignore
} catch (e: Throwable) {
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.plugin.compose") 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 UIKit
@main
struct MoltbotApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var appModel: NodeAppModel
@State private var gatewayController: GatewayConnectionController
@Environment(\.scenePhase) private var scenePhase
@ -11,6 +13,12 @@ struct MoltbotApp: App {
let appModel = NodeAppModel()
_appModel = State(initialValue: appModel)
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
// Register for push notifications
PushManager.shared.registerForVoIPPush()
Task {
await PushManager.shared.registerForRemoteNotifications()
}
}
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>
<array>
<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>
<key>UILaunchScreen</key>
<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
}
}

View File

@ -186,6 +186,12 @@ struct SettingsTab: View {
"Wake Words",
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
}
NavigationLink {
TTSVoiceSettingsView()
} label: {
Text("TTS Voice Settings")
}
}
Section("Camera") {
@ -278,8 +284,13 @@ struct SettingsTab: View {
@ViewBuilder
private func gatewayList(showing: GatewayListMode) -> some View {
if self.gatewayController.gateways.isEmpty {
Text("No gateways found yet.")
.foregroundStyle(.secondary)
ContentUnavailableView {
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 {
let connectedID = self.appModel.connectedGatewayID
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
UIBackgroundModes:
- audio
- voip
- remote-notification
CFBundleURLTypes:
- CFBundleURLName: bot.molt.ios
CFBundleURLSchemes:
- moltbot
NSLocalNetworkUsageDescription: Moltbot discovers and connects to your Moltbot gateway on the local network.
NSAppTransportSecurity:
NSAllowsArbitraryLoadsInWebContent: true