diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b9daf4e4b8..c499097a822 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/apps/android/README.md b/apps/android/README.md
index 9c6baf807c9..008941ecda7 100644
--- a/apps/android/README.md
+++ b/apps/android/README.md
@@ -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.
diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml
index c8cf255c127..283daae601f 100644
--- a/apps/android/app/src/main/AndroidManifest.xml
+++ b/apps/android/app/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
+
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(),
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
index 0dd8047596b..3e903098196 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt
@@ -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
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
index 880be1ab4e3..2ed0773bc43 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt
@@ -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(
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt
index 0c76ac24587..f2885e23d73 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt
@@ -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)
+ }
+ }
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt
index 3c5184b0247..0256125b354 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt
@@ -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,
+ 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,
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,
@@ -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,
+ error: String? = null,
+ ): String {
+ val messagesArray = json.encodeToString(messages)
+ val messagesElement = json.parseToJsonElement(messagesArray)
+ val payload = mutableMapOf(
+ "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 {
+ val phoneNumbers = mutableListOf()
+ 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): List {
+ val messages = mutableListOf()
+
+ // Build selection and selectionArgs
+ val selections = mutableListOf()
+ val selectionArgs = mutableListOf()
+
+ // 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
+ }
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
index 3a8e6cdd2be..ceed86f767b 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt
@@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
+ Search("sms.search"),
;
companion object {
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
index ba48b9f3cfa..e51157297f1 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt
@@ -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,
)
}
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
index 22183776366..f78e4535bcb 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt
@@ -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(),
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
index 334fe31cb7f..29decd2f76d 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt
@@ -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,
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt
index c1b98908f08..88c75a40a9a 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt
@@ -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)
+ }
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
index 6069a2cc97c..b30edb80e6f 100644
--- a/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/app/protocol/OpenClawProtocolConstantsTest.kt
@@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
+
+ @Test
+ fun smsCommandsUseStableStrings() {
+ assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
+ }
}
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index 3de435dd59e..f23a2c979cf 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -286,6 +286,7 @@ Available families:
- `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add`
- `callLog.search`
+- `sms.search`
- `motion.activity`, `motion.pedometer`
Example invokes:
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index bfe73ca4526..384b5311c33 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -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`
diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts
index de7f5e81117..f25dbd5b4b6 100644
--- a/src/gateway/gateway-misc.test.ts
+++ b/src/gateway/gateway-misc.test.ts
@@ -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);
});
diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts
index 7310dc4ec73..d4ff5c0f045 100644
--- a/src/gateway/node-command-policy.ts
+++ b/src/gateway/node-command-policy.ts
@@ -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 = {
...CALENDAR_COMMANDS,
...CALL_LOG_COMMANDS,
...REMINDERS_COMMANDS,
+ ...SMS_COMMANDS,
...PHOTOS_COMMANDS,
...MOTION_COMMANDS,
],