fix(android): add sms permission flow and tests

This commit is contained in:
Peter Steinberger
2026-01-04 13:27:30 +01:00
parent 1318276105
commit 0d56a73118
8 changed files with 338 additions and 84 deletions

View File

@@ -39,6 +39,7 @@ class MainActivity : ComponentActivity() {
screenCaptureRequester = ScreenCaptureRequester(this) screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this) viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester) viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester) viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
viewModel.screenRecorder.attachPermissionRequester(permissionRequester) viewModel.screenRecorder.attachPermissionRequester(permissionRequester)

View File

@@ -7,6 +7,7 @@ import com.clawdis.android.chat.OutgoingAttachment
import com.clawdis.android.node.CameraCaptureManager import com.clawdis.android.node.CameraCaptureManager
import com.clawdis.android.node.CanvasController import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager import com.clawdis.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) { class MainViewModel(app: Application) : AndroidViewModel(app) {
@@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val canvas: CanvasController = runtime.canvas val canvas: CanvasController = runtime.canvas
val camera: CameraCaptureManager = runtime.camera val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText

View File

@@ -391,7 +391,10 @@ class NodeRuntime(context: Context) {
add(ClawdisCameraCommand.Snap.rawValue) add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue) add(ClawdisCameraCommand.Clip.rawValue)
} }
if (sms.hasSmsPermission()) { if (locationMode.value != LocationMode.Off) {
add(ClawdisLocationCommand.Get.rawValue)
}
if (sms.canSendSms()) {
add(ClawdisSmsCommand.Send.rawValue) add(ClawdisSmsCommand.Send.rawValue)
} }
} }
@@ -402,7 +405,7 @@ class NodeRuntime(context: Context) {
add(ClawdisCapability.Canvas.rawValue) add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue) add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.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()) { if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue) add(ClawdisCapability.VoiceWake.rawValue)
} }
@@ -467,7 +470,7 @@ class NodeRuntime(context: Context) {
add(ClawdisCapability.Canvas.rawValue) add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue) add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.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()) { if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue) add(ClawdisCapability.VoiceWake.rawValue)
} }

View File

@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
private fun buildRationaleMessage(permissions: List<String>): String { private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) } 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>): String { private fun buildSettingsMessage(permissions: List<String>): String {
@@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
when (permission) { when (permission) {
Manifest.permission.CAMERA -> "Camera" Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone" Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "SMS"
else -> permission else -> permission
} }
} }

View File

@@ -6,8 +6,12 @@ import android.content.pm.PackageManager
import android.telephony.SmsManager as AndroidSmsManager import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive 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. * Sends SMS messages via the Android SMS API.
@@ -15,20 +19,99 @@ import kotlinx.serialization.json.JsonPrimitive
*/ */
class SmsManager(private val context: Context) { 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( data class SendResult(
val ok: Boolean, val ok: Boolean,
val to: String, val to: String,
val message: String?, val message: String?,
val error: String? = null val error: String? = null,
) { val payloadJson: String,
val payloadJson: String )
get() = if (ok) {
"""{"ok":true,"to":"$to"}""" internal data class ParsedParams(
} else { val to: String,
"""{"ok":false,"to":"$to","error":"${error?.replace("\"", "\\\"")}"}""" 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<String>,
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<String>,
): 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<String, JsonElement>(
"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 { fun hasSmsPermission(): Boolean {
@@ -38,107 +121,110 @@ class SmsManager(private val context: Context) {
) == PackageManager.PERMISSION_GRANTED ) == 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. * Send an SMS message.
* *
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields * @param paramsJson JSON with "to" (phone number) and "message" (text) fields
* @return SendResult indicating success or failure * @return SendResult indicating success or failure
*/ */
fun send(paramsJson: String?): SendResult { suspend fun send(paramsJson: String?): SendResult {
if (!hasSmsPermission()) { if (!hasTelephonyFeature()) {
return SendResult( return errorResult(
ok = false, error = "SMS_UNAVAILABLE: telephony not available",
to = "",
message = null,
error = "SMS_PERMISSION_REQUIRED: SEND_SMS permission not granted"
) )
} }
val params = paramsJson?.trim().orEmpty() if (!ensureSmsPermission()) {
if (params.isEmpty()) { return errorResult(
return SendResult( error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
ok = false,
to = "",
message = null,
error = "INVALID_REQUEST: paramsJSON required"
) )
} }
val obj = try { val parseResult = parseParams(paramsJson, json)
json.parseToJsonElement(params) as? JsonObject if (parseResult is ParseResult.Error) {
} catch (e: Throwable) { return errorResult(
null error = parseResult.error,
} to = parseResult.to,
message = parseResult.message,
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 params = (parseResult as ParseResult.Ok).params
return try { return try {
val smsManager = context.getSystemService(AndroidSmsManager::class.java) val smsManager = context.getSystemService(AndroidSmsManager::class.java)
?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available")
// Handle long messages by splitting into parts val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
if (message.length > 160) { if (plan.useMultipart) {
val parts = smsManager.divideMessage(message)
smsManager.sendMultipartTextMessage( smsManager.sendMultipartTextMessage(
to, // destination params.to, // destination
null, // service center (null = default) null, // service center (null = default)
parts, // message parts ArrayList(plan.parts), // message parts
null, // sent intents null, // sent intents
null // delivery intents null, // delivery intents
) )
} else { } else {
smsManager.sendTextMessage( smsManager.sendTextMessage(
to, // destination params.to, // destination
null, // service center (null = default) null, // service center (null = default)
message, // message params.message,// message
null, // sent intent null, // sent intent
null // delivery intent null, // delivery intent
) )
} }
SendResult(ok = true, to = to, message = message) okResult(to = params.to, message = params.message)
} catch (e: SecurityException) { } catch (e: SecurityException) {
SendResult( errorResult(
ok = false, error = "SMS_PERMISSION_REQUIRED: ${e.message}",
to = to, to = params.to,
message = message, message = params.message,
error = "SMS_PERMISSION_REQUIRED: ${e.message}"
) )
} catch (e: Throwable) { } catch (e: Throwable) {
SendResult( errorResult(
ok = false, error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}",
to = to, to = params.to,
message = message, message = params.message,
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}"
) )
} }
} }
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),
)
}
} }

View File

@@ -149,6 +149,22 @@ fun SettingsSheet(viewModel: MainViewModel) {
// Status text is handled by NodeRuntime. // 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) { fun setCameraEnabledChecked(checked: Boolean) {
if (!checked) { if (!checked) {
viewModel.setCameraEnabled(false) viewModel.setCameraEnabled(false)
@@ -233,7 +249,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.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 { Text("Node", style = MaterialTheme.typography.titleSmall) }
item { item {
OutlinedTextField( OutlinedTextField(
@@ -507,6 +523,46 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() } 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 // Location
item { Text("Location", style = MaterialTheme.typography.titleSmall) } item { Text("Location", style = MaterialTheme.typography.titleSmall) }
item { item {

View File

@@ -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)
}
}

View File

@@ -110,6 +110,20 @@ Notes:
- “Always” requires system permission; background fetch is best-effort. - “Always” requires system permission; background fetch is best-effort.
- The response includes lat/lon, accuracy (meters), and timestamp. - 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 <idOrNameOrIp> --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) ## System commands (mac node)
The macOS node exposes `system.run` and `system.notify`. The macOS node exposes `system.run` and `system.notify`.