feat(android): add SMS sending capability to Android node

Add sms.send command to allow sending text messages via the paired Android device.

Changes:
- Add SmsManager class to handle SMS sending via Android SmsManager API
- Add ClawdisSmsCommand enum and Sms capability to protocol constants
- Wire sms.send command into NodeRuntime invoke handler
- Add SEND_SMS permission to AndroidManifest.xml
- Advertise sms capability when SEND_SMS permission is granted

The SMS capability is only advertised when the user has granted SEND_SMS
permission. Messages longer than 160 chars are automatically split into
multipart messages.
This commit is contained in:
Vasanth Rao Naik Sabavat
2026-01-03 22:08:09 -08:00
committed by Peter Steinberger
parent 7aab2ae182
commit 1318276105
4 changed files with 177 additions and 3 deletions

View File

@@ -14,9 +14,13 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<application
android:name=".NodeApp"

View File

@@ -21,13 +21,15 @@ import com.clawdis.android.node.LocationCaptureManager
import com.clawdis.android.BuildConfig
import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager
import com.clawdis.android.protocol.ClawdisCapability
import com.clawdis.android.protocol.ClawdisCameraCommand
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
import com.clawdis.android.protocol.ClawdisCanvasCommand
import com.clawdis.android.protocol.ClawdisLocationCommand
import com.clawdis.android.protocol.ClawdisScreenCommand
import com.clawdis.android.protocol.ClawdisLocationCommand
import com.clawdis.android.protocol.ClawdisSmsCommand
import com.clawdis.android.voice.TalkModeManager
import com.clawdis.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope
@@ -61,6 +63,7 @@ class NodeRuntime(context: Context) {
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
private val externalAudioCaptureActive = MutableStateFlow(false)
@@ -388,8 +391,8 @@ class NodeRuntime(context: Context) {
add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisLocationCommand.Get.rawValue)
if (sms.hasSmsPermission()) {
add(ClawdisSmsCommand.Send.rawValue)
}
}
val resolved =
@@ -399,6 +402,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 (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
@@ -463,6 +467,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 (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
@@ -911,6 +916,17 @@ class NodeRuntime(context: Context) {
_screenRecordActive.value = false
}
}
ClawdisSmsCommand.Send.rawValue -> {
val res = sms.send(paramsJson)
if (res.ok) {
BridgeSession.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"
BridgeSession.InvokeResult.error(code = code, message = error)
}
}
else ->
BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST",

View File

@@ -0,0 +1,144 @@
package com.clawdis.android.node
import android.Manifest
import android.content.Context
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.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Sends SMS messages via the Android SMS API.
* Requires SEND_SMS permission to be granted.
*/
class SmsManager(private val context: Context) {
private val json = Json { ignoreUnknownKeys = true }
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("\"", "\\\"")}"}"""
}
}
fun hasSmsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.SEND_SMS
) == PackageManager.PERMISSION_GRANTED
}
/**
* 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"
)
}
val params = paramsJson?.trim().orEmpty()
if (params.isEmpty()) {
return SendResult(
ok = false,
to = "",
message = null,
error = "INVALID_REQUEST: paramsJSON required"
)
}
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"
)
}
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)
smsManager.sendMultipartTextMessage(
to, // destination
null, // service center (null = default)
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
)
}
SendResult(ok = true, to = to, message = message)
} catch (e: SecurityException) {
SendResult(
ok = false,
to = to,
message = message,
error = "SMS_PERMISSION_REQUIRED: ${e.message}"
)
} catch (e: Throwable) {
SendResult(
ok = false,
to = to,
message = message,
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}"
)
}
}
}

View File

@@ -4,6 +4,7 @@ enum class ClawdisCapability(val rawValue: String) {
Canvas("canvas"),
Camera("camera"),
Screen("screen"),
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
}
@@ -51,6 +52,15 @@ enum class ClawdisScreenCommand(val rawValue: String) {
}
}
enum class ClawdisSmsCommand(val rawValue: String) {
Send("sms.send"),
;
companion object {
const val NamespacePrefix: String = "sms."
}
}
enum class ClawdisLocationCommand(val rawValue: String) {
Get("location.get"),
;