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:
parent
58cf9b865f
commit
c86de678f3
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,7 @@ enum class OpenClawCameraCommand(val rawValue: String) {
|
||||
|
||||
enum class OpenClawSmsCommand(val rawValue: String) {
|
||||
Send("sms.send"),
|
||||
Search("sms.search"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,4 +90,9 @@ class OpenClawProtocolConstantsTest {
|
||||
fun callLogCommandsUseStableStrings() {
|
||||
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun smsCommandsUseStableStrings() {
|
||||
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@ -286,6 +286,7 @@ Available families:
|
||||
- `contacts.search`, `contacts.add`
|
||||
- `calendar.events`, `calendar.add`
|
||||
- `callLog.search`
|
||||
- `sms.search`
|
||||
- `motion.activity`, `motion.pedometer`
|
||||
|
||||
Example invokes:
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user