fix(android): add sms permission flow and tests
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user