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:
committed by
Peter Steinberger
parent
7aab2ae182
commit
1318276105
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
;
|
||||
|
||||
Reference in New Issue
Block a user