feat(android): support android node sms.search (#48299)

* feat(android): support android node sms.search

* feat(android): support android node sms.search

* fix(android): split sms search permissions

* fix: document android sms.search landing (#48299) (thanks @lixuankai)

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
lixuankai 2026-03-19 11:22:15 +08:00 committed by GitHub
parent 58cf9b865f
commit c86de678f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 640 additions and 28 deletions

View File

@ -23,7 +23,8 @@ Docs: https://docs.openclaw.ai
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lixuankai.
- Android/nodes: add `sms.search` plus shared SMS permission wiring so Android nodes can search device text messages through the gateway. (#48299) Thanks @lixuankai.
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.

View File

@ -176,6 +176,45 @@ More details: `docs/platforms/android.md`.
- `CAMERA` for `camera.snap` and `camera.clip`
- `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`
## Google Play Restricted Permissions
As of March 19, 2026, these manifest permissions are the main Google Play policy risk for this app:
- `READ_SMS`
- `SEND_SMS`
- `READ_CALL_LOG`
Why these matter:
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
Current OpenClaw Android implication:
- APK / sideload build can keep SMS and Call Log features.
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
Policy links:
- [Google Play SMS and Call Log policy](https://support.google.com/googleplay/android-developer/answer/10208820?hl=en)
- [Google Play sensitive permissions policy hub](https://support.google.com/googleplay/android-developer/answer/16558241)
- [Android default handlers guide](https://developer.android.com/guide/topics/permissions/default-handlers)
Other Play-restricted surfaces to watch if added later:
- `ACCESS_BACKGROUND_LOCATION`
- `MANAGE_EXTERNAL_STORAGE`
- `QUERY_ALL_PACKAGES`
- `REQUEST_INSTALL_PACKAGES`
- `AccessibilityService`
Reference links:
- [Background location policy](https://support.google.com/googleplay/android-developer/answer/9799150)
- [AccessibilityService policy](https://support.google.com/googleplay/android-developer/answer/10964491?hl=en-GB)
- [Photo and Video Permissions policy](https://support.google.com/googleplay/android-developer/answer/14594990)
## Integration Capability Test (Preconditioned)
This suite assumes setup is already done manually. It does **not** install/run/pair automatically.

View File

@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission

View File

@ -137,7 +137,8 @@ class NodeRuntime(
voiceWakeMode = { VoiceWakeMode.Off },
motionActivityAvailable = { motionHandler.isActivityAvailable() },
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
)
@ -160,7 +161,8 @@ class NodeRuntime(
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
smsAvailable = { sms.canSendSms() },
sendSmsAvailable = { sms.canSendSms() },
readSmsAvailable = { sms.canReadSms() },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
onCanvasA2uiPush = {

View File

@ -17,7 +17,8 @@ class ConnectionManager(
private val voiceWakeMode: () -> VoiceWakeMode,
private val motionActivityAvailable: () -> Boolean,
private val motionPedometerAvailable: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
@ -78,7 +79,8 @@ class ConnectionManager(
NodeRuntimeFlags(
cameraEnabled = cameraEnabled(),
locationEnabled = locationMode() != LocationMode.Off,
smsAvailable = smsAvailable(),
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),

View File

@ -18,7 +18,8 @@ import ai.openclaw.app.protocol.OpenClawSystemCommand
data class NodeRuntimeFlags(
val cameraEnabled: Boolean,
val locationEnabled: Boolean,
val smsAvailable: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
@ -29,7 +30,8 @@ enum class InvokeCommandAvailability {
Always,
CameraEnabled,
LocationEnabled,
SmsAvailable,
SendSmsAvailable,
ReadSmsAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
@ -187,7 +189,11 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable,
availability = InvokeCommandAvailability.SendSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Search.rawValue,
availability = InvokeCommandAvailability.ReadSmsAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
@ -213,7 +219,7 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.Always -> true
NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.smsAvailable
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
@ -228,7 +234,8 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.Always -> true
InvokeCommandAvailability.CameraEnabled -> flags.cameraEnabled
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SmsAvailable -> flags.smsAvailable
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild

View File

@ -32,7 +32,8 @@ class InvokeDispatcher(
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
private val smsAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
@ -162,6 +163,7 @@ class InvokeDispatcher(
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
OpenClawSmsCommand.Search.rawValue -> smsHandler.handleSmsSearch(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
@ -256,8 +258,17 @@ class InvokeDispatcher(
message = "PEDOMETER_UNAVAILABLE: step counter not available",
)
}
InvokeCommandAvailability.SmsAvailable ->
if (smsAvailable()) {
InvokeCommandAvailability.SendSmsAvailable ->
if (sendSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable ->
if (readSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(

View File

@ -16,4 +16,16 @@ class SmsHandler(
return GatewaySession.InvokeResult.error(code = code, message = error)
}
}
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
val res = sms.search(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEARCH_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEARCH_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
}
}

View File

@ -3,19 +3,27 @@ package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Telephony
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.encodeToString
import kotlinx.serialization.Serializable
import ai.openclaw.app.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
* Requires SEND_SMS permission to be granted.
*
* Also provides SMS query functionality with READ_SMS permission.
*/
class SmsManager(private val context: Context) {
@ -30,6 +38,30 @@ class SmsManager(private val context: Context) {
val payloadJson: String,
)
/**
* Represents a single SMS message
*/
@Serializable
data class SmsMessage(
val id: Long,
val threadId: Long,
val address: String?,
val person: String?,
val date: Long,
val dateSent: Long,
val read: Boolean,
val type: Int,
val body: String?,
val status: Int,
)
data class SearchResult(
val ok: Boolean,
val messages: List<SmsMessage>,
val error: String? = null,
val payloadJson: String,
)
internal data class ParsedParams(
val to: String,
val message: String,
@ -44,12 +76,30 @@ class SmsManager(private val context: Context) {
) : ParseResult()
}
internal data class QueryParams(
val startTime: Long? = null,
val endTime: Long? = null,
val contactName: String? = null,
val phoneNumber: String? = null,
val keyword: String? = null,
val type: Int? = null,
val isRead: Boolean? = null,
val limit: Int = DEFAULT_SMS_LIMIT,
val offset: Int = 0,
)
internal sealed class QueryParseResult {
data class Ok(val params: QueryParams) : QueryParseResult()
data class Error(val error: String) : QueryParseResult()
}
internal data class SendPlan(
val parts: List<String>,
val useMultipart: Boolean,
)
companion object {
private const val DEFAULT_SMS_LIMIT = 25
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
@ -88,6 +138,52 @@ class SmsManager(private val context: Context) {
return ParseResult.Ok(ParsedParams(to = to, message = message))
}
internal fun parseQueryParams(paramsJson: String?, json: Json = JsonConfig): QueryParseResult {
val params = paramsJson?.trim().orEmpty()
if (params.isEmpty()) {
return QueryParseResult.Ok(QueryParams())
}
val obj = try {
json.parseToJsonElement(params).jsonObject
} catch (_: Throwable) {
return QueryParseResult.Error("INVALID_REQUEST: expected JSON object")
}
val startTime = (obj["startTime"] as? JsonPrimitive)?.content?.toLongOrNull()
val endTime = (obj["endTime"] as? JsonPrimitive)?.content?.toLongOrNull()
val contactName = (obj["contactName"] as? JsonPrimitive)?.content?.trim()
val phoneNumber = (obj["phoneNumber"] as? JsonPrimitive)?.content?.trim()
val keyword = (obj["keyword"] as? JsonPrimitive)?.content?.trim()
val type = (obj["type"] as? JsonPrimitive)?.content?.toIntOrNull()
val isRead = (obj["isRead"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull()
val limit = ((obj["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_SMS_LIMIT)
.coerceIn(1, 200)
val offset = ((obj["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
// Validate time range
if (startTime != null && endTime != null && startTime > endTime) {
return QueryParseResult.Error("INVALID_REQUEST: startTime must be less than or equal to endTime")
}
return QueryParseResult.Ok(QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
limit = limit,
offset = offset,
))
}
private fun normalizePhoneNumber(phone: String): String {
return phone.replace(Regex("""[\s\-()]"""), "")
}
internal fun buildSendPlan(
message: String,
divider: (String) -> List<String>,
@ -112,6 +208,25 @@ class SmsManager(private val context: Context) {
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
internal fun buildQueryPayloadJson(
json: Json = JsonConfig,
ok: Boolean,
messages: List<SmsMessage>,
error: String? = null,
): String {
val messagesArray = json.encodeToString(messages)
val messagesElement = json.parseToJsonElement(messagesArray)
val payload = mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"count" to JsonPrimitive(messages.size),
"messages" to messagesElement
)
if (!ok && error != null) {
payload["error"] = JsonPrimitive(error)
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
}
fun hasSmsPermission(): Boolean {
@ -121,10 +236,28 @@ class SmsManager(private val context: Context) {
) == PackageManager.PERMISSION_GRANTED
}
fun hasReadSmsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_SMS
) == PackageManager.PERMISSION_GRANTED
}
fun hasReadContactsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED
}
fun canSendSms(): Boolean {
return hasSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
return hasReadSmsPermission() && hasTelephonyFeature()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
@ -208,6 +341,20 @@ class SmsManager(private val context: Context) {
return results[Manifest.permission.SEND_SMS] == true
}
private suspend fun ensureReadSmsPermission(): Boolean {
if (hasReadSmsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_SMS))
return results[Manifest.permission.READ_SMS] == true
}
private suspend fun ensureReadContactsPermission(): Boolean {
if (hasReadContactsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.READ_CONTACTS))
return results[Manifest.permission.READ_CONTACTS] == true
}
private fun okResult(to: String, message: String): SendResult {
return SendResult(
ok = true,
@ -227,4 +374,240 @@ class SmsManager(private val context: Context) {
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
)
}
/**
* search SMS messages with the specified parameters.
*
* @param paramsJson JSON with optional fields:
* - startTime (Long): Start time in milliseconds
* - endTime (Long): End time in milliseconds
* - contactName (String): Contact name to search
* - phoneNumber (String): Phone number to search (supports partial matching)
* - keyword (String): Keyword to search in message body
* - type (Int): SMS type (1=Inbox, 2=Sent, 3=Draft, etc.)
* - isRead (Boolean): Read status
* - limit (Int): Number of records to return (default: 25, range: 1-200)
* - offset (Int): Number of records to skip (default: 0)
* @return SearchResult containing the list of SMS messages or an error
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_UNAVAILABLE: telephony not available",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_UNAVAILABLE: telephony not available")
)
}
if (!ensureReadSmsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
)
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = parseResult.error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = parseResult.error)
)
}
val params = (parseResult as QueryParseResult.Ok).params
return@withContext try {
// Get phone numbers from contact name if provided
val phoneNumbers = if (!params.contactName.isNullOrEmpty()) {
if (!ensureReadContactsPermission()) {
return@withContext SearchResult(
ok = false,
messages = emptyList(),
error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
)
}
getPhoneNumbersFromContactName(params.contactName)
} else {
emptyList()
}
val messages = querySmsMessages(params, phoneNumbers)
SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages)
)
} catch (e: SecurityException) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_PERMISSION_REQUIRED: ${e.message}")
)
} catch (e: Throwable) {
SearchResult(
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}",
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = "SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
)
}
}
/**
* Get all phone numbers associated with a contact name
*/
private fun getPhoneNumbersFromContactName(contactName: String): List<String> {
val phoneNumbers = mutableListOf<String>()
val selection = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ?"
val selectionArgs = arrayOf("%$contactName%")
val cursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER),
selection,
selectionArgs,
null
)
cursor?.use {
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) {
val number = it.getString(numberIndex)
if (!number.isNullOrBlank()) {
phoneNumbers.add(normalizePhoneNumber(number))
}
}
}
return phoneNumbers
}
/**
* Query SMS messages based on the provided parameters
*/
private fun querySmsMessages(params: QueryParams, phoneNumbers: List<String>): List<SmsMessage> {
val messages = mutableListOf<SmsMessage>()
// Build selection and selectionArgs
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
// Time range
if (params.startTime != null) {
selections.add("${Telephony.Sms.DATE} >= ?")
selectionArgs.add(params.startTime.toString())
}
if (params.endTime != null) {
selections.add("${Telephony.Sms.DATE} <= ?")
selectionArgs.add(params.endTime.toString())
}
// Phone numbers (from contact name or direct phone number)
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
} else {
phoneNumbers
}
if (allPhoneNumbers.isNotEmpty()) {
val addressSelection = allPhoneNumbers.joinToString(" OR ") {
"${Telephony.Sms.ADDRESS} LIKE ?"
}
selections.add("($addressSelection)")
allPhoneNumbers.forEach {
selectionArgs.add("%$it%")
}
}
// Keyword in body
if (!params.keyword.isNullOrEmpty()) {
selections.add("${Telephony.Sms.BODY} LIKE ?")
selectionArgs.add("%${params.keyword}%")
}
// Type
if (params.type != null) {
selections.add("${Telephony.Sms.TYPE} = ?")
selectionArgs.add(params.type.toString())
}
// Read status
if (params.isRead != null) {
selections.add("${Telephony.Sms.READ} = ?")
selectionArgs.add(if (params.isRead) "1" else "0")
}
val selection = if (selections.isNotEmpty()) {
selections.joinToString(" AND ")
} else {
null
}
val selectionArgsArray = if (selectionArgs.isNotEmpty()) {
selectionArgs.toTypedArray()
} else {
null
}
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
val cursor = context.contentResolver.query(
Telephony.Sms.CONTENT_URI,
arrayOf(
Telephony.Sms._ID,
Telephony.Sms.THREAD_ID,
Telephony.Sms.ADDRESS,
Telephony.Sms.PERSON,
Telephony.Sms.DATE,
Telephony.Sms.DATE_SENT,
Telephony.Sms.READ,
Telephony.Sms.TYPE,
Telephony.Sms.BODY,
Telephony.Sms.STATUS
),
selection,
selectionArgsArray,
sortOrder
)
cursor?.use {
val idIndex = it.getColumnIndex(Telephony.Sms._ID)
val threadIdIndex = it.getColumnIndex(Telephony.Sms.THREAD_ID)
val addressIndex = it.getColumnIndex(Telephony.Sms.ADDRESS)
val personIndex = it.getColumnIndex(Telephony.Sms.PERSON)
val dateIndex = it.getColumnIndex(Telephony.Sms.DATE)
val dateSentIndex = it.getColumnIndex(Telephony.Sms.DATE_SENT)
val readIndex = it.getColumnIndex(Telephony.Sms.READ)
val typeIndex = it.getColumnIndex(Telephony.Sms.TYPE)
val bodyIndex = it.getColumnIndex(Telephony.Sms.BODY)
val statusIndex = it.getColumnIndex(Telephony.Sms.STATUS)
var count = 0
while (it.moveToNext() && count < params.limit) {
val message = SmsMessage(
id = it.getLong(idIndex),
threadId = it.getLong(threadIdIndex),
address = it.getString(addressIndex),
person = it.getString(personIndex),
date = it.getLong(dateIndex),
dateSent = it.getLong(dateSentIndex),
read = it.getInt(readIndex) == 1,
type = it.getInt(typeIndex),
body = it.getString(bodyIndex),
status = it.getInt(statusIndex)
)
messages.add(message)
count++
}
}
return messages
}
}

View File

@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
Search("sms.search"),
;
companion object {

View File

@ -287,7 +287,11 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
var enableSms by
rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
mutableStateOf(
smsAvailable &&
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS)
)
}
var enableCallLog by
rememberSaveable {
@ -336,7 +340,9 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
!motionPermissionRequired ||
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
!smsAvailable ||
(isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS))
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
}
@ -698,7 +704,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
requestPermissionToggle(
PermissionToggle.Sms,
checked,
listOf(Manifest.permission.SEND_SMS),
listOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS),
)
}
},
@ -1437,9 +1443,11 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "SMS",
subtitle = "Send text messages via the gateway",
subtitle = "Send and search text messages via the gateway",
checked = enableSms,
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
granted =
isPermissionGranted(context, Manifest.permission.SEND_SMS) &&
isPermissionGranted(context, Manifest.permission.READ_SMS),
onCheckedChange = onSmsChange,
)
}

View File

@ -247,12 +247,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val sendOk = perms[Manifest.permission.SEND_SMS] == true
val readOk = perms[Manifest.permission.READ_SMS] == true
smsPermissionGranted = sendOk && readOk
viewModel.refreshGatewayConnection()
}
@ -287,6 +291,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
}
}
@ -507,7 +513,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
colors = listItemColors,
headlineContent = { Text("SMS", style = mobileHeadline) },
supportingContent = {
Text("Send SMS from this device.", style = mobileCallout)
Text("Send and search SMS from this device.", style = mobileCallout)
},
trailingContent = {
Button(
@ -515,7 +521,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
if (smsPermissionGranted) {
openAppSettings(context)
} else {
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
smsPermissionLauncher.launch(arrayOf(Manifest.permission.SEND_SMS, Manifest.permission.READ_SMS))
}
},
colors = settingsPrimaryButtonColors(),

View File

@ -64,6 +64,7 @@ class InvokeCommandRegistryTest {
OpenClawMotionCommand.Activity.rawValue,
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
OpenClawSmsCommand.Search.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@ -83,7 +84,8 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@ -108,7 +110,8 @@ class InvokeCommandRegistryTest {
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
sendSmsAvailable = true,
readSmsAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
@ -125,7 +128,8 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
sendSmsAvailable = false,
readSmsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = false,
@ -137,10 +141,43 @@ class InvokeCommandRegistryTest {
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
}
@Test
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
val readOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(readSmsAvailable = true),
)
val sendOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(sendSmsAvailable = true),
)
assertTrue(readOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
assertFalse(readOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(sendOnlyCommands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(sendOnlyCommands.contains(OpenClawSmsCommand.Search.rawValue))
}
@Test
fun advertisedCapabilities_includeSmsWhenEitherSmsPathIsAvailable() {
val readOnlyCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(readSmsAvailable = true),
)
val sendOnlyCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(sendSmsAvailable = true),
)
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
smsAvailable: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
@ -149,7 +186,8 @@ class InvokeCommandRegistryTest {
NodeRuntimeFlags(
cameraEnabled = cameraEnabled,
locationEnabled = locationEnabled,
smsAvailable = smsAvailable,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,

View File

@ -88,4 +88,95 @@ class SmsManagerTest {
assertFalse(plan.useMultipart)
assertEquals(listOf("hello"), plan.parts)
}
@Test
fun parseQueryParamsAcceptsEmptyPayload() {
val result = SmsManager.parseQueryParams(null, json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(25, ok.params.limit)
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsRejectsInvalidJson() {
val result = SmsManager.parseQueryParams("not-json", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsRejectsNonObjectJson() {
val result = SmsManager.parseQueryParams("[]", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseQueryParamsParsesLimitAndOffset() {
val result = SmsManager.parseQueryParams("{\"limit\":10,\"offset\":5}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(10, ok.params.limit)
assertEquals(5, ok.params.offset)
}
@Test
fun parseQueryParamsClampsLimitRange() {
val result = SmsManager.parseQueryParams("{\"limit\":300}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(200, ok.params.limit)
}
@Test
fun parseQueryParamsParsesPhoneNumber() {
val result = SmsManager.parseQueryParams("{\"phoneNumber\":\"+1234567890\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("+1234567890", ok.params.phoneNumber)
}
@Test
fun parseQueryParamsParsesContactName() {
val result = SmsManager.parseQueryParams("{\"contactName\":\"lixuankai\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("lixuankai", ok.params.contactName)
}
@Test
fun parseQueryParamsParsesKeyword() {
val result = SmsManager.parseQueryParams("{\"keyword\":\"test\"}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals("test", ok.params.keyword)
}
@Test
fun parseQueryParamsParsesTimeRange() {
val result = SmsManager.parseQueryParams("{\"startTime\":1000,\"endTime\":2000}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(1000L, ok.params.startTime)
assertEquals(2000L, ok.params.endTime)
}
@Test
fun parseQueryParamsParsesType() {
val result = SmsManager.parseQueryParams("{\"type\":1}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(1, ok.params.type)
}
@Test
fun parseQueryParamsParsesReadStatus() {
val result = SmsManager.parseQueryParams("{\"isRead\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(true, ok.params.isRead)
}
}

View File

@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
}

View File

@ -286,6 +286,7 @@ Available families:
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `sms.search`
- `motion.activity`, `motion.pedometer`
Example invokes:

View File

@ -164,4 +164,5 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
- `sms.search`
- `motion.activity`, `motion.pedometer`

View File

@ -348,6 +348,7 @@ describe("resolveNodeCommandAllowlist", () => {
expect(allow.has("device.permissions")).toBe(true);
expect(allow.has("device.health")).toBe(true);
expect(allow.has("callLog.search")).toBe(true);
expect(allow.has("sms.search")).toBe(true);
expect(allow.has("system.notify")).toBe(true);
});

View File

@ -45,6 +45,7 @@ const PHOTOS_COMMANDS = ["photos.latest"];
const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"];
const SMS_COMMANDS = ["sms.search"];
const SMS_DANGEROUS_COMMANDS = ["sms.send"];
// iOS nodes don't implement system.run/which, but they do support notifications.
@ -97,6 +98,7 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
...CALENDAR_COMMANDS,
...CALL_LOG_COMMANDS,
...REMINDERS_COMMANDS,
...SMS_COMMANDS,
...PHOTOS_COMMANDS,
...MOTION_COMMANDS,
],