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.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.telephony"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NodeApp"
|
android:name=".NodeApp"
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ import com.clawdis.android.node.LocationCaptureManager
|
|||||||
import com.clawdis.android.BuildConfig
|
import com.clawdis.android.BuildConfig
|
||||||
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 com.clawdis.android.protocol.ClawdisCapability
|
import com.clawdis.android.protocol.ClawdisCapability
|
||||||
import com.clawdis.android.protocol.ClawdisCameraCommand
|
import com.clawdis.android.protocol.ClawdisCameraCommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
||||||
import com.clawdis.android.protocol.ClawdisLocationCommand
|
|
||||||
import com.clawdis.android.protocol.ClawdisScreenCommand
|
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.TalkModeManager
|
||||||
import com.clawdis.android.voice.VoiceWakeManager
|
import com.clawdis.android.voice.VoiceWakeManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -61,6 +63,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
val location = LocationCaptureManager(appContext)
|
val location = LocationCaptureManager(appContext)
|
||||||
val screenRecorder = ScreenRecordManager(appContext)
|
val screenRecorder = ScreenRecordManager(appContext)
|
||||||
|
val sms = SmsManager(appContext)
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||||
@@ -388,8 +391,8 @@ class NodeRuntime(context: Context) {
|
|||||||
add(ClawdisCameraCommand.Snap.rawValue)
|
add(ClawdisCameraCommand.Snap.rawValue)
|
||||||
add(ClawdisCameraCommand.Clip.rawValue)
|
add(ClawdisCameraCommand.Clip.rawValue)
|
||||||
}
|
}
|
||||||
if (locationMode.value != LocationMode.Off) {
|
if (sms.hasSmsPermission()) {
|
||||||
add(ClawdisLocationCommand.Get.rawValue)
|
add(ClawdisSmsCommand.Send.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val resolved =
|
val resolved =
|
||||||
@@ -399,6 +402,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 (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
}
|
}
|
||||||
@@ -463,6 +467,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 (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
add(ClawdisCapability.VoiceWake.rawValue)
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
}
|
}
|
||||||
@@ -911,6 +916,17 @@ class NodeRuntime(context: Context) {
|
|||||||
_screenRecordActive.value = false
|
_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 ->
|
else ->
|
||||||
BridgeSession.InvokeResult.error(
|
BridgeSession.InvokeResult.error(
|
||||||
code = "INVALID_REQUEST",
|
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"),
|
Canvas("canvas"),
|
||||||
Camera("camera"),
|
Camera("camera"),
|
||||||
Screen("screen"),
|
Screen("screen"),
|
||||||
|
Sms("sms"),
|
||||||
VoiceWake("voiceWake"),
|
VoiceWake("voiceWake"),
|
||||||
Location("location"),
|
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) {
|
enum class ClawdisLocationCommand(val rawValue: String) {
|
||||||
Get("location.get"),
|
Get("location.get"),
|
||||||
;
|
;
|
||||||
|
|||||||
Reference in New Issue
Block a user