From 0d56a731181aefd3f86d803df99e66e1c48f4b62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Jan 2026 13:27:30 +0100 Subject: [PATCH] fix(android): add sms permission flow and tests --- .../java/com/clawdis/android/MainActivity.kt | 1 + .../java/com/clawdis/android/MainViewModel.kt | 2 + .../java/com/clawdis/android/NodeRuntime.kt | 9 +- .../clawdis/android/PermissionRequester.kt | 3 +- .../com/clawdis/android/node/SmsManager.kt | 244 ++++++++++++------ .../com/clawdis/android/ui/SettingsSheet.kt | 58 ++++- .../clawdis/android/node/SmsManagerTest.kt | 91 +++++++ docs/nodes.md | 14 + 8 files changed, 338 insertions(+), 84 deletions(-) create mode 100644 apps/android/app/src/test/java/com/clawdis/android/node/SmsManagerTest.kt diff --git a/apps/android/app/src/main/java/com/clawdis/android/MainActivity.kt b/apps/android/app/src/main/java/com/clawdis/android/MainActivity.kt index cf66b3b1a..2a95869c9 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/MainActivity.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/MainActivity.kt @@ -39,6 +39,7 @@ class MainActivity : ComponentActivity() { screenCaptureRequester = ScreenCaptureRequester(this) viewModel.camera.attachLifecycleOwner(this) viewModel.camera.attachPermissionRequester(permissionRequester) + viewModel.sms.attachPermissionRequester(permissionRequester) viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester) viewModel.screenRecorder.attachPermissionRequester(permissionRequester) diff --git a/apps/android/app/src/main/java/com/clawdis/android/MainViewModel.kt b/apps/android/app/src/main/java/com/clawdis/android/MainViewModel.kt index a0e1be579..d943cdccd 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/MainViewModel.kt @@ -7,6 +7,7 @@ import com.clawdis.android.chat.OutgoingAttachment import com.clawdis.android.node.CameraCaptureManager import com.clawdis.android.node.CanvasController import com.clawdis.android.node.ScreenRecordManager +import com.clawdis.android.node.SmsManager import kotlinx.coroutines.flow.StateFlow class MainViewModel(app: Application) : AndroidViewModel(app) { @@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val canvas: CanvasController = runtime.canvas val camera: CameraCaptureManager = runtime.camera val screenRecorder: ScreenRecordManager = runtime.screenRecorder + val sms: SmsManager = runtime.sms val bridges: StateFlow> = runtime.bridges val discoveryStatusText: StateFlow = runtime.discoveryStatusText diff --git a/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt b/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt index 484d24765..18faffd49 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/NodeRuntime.kt @@ -391,7 +391,10 @@ class NodeRuntime(context: Context) { add(ClawdisCameraCommand.Snap.rawValue) add(ClawdisCameraCommand.Clip.rawValue) } - if (sms.hasSmsPermission()) { + if (locationMode.value != LocationMode.Off) { + add(ClawdisLocationCommand.Get.rawValue) + } + if (sms.canSendSms()) { add(ClawdisSmsCommand.Send.rawValue) } } @@ -402,7 +405,7 @@ class NodeRuntime(context: Context) { add(ClawdisCapability.Canvas.rawValue) add(ClawdisCapability.Screen.rawValue) if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue) - if (sms.hasSmsPermission()) add(ClawdisCapability.Sms.rawValue) + if (sms.canSendSms()) add(ClawdisCapability.Sms.rawValue) if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) { add(ClawdisCapability.VoiceWake.rawValue) } @@ -467,7 +470,7 @@ class NodeRuntime(context: Context) { add(ClawdisCapability.Canvas.rawValue) add(ClawdisCapability.Screen.rawValue) if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue) - if (sms.hasSmsPermission()) add(ClawdisCapability.Sms.rawValue) + if (sms.canSendSms()) add(ClawdisCapability.Sms.rawValue) if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) { add(ClawdisCapability.VoiceWake.rawValue) } diff --git a/apps/android/app/src/main/java/com/clawdis/android/PermissionRequester.kt b/apps/android/app/src/main/java/com/clawdis/android/PermissionRequester.kt index 33902edc3..290fb4982 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/PermissionRequester.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/PermissionRequester.kt @@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) { private fun buildRationaleMessage(permissions: List): String { val labels = permissions.map { permissionLabel(it) } - return "Clawdis needs ${labels.joinToString(", ")} to capture camera media." + return "Clawdis needs ${labels.joinToString(", ")} permissions to continue." } private fun buildSettingsMessage(permissions: List): String { @@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) { when (permission) { Manifest.permission.CAMERA -> "Camera" Manifest.permission.RECORD_AUDIO -> "Microphone" + Manifest.permission.SEND_SMS -> "SMS" else -> permission } } diff --git a/apps/android/app/src/main/java/com/clawdis/android/node/SmsManager.kt b/apps/android/app/src/main/java/com/clawdis/android/node/SmsManager.kt index c7b1286c9..353e49dc3 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/node/SmsManager.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/node/SmsManager.kt @@ -6,8 +6,12 @@ import android.content.pm.PackageManager import android.telephony.SmsManager as AndroidSmsManager import androidx.core.content.ContextCompat import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.encodeToString +import com.clawdis.android.PermissionRequester /** * Sends SMS messages via the Android SMS API. @@ -15,20 +19,99 @@ import kotlinx.serialization.json.JsonPrimitive */ class SmsManager(private val context: Context) { - private val json = Json { ignoreUnknownKeys = true } + private val json = JsonConfig + @Volatile private var permissionRequester: PermissionRequester? = null data class SendResult( val ok: Boolean, val to: String, val message: String?, - val error: String? = null - ) { - val payloadJson: String - get() = if (ok) { - """{"ok":true,"to":"$to"}""" - } else { - """{"ok":false,"to":"$to","error":"${error?.replace("\"", "\\\"")}"}""" + val error: String? = null, + val payloadJson: String, + ) + + internal data class ParsedParams( + val to: String, + val message: String, + ) + + internal sealed class ParseResult { + data class Ok(val params: ParsedParams) : ParseResult() + data class Error( + val error: String, + val to: String = "", + val message: String? = null, + ) : ParseResult() + } + + internal data class SendPlan( + val parts: List, + val useMultipart: Boolean, + ) + + companion object { + internal val JsonConfig = Json { ignoreUnknownKeys = true } + + internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult { + val params = paramsJson?.trim().orEmpty() + if (params.isEmpty()) { + return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required") } + + val obj = try { + json.parseToJsonElement(params).jsonObject + } catch (_: Throwable) { + null + } + + if (obj == null) { + return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object") + } + + val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() + val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() + + if (to.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'to' phone number required", + message = message, + ) + } + + if (message.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'message' text required", + to = to, + ) + } + + return ParseResult.Ok(ParsedParams(to = to, message = message)) + } + + internal fun buildSendPlan( + message: String, + divider: (String) -> List, + ): SendPlan { + val parts = divider(message).ifEmpty { listOf(message) } + return SendPlan(parts = parts, useMultipart = parts.size > 1) + } + + internal fun buildPayloadJson( + json: Json = JsonConfig, + ok: Boolean, + to: String, + error: String?, + ): String { + val payload = + mutableMapOf( + "ok" to JsonPrimitive(ok), + "to" to JsonPrimitive(to), + ) + if (!ok) { + payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED") + } + return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) + } } fun hasSmsPermission(): Boolean { @@ -38,107 +121,110 @@ class SmsManager(private val context: Context) { ) == PackageManager.PERMISSION_GRANTED } + fun canSendSms(): Boolean { + return hasSmsPermission() && hasTelephonyFeature() + } + + fun hasTelephonyFeature(): Boolean { + return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + + fun attachPermissionRequester(requester: PermissionRequester) { + permissionRequester = requester + } + /** * Send an SMS message. * * @param paramsJson JSON with "to" (phone number) and "message" (text) fields * @return SendResult indicating success or failure */ - fun send(paramsJson: String?): SendResult { - if (!hasSmsPermission()) { - return SendResult( - ok = false, - to = "", - message = null, - error = "SMS_PERMISSION_REQUIRED: SEND_SMS permission not granted" + suspend fun send(paramsJson: String?): SendResult { + if (!hasTelephonyFeature()) { + return errorResult( + error = "SMS_UNAVAILABLE: telephony not available", ) } - val params = paramsJson?.trim().orEmpty() - if (params.isEmpty()) { - return SendResult( - ok = false, - to = "", - message = null, - error = "INVALID_REQUEST: paramsJSON required" + if (!ensureSmsPermission()) { + return errorResult( + error = "SMS_PERMISSION_REQUIRED: grant SMS permission", ) } - val obj = try { - json.parseToJsonElement(params) as? JsonObject - } catch (e: Throwable) { - null - } - - if (obj == null) { - return SendResult( - ok = false, - to = "", - message = null, - error = "INVALID_REQUEST: expected JSON object" - ) - } - - val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() - val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() - - if (to.isEmpty()) { - return SendResult( - ok = false, - to = "", - message = message, - error = "INVALID_REQUEST: 'to' phone number required" - ) - } - - if (message.isEmpty()) { - return SendResult( - ok = false, - to = to, - message = null, - error = "INVALID_REQUEST: 'message' text required" + val parseResult = parseParams(paramsJson, json) + if (parseResult is ParseResult.Error) { + return errorResult( + error = parseResult.error, + to = parseResult.to, + message = parseResult.message, ) } + val params = (parseResult as ParseResult.Ok).params return try { val smsManager = context.getSystemService(AndroidSmsManager::class.java) ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") - // Handle long messages by splitting into parts - if (message.length > 160) { - val parts = smsManager.divideMessage(message) + val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) } + if (plan.useMultipart) { smsManager.sendMultipartTextMessage( - to, // destination - null, // service center (null = default) - parts, // message parts - null, // sent intents - null // delivery intents + params.to, // destination + null, // service center (null = default) + ArrayList(plan.parts), // message parts + null, // sent intents + null, // delivery intents ) } else { smsManager.sendTextMessage( - to, // destination - null, // service center (null = default) - message, // message - null, // sent intent - null // delivery intent + params.to, // destination + null, // service center (null = default) + params.message,// message + null, // sent intent + null, // delivery intent ) } - SendResult(ok = true, to = to, message = message) + okResult(to = params.to, message = params.message) } catch (e: SecurityException) { - SendResult( - ok = false, - to = to, - message = message, - error = "SMS_PERMISSION_REQUIRED: ${e.message}" + errorResult( + error = "SMS_PERMISSION_REQUIRED: ${e.message}", + to = params.to, + message = params.message, ) } catch (e: Throwable) { - SendResult( - ok = false, - to = to, - message = message, - error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}" + errorResult( + error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}", + to = params.to, + message = params.message, ) } } + + private suspend fun ensureSmsPermission(): Boolean { + if (hasSmsPermission()) return true + val requester = permissionRequester ?: return false + val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS)) + return results[Manifest.permission.SEND_SMS] == true + } + + private fun okResult(to: String, message: String): SendResult { + return SendResult( + ok = true, + to = to, + message = message, + error = null, + payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null), + ) + } + + private fun errorResult(error: String, to: String = "", message: String? = null): SendResult { + return SendResult( + ok = false, + to = to, + message = message, + error = error, + payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error), + ) + } } diff --git a/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt index ec36c8343..85f492495 100644 --- a/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/clawdis/android/ui/SettingsSheet.kt @@ -149,6 +149,22 @@ fun SettingsSheet(viewModel: MainViewModel) { // Status text is handled by NodeRuntime. } + val smsPermissionAvailable = + remember { + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + } + var smsPermissionGranted by + remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == + PackageManager.PERMISSION_GRANTED, + ) + } + val smsPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + smsPermissionGranted = granted + } + fun setCameraEnabledChecked(checked: Boolean) { if (!checked) { viewModel.setCameraEnabled(false) @@ -233,7 +249,7 @@ fun SettingsSheet(viewModel: MainViewModel) { contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - // Order parity: Node → Bridge → Voice → Camera → Location → Screen. + // Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen. item { Text("Node", style = MaterialTheme.typography.titleSmall) } item { OutlinedTextField( @@ -507,6 +523,46 @@ fun SettingsSheet(viewModel: MainViewModel) { item { HorizontalDivider() } + // Messaging + item { Text("Messaging", style = MaterialTheme.typography.titleSmall) } + item { + val buttonLabel = + when { + !smsPermissionAvailable -> "Unavailable" + smsPermissionGranted -> "Manage" + else -> "Grant" + } + ListItem( + headlineContent = { Text("SMS Permission") }, + supportingContent = { + Text( + if (smsPermissionAvailable) { + "Allow the bridge to send SMS from this device." + } else { + "SMS requires a device with telephony hardware." + }, + ) + }, + trailingContent = { + Button( + onClick = { + if (!smsPermissionAvailable) return@Button + if (smsPermissionGranted) { + openAppSettings(context) + } else { + smsPermissionLauncher.launch(Manifest.permission.SEND_SMS) + } + }, + enabled = smsPermissionAvailable, + ) { + Text(buttonLabel) + } + }, + ) + } + + item { HorizontalDivider() } + // Location item { Text("Location", style = MaterialTheme.typography.titleSmall) } item { diff --git a/apps/android/app/src/test/java/com/clawdis/android/node/SmsManagerTest.kt b/apps/android/app/src/test/java/com/clawdis/android/node/SmsManagerTest.kt new file mode 100644 index 000000000..8d32b429b --- /dev/null +++ b/apps/android/app/src/test/java/com/clawdis/android/node/SmsManagerTest.kt @@ -0,0 +1,91 @@ +package com.clawdis.android.node + +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SmsManagerTest { + private val json = SmsManager.JsonConfig + + @Test + fun parseParamsRejectsEmptyPayload() { + val result = SmsManager.parseParams("", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: paramsJSON required", error.error) + } + + @Test + fun parseParamsRejectsInvalidJson() { + val result = SmsManager.parseParams("not-json", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsNonObjectJson() { + val result = SmsManager.parseParams("[]", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsMissingTo() { + val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'to' phone number required", error.error) + assertEquals("Hi", error.message) + } + + @Test + fun parseParamsRejectsMissingMessage() { + val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'message' text required", error.error) + assertEquals("+1234", error.to) + } + + @Test + fun parseParamsTrimsToField() { + val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json) + assertTrue(result is SmsManager.ParseResult.Ok) + val ok = result as SmsManager.ParseResult.Ok + assertEquals("+1555", ok.params.to) + assertEquals("Hello", ok.params.message) + } + + @Test + fun buildPayloadJsonEscapesFields() { + val payload = SmsManager.buildPayloadJson( + json = json, + ok = false, + to = "+1\"23", + error = "SMS_SEND_FAILED: \"nope\"", + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) + assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content) + assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content) + } + + @Test + fun buildSendPlanUsesMultipartWhenMultipleParts() { + val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") } + assertTrue(plan.useMultipart) + assertEquals(listOf("a", "b"), plan.parts) + } + + @Test + fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() { + val plan = SmsManager.buildSendPlan("hello") { emptyList() } + assertFalse(plan.useMultipart) + assertEquals(listOf("hello"), plan.parts) + } +} diff --git a/docs/nodes.md b/docs/nodes.md index dcf74c866..97383765f 100644 --- a/docs/nodes.md +++ b/docs/nodes.md @@ -110,6 +110,20 @@ Notes: - “Always” requires system permission; background fetch is best-effort. - The response includes lat/lon, accuracy (meters), and timestamp. +## SMS (Android nodes) + +Android nodes can expose `sms.send` when the user grants **SMS** permission and the device supports telephony. + +Low-level invoke: + +```bash +clawdis nodes invoke --node --command sms.send --params '{"to":"+15555550123","message":"Hello from Clawdis"}' +``` + +Notes: +- The permission prompt must be accepted on the Android device before the capability is advertised. +- Wi-Fi-only devices without telephony will not advertise `sms.send`. + ## System commands (mac node) The macOS node exposes `system.run` and `system.notify`.