diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index 883b59df2..a14b8c079 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -14,9 +14,13 @@
+
+
{
+ 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",
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
new file mode 100644
index 000000000..c7b1286c9
--- /dev/null
+++ b/apps/android/app/src/main/java/com/clawdis/android/node/SmsManager.kt
@@ -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"}"
+ )
+ }
+ }
+}
diff --git a/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt b/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt
index 7988f05c9..d63d50c25 100644
--- a/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt
+++ b/apps/android/app/src/main/java/com/clawdis/android/protocol/ClawdisProtocolConstants.kt
@@ -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"),
;