fix: finalize android sms search (#50146) (thanks @scaryshark124)

This commit is contained in:
Ayaan Zaidi
2026-03-30 10:20:35 +05:30
parent 74ea42e210
commit 0462a7fd8c
21 changed files with 2087 additions and 190 deletions

View File

@@ -139,6 +139,7 @@ class NodeRuntime(
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
hasRecordAudioPermission = { hasRecordAudioPermission() },
manualTls = { manualTls.value },
@@ -164,6 +165,8 @@ class NodeRuntime(
locationEnabled = { locationMode.value != LocationMode.Off },
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
debugBuild = { BuildConfig.DEBUG },
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },

View File

@@ -185,7 +185,16 @@ class PermissionRequester(private val activity: ComponentActivity) {
when (permission) {
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "SMS"
Manifest.permission.SEND_SMS -> "Send SMS"
Manifest.permission.READ_SMS -> "Read SMS"
Manifest.permission.READ_CONTACTS -> "Read Contacts"
Manifest.permission.WRITE_CONTACTS -> "Write Contacts"
Manifest.permission.READ_CALENDAR -> "Read Calendar"
Manifest.permission.WRITE_CALENDAR -> "Write Calendar"
Manifest.permission.READ_CALL_LOG -> "Read Call Log"
Manifest.permission.ACTIVITY_RECOGNITION -> "Motion Activity"
Manifest.permission.READ_MEDIA_IMAGES -> "Photos"
Manifest.permission.READ_EXTERNAL_STORAGE -> "Photos"
else -> permission
}
}

View File

@@ -19,6 +19,7 @@ class ConnectionManager(
private val motionPedometerAvailable: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val smsSearchPossible: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
@@ -82,6 +83,7 @@ class ConnectionManager(
locationEnabled = locationMode() != LocationMode.Off,
sendSmsAvailable = sendSmsAvailable(),
readSmsAvailable = readSmsAvailable(),
smsSearchPossible = smsSearchPossible(),
callLogAvailable = callLogAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import android.Manifest
import android.app.ActivityManager
import android.content.Context
@@ -15,9 +16,9 @@ import android.os.PowerManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.gateway.GatewaySession
import java.util.Locale
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
@@ -28,6 +29,25 @@ class DeviceHandler(
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
) {
companion object {
internal fun hasAnySmsCapability(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (smsSendGranted || smsReadGranted)
}
internal fun isSmsPromptable(
smsEnabled: Boolean,
telephonyAvailable: Boolean,
smsSendGranted: Boolean,
smsReadGranted: Boolean,
): Boolean {
return smsEnabled && telephonyAvailable && (!smsSendGranted || !smsReadGranted)
}
}
private data class BatterySnapshot(
val status: Int,
val plugged: Int,
@@ -131,6 +151,8 @@ class DeviceHandler(
private fun permissionsPayloadJson(): String {
val canSendSms = appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
val smsSendGranted = hasPermission(Manifest.permission.SEND_SMS)
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
val photosGranted =
if (Build.VERSION.SDK_INT >= 33) {
@@ -174,10 +196,34 @@ class DeviceHandler(
)
put(
"sms",
permissionStateJson(
granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
buildJsonObject {
put(
"status",
JsonPrimitive(
if (hasAnySmsCapability(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)) "granted" else "denied",
),
)
put("promptable", JsonPrimitive(isSmsPromptable(smsEnabled, canSendSms, smsSendGranted, smsReadGranted)))
put(
"capabilities",
buildJsonObject {
put(
"send",
permissionStateJson(
granted = smsEnabled && smsSendGranted && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
put(
"read",
permissionStateJson(
granted = smsEnabled && smsReadGranted && canSendSms,
promptableWhenDenied = smsEnabled && canSendSms,
),
)
},
)
},
)
put(
"notificationListener",

View File

@@ -20,6 +20,7 @@ data class NodeRuntimeFlags(
val locationEnabled: Boolean,
val sendSmsAvailable: Boolean,
val readSmsAvailable: Boolean,
val smsSearchPossible: Boolean,
val callLogAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
@@ -33,6 +34,7 @@ enum class InvokeCommandAvailability {
LocationEnabled,
SendSmsAvailable,
ReadSmsAvailable,
RequestableSmsSearchAvailable,
CallLogAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
@@ -199,7 +201,7 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawSmsCommand.Search.rawValue,
availability = InvokeCommandAvailability.ReadSmsAvailable,
availability = InvokeCommandAvailability.RequestableSmsSearchAvailable,
),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
@@ -244,6 +246,7 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled
InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable

View File

@@ -14,6 +14,44 @@ import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
internal enum class SmsSearchAvailabilityReason {
Available,
PermissionRequired,
Unavailable,
}
internal fun classifySmsSearchAvailability(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): SmsSearchAvailabilityReason {
if (readSmsAvailable) return SmsSearchAvailabilityReason.Available
if (!smsFeatureEnabled || !smsTelephonyAvailable) return SmsSearchAvailabilityReason.Unavailable
return SmsSearchAvailabilityReason.PermissionRequired
}
internal fun smsSearchAvailabilityError(
readSmsAvailable: Boolean,
smsFeatureEnabled: Boolean,
smsTelephonyAvailable: Boolean,
): GatewaySession.InvokeResult? {
return when (
classifySmsSearchAvailability(
readSmsAvailable = readSmsAvailable,
smsFeatureEnabled = smsFeatureEnabled,
smsTelephonyAvailable = smsTelephonyAvailable,
)
) {
SmsSearchAvailabilityReason.Available,
SmsSearchAvailabilityReason.PermissionRequired -> null
SmsSearchAvailabilityReason.Unavailable ->
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
}
class InvokeDispatcher(
private val canvas: CanvasController,
private val cameraHandler: CameraHandler,
@@ -34,6 +72,8 @@ class InvokeDispatcher(
private val locationEnabled: () -> Boolean,
private val sendSmsAvailable: () -> Boolean,
private val readSmsAvailable: () -> Boolean,
private val smsFeatureEnabled: () -> Boolean,
private val smsTelephonyAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val refreshNodeCanvasCapability: suspend () -> Boolean,
@@ -268,15 +308,13 @@ class InvokeDispatcher(
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable ->
if (readSmsAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "SMS_UNAVAILABLE",
message = "SMS_UNAVAILABLE: SMS not available on this device",
)
}
InvokeCommandAvailability.ReadSmsAvailable,
InvokeCommandAvailability.RequestableSmsSearchAvailable ->
smsSearchAvailabilityError(
readSmsAvailable = readSmsAvailable(),
smsFeatureEnabled = smsFeatureEnabled(),
smsTelephonyAvailable = smsTelephonyAvailable(),
)
InvokeCommandAvailability.CallLogAvailable ->
if (callLogAvailable()) {
null

View File

@@ -9,23 +9,28 @@ class SmsHandler(
val res = sms.send(paramsJson)
if (res.ok) {
return GatewaySession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
return GatewaySession.InvokeResult.error(code = code, message = error)
}
return errorResult(res.error, defaultCode = "SMS_SEND_FAILED")
}
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)
}
return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED")
}
private fun errorResult(error: String?, defaultCode: String): GatewaySession.InvokeResult {
val rawMessage = error ?: defaultCode
val idx = rawMessage.indexOf(':')
val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode
val message =
if (idx > 0 && code == rawMessage.substring(0, idx).trim()) {
rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage }
} else {
rawMessage
}
return GatewaySession.InvokeResult.error(code = code, message = message)
}
}

View File

@@ -3,21 +3,21 @@ 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 ai.openclaw.app.PermissionRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
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.Serializable
import ai.openclaw.app.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
@@ -39,7 +39,7 @@ class SmsManager(private val context: Context) {
)
/**
* Represents a single SMS message
* Represents a single SMS message.
*/
@Serializable
data class SmsMessage(
@@ -53,6 +53,7 @@ class SmsManager(private val context: Context) {
val type: Int,
val body: String?,
val status: Int,
val transportType: String? = null,
)
data class SearchResult(
@@ -62,6 +63,13 @@ class SmsManager(private val context: Context) {
val payloadJson: String,
)
internal data class QueryMetadata(
val mmsRequested: Boolean,
val mmsEligible: Boolean,
val mmsAttempted: Boolean,
val mmsIncluded: Boolean,
)
internal data class ParsedParams(
val to: String,
val message: String,
@@ -84,6 +92,8 @@ class SmsManager(private val context: Context) {
val keyword: String? = null,
val type: Int? = null,
val isRead: Boolean? = null,
val includeMms: Boolean = false,
val conversationReview: Boolean = false,
val limit: Int = DEFAULT_SMS_LIMIT,
val offset: Int = 0,
)
@@ -100,6 +110,11 @@ class SmsManager(private val context: Context) {
companion object {
private const val DEFAULT_SMS_LIMIT = 25
internal const val MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW = 500
private const val MMS_SMS_BY_PHONE_BASE = "content://mms-sms/messages/byphone"
private const val MMS_CONTENT_BASE = "content://mms"
private const val MMS_PART_URI = "content://mms/part"
private val PHONE_FORMATTING_REGEX = Regex("""[\s\-()]""")
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
@@ -157,31 +172,333 @@ class SmsManager(private val context: Context) {
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 includeMms = (obj["includeMms"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false
val conversationReview = (obj["conversationReview"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false
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,
))
return QueryParseResult.Ok(
QueryParams(
startTime = startTime,
endTime = endTime,
contactName = contactName,
phoneNumber = phoneNumber,
keyword = keyword,
type = type,
isRead = isRead,
includeMms = includeMms,
conversationReview = conversationReview,
limit = limit,
offset = offset,
)
)
}
private fun normalizePhoneNumber(phone: String): String {
return phone.replace(Regex("""[\s\-()]"""), "")
return phone.replace(PHONE_FORMATTING_REGEX, "")
}
internal fun normalizePhoneNumberOrNull(phone: String?): String? {
val normalized = phone?.let(::normalizePhoneNumber)?.trim().orEmpty()
if (normalized.isEmpty()) {
return null
}
val digits = toByPhoneLookupNumber(normalized)
return normalized.takeIf { digits.isNotEmpty() }
}
internal fun sanitizeContactPhoneNumberOrNull(phone: String?): String? {
val normalized = normalizePhoneNumberOrNull(phone) ?: return null
return normalized.takeUnless(::hasSqlLikeWildcard)
}
internal fun shouldPromptForContactNameSearchPermission(
contactName: String?,
phoneNumber: String?,
hasReadContactsPermission: Boolean,
): Boolean {
return !contactName.isNullOrEmpty() && phoneNumber.isNullOrEmpty() && !hasReadContactsPermission
}
internal fun mapMmsMsgBoxToSearchType(msgBox: Int?): Int? {
return when (msgBox) {
1 -> 1 // inbox
2 -> 2 // sent
3 -> 3 // draft
4 -> 4 // outbox
5 -> 5 // failed
6 -> 6 // queued
else -> null
}
}
internal fun escapeSqlLikeLiteral(value: String): String {
return buildString(value.length) {
for (ch in value) {
when (ch) {
'\\', '%', '_' -> {
append('\\')
append(ch)
}
else -> append(ch)
}
}
}
}
internal fun buildContactNameLikeSelection(): String {
return "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ? ESCAPE '\\'"
}
internal fun buildContactNameLikeArg(contactName: String): String {
return "%${escapeSqlLikeLiteral(contactName)}%"
}
internal fun buildKeywordLikeSelection(): String {
return "${Telephony.Sms.BODY} LIKE ? ESCAPE '\\'"
}
internal fun buildKeywordLikeArg(keyword: String): String {
return "%${escapeSqlLikeLiteral(keyword)}%"
}
internal fun buildMixedByPhoneProjection(): Array<String> {
return arrayOf(
"_id",
"thread_id",
"transport_type",
"address",
"date",
"date_sent",
"read",
"type",
"body",
"status",
)
}
internal fun hasSqlLikeWildcard(value: String): Boolean {
return value.contains('%') || value.contains('_')
}
internal fun isExplicitPhoneInputInvalid(rawPhone: String?, normalizedPhone: String?): Boolean {
if (rawPhone.isNullOrBlank()) {
return false
}
if (normalizedPhone == null) {
return true
}
return hasSqlLikeWildcard(normalizedPhone)
}
internal fun resolveMixedByPhoneRowStatus(transportType: String?, smsStatus: Int?): Int {
return if (transportType.equals("mms", ignoreCase = true)) -1 else (smsStatus ?: 0)
}
internal fun resolveMixedByPhoneRowAddress(
providerAddress: String?,
phoneNumber: String,
mmsAddress: String? = null,
): String? {
val resolvedMmsAddress = normalizePhoneNumberOrNull(mmsAddress)
if (resolvedMmsAddress != null) {
return resolvedMmsAddress
}
val resolvedProviderAddress = normalizePhoneNumberOrNull(providerAddress)
return resolvedProviderAddress ?: phoneNumber
}
internal fun selectPreferredMmsAddress(
addressRows: List<Pair<String?, Int?>>,
lookupNumber: String,
): String? {
val lookupDigits = toByPhoneLookupNumber(lookupNumber)
val normalizedRows = addressRows.mapNotNull { (address, type) ->
val normalized = normalizePhoneNumberOrNull(address) ?: return@mapNotNull null
val digits = toByPhoneLookupNumber(normalized)
if (digits.isBlank()) return@mapNotNull null
Triple(normalized, digits, type)
}
fun firstPreferred(vararg types: Int): String? {
return normalizedRows.firstOrNull { row ->
(types.isEmpty() || types.contains(row.third ?: -1)) && row.second != lookupDigits
}?.first
}
return firstPreferred(137)
?: firstPreferred(151, 130, 129)
?: firstPreferred()
?: normalizedRows.firstOrNull()?.first
}
internal fun shouldUseConversationReviewByPhoneMode(
params: QueryParams,
resolvedPhoneNumbers: List<String> = emptyList(),
): Boolean {
val hasExplicitPhoneNumber = !params.phoneNumber.isNullOrEmpty()
val hasSingleResolvedPhoneNumber = resolvedPhoneNumbers.size == 1
return params.conversationReview && params.includeMms && (hasExplicitPhoneNumber || hasSingleResolvedPhoneNumber)
}
internal fun effectiveSearchParams(
params: QueryParams,
resolvedPhoneNumbers: List<String> = emptyList(),
): QueryParams {
if (!shouldUseConversationReviewByPhoneMode(params, resolvedPhoneNumbers)) return params
val reviewLimit = maxOf(params.limit, 25)
return params.copy(limit = reviewLimit)
}
internal fun resolveSearchParams(
params: QueryParams,
normalizedPhoneNumber: String?,
resolvedPhoneNumbers: List<String> = emptyList(),
): QueryParams {
val effectivePhoneNumber = normalizedPhoneNumber ?: resolvedPhoneNumbers.singleOrNull()
val normalizedParams = params.copy(phoneNumber = effectivePhoneNumber)
return effectiveSearchParams(normalizedParams, resolvedPhoneNumbers)
}
internal fun toByPhoneLookupNumber(phone: String): String {
return phone.filter { it.isDigit() }
}
internal fun normalizeProviderDateMillis(rawDate: Long): Long {
return if (rawDate in 1..99_999_999_999L) rawDate * 1000L else rawDate
}
internal fun canonicalizeMixedPathPhoneFilters(phoneNumbers: List<String>): List<String> {
return phoneNumbers
.map(::toByPhoneLookupNumber)
.filter { it.isNotBlank() }
.distinct()
}
internal fun requestedMixedByPhoneCandidateWindow(params: QueryParams): Long {
return params.offset.toLong() + params.limit.toLong()
}
internal fun exceedsMixedByPhoneCandidateWindow(
params: QueryParams,
allPhoneNumbers: List<String>,
): Boolean {
return params.includeMms &&
allPhoneNumbers.size == 1 &&
requestedMixedByPhoneCandidateWindow(params) > MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW
}
internal fun mixedByPhoneWindowError(): String {
return "INVALID_REQUEST: includeMms offset+limit exceeds supported window ($MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW)"
}
internal fun isMmsTransportRow(message: SmsMessage): Boolean {
return message.transportType.equals("mms", ignoreCase = true)
}
internal fun shouldHydrateMmsByPhoneRow(transportType: String?, body: String?, type: Int): Boolean {
return transportType.equals("mms", ignoreCase = true) && (body.isNullOrBlank() || type == 0)
}
internal fun buildQueryMetadata(
params: QueryParams,
allPhoneNumbers: List<String>,
messages: List<SmsMessage>,
): QueryMetadata {
val mmsRequested = params.includeMms
val mmsEligible = mmsRequested && allPhoneNumbers.size == 1
val mmsAttempted = mmsEligible
val mmsIncluded = mmsAttempted && messages.any(::isMmsTransportRow)
return QueryMetadata(
mmsRequested = mmsRequested,
mmsEligible = mmsEligible,
mmsAttempted = mmsAttempted,
mmsIncluded = mmsIncluded,
)
}
internal fun compareByPhoneCandidateOrder(left: SmsMessage, right: SmsMessage): Int {
return when {
left.date != right.date -> right.date.compareTo(left.date)
left.id != right.id -> right.id.compareTo(left.id)
else -> 0
}
}
internal fun buildMixedRowIdentity(rowId: Long, transportType: String?): String {
return "${transportType?.ifBlank { "unknown" } ?: "unknown"}:$rowId"
}
internal fun upsertTopDateCandidates(
candidates: MutableList<Pair<String, SmsMessage>>,
identityKey: String,
message: SmsMessage,
maxCandidates: Int,
) {
if (maxCandidates <= 0) {
return
}
candidates.removeAll { existing -> existing.first == identityKey }
candidates.add(identityKey to message)
candidates.sortWith { left, right -> compareByPhoneCandidateOrder(left.second, right.second) }
while (candidates.size > maxCandidates) {
candidates.removeAt(candidates.lastIndex)
}
}
internal fun materializeByPhoneCandidate(
candidates: MutableMap<String, SmsMessage>,
identityKey: String,
message: SmsMessage,
) {
candidates[identityKey] = message
}
internal fun collectMixedByPhoneCandidate(
topCandidates: MutableList<Pair<String, SmsMessage>>,
materializedCandidates: MutableMap<String, SmsMessage>,
identityKey: String,
message: SmsMessage,
maxCandidates: Int,
reviewMode: Boolean,
) {
if (reviewMode) {
materializeByPhoneCandidate(materializedCandidates, identityKey, message)
} else {
upsertTopDateCandidates(topCandidates, identityKey, message, maxCandidates)
}
}
internal fun pageMixedByPhoneCandidates(
topCandidates: Collection<Pair<String, SmsMessage>>,
materializedCandidates: Map<String, SmsMessage>,
params: QueryParams,
reviewMode: Boolean,
): List<SmsMessage> {
return if (reviewMode) {
pageByPhoneCandidates(materializedCandidates.values, params)
} else {
pageByPhoneCandidates(topCandidates.map { it.second }, params)
}
}
internal fun pageByPhoneCandidates(
candidates: Collection<SmsMessage>,
params: QueryParams,
): List<SmsMessage> {
return candidates
.sortedWith(::compareByPhoneCandidateOrder)
.drop(params.offset)
.take(params.limit)
}
internal fun buildSendPlan(
@@ -214,14 +531,21 @@ class SmsManager(private val context: Context) {
ok: Boolean,
messages: List<SmsMessage>,
error: String? = null,
queryMetadata: QueryMetadata? = 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
"messages" to messagesElement,
)
queryMetadata?.let {
payload["mmsRequested"] = JsonPrimitive(it.mmsRequested)
payload["mmsEligible"] = JsonPrimitive(it.mmsEligible)
payload["mmsAttempted"] = JsonPrimitive(it.mmsAttempted)
payload["mmsIncluded"] = JsonPrimitive(it.mmsIncluded)
}
if (!ok && error != null) {
payload["error"] = JsonPrimitive(error)
}
@@ -254,10 +578,14 @@ class SmsManager(private val context: Context) {
return hasSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
fun canSearchSms(): Boolean {
return hasReadSmsPermission() && hasTelephonyFeature()
}
fun canReadSms(): Boolean {
return canSearchSms()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
@@ -302,19 +630,19 @@ class SmsManager(private val context: Context) {
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
if (plan.useMultipart) {
smsManager.sendMultipartTextMessage(
params.to, // destination
null, // service center (null = default)
ArrayList(plan.parts), // message parts
null, // sent intents
null, // delivery intents
params.to,
null,
ArrayList(plan.parts),
null,
null,
)
} else {
smsManager.sendTextMessage(
params.to, // destination
null, // service center (null = default)
params.message,// message
null, // sent intent
null, // delivery intent
params.to,
null,
params.message,
null,
null,
)
}
@@ -334,6 +662,82 @@ class SmsManager(private val context: Context) {
}
}
/**
* Search SMS messages with the specified parameters.
*/
suspend fun search(paramsJson: String?): SearchResult = withContext(Dispatchers.IO) {
if (!hasTelephonyFeature()) {
return@withContext queryError("SMS_UNAVAILABLE: telephony not available")
}
if (!ensureReadSmsPermission()) {
return@withContext queryError("SMS_PERMISSION_REQUIRED: grant READ_SMS permission")
}
val parseResult = parseQueryParams(paramsJson, json)
if (parseResult is QueryParseResult.Error) {
return@withContext queryError(parseResult.error)
}
val parsedParams = (parseResult as QueryParseResult.Ok).params
val normalizedPhoneNumber = normalizePhoneNumberOrNull(parsedParams.phoneNumber)
if (isExplicitPhoneInputInvalid(parsedParams.phoneNumber, normalizedPhoneNumber)) {
val error =
if (!parsedParams.phoneNumber.isNullOrBlank() && normalizedPhoneNumber != null && hasSqlLikeWildcard(normalizedPhoneNumber)) {
"INVALID_REQUEST: phoneNumber must not contain SQL LIKE wildcard characters"
} else {
"INVALID_REQUEST: phoneNumber must contain at least one digit"
}
return@withContext queryError(error)
}
val normalizedParams = resolveSearchParams(parsedParams, normalizedPhoneNumber)
return@withContext try {
val contactsPermissionGranted = hasReadContactsPermission()
val shouldPromptForContactsPermission =
shouldPromptForContactNameSearchPermission(
contactName = normalizedParams.contactName,
phoneNumber = normalizedParams.phoneNumber,
hasReadContactsPermission = contactsPermissionGranted,
)
val phoneNumbers = if (!normalizedParams.contactName.isNullOrEmpty()) {
if (contactsPermissionGranted || (shouldPromptForContactsPermission && ensureReadContactsPermission())) {
getPhoneNumbersFromContactName(normalizedParams.contactName)
} else if (shouldPromptForContactsPermission) {
return@withContext queryError("CONTACTS_PERMISSION_REQUIRED: grant READ_CONTACTS permission")
} else {
emptyList()
}
} else {
emptyList()
}
val params = resolveSearchParams(parsedParams, normalizedPhoneNumber, phoneNumbers)
val mixedPathPhoneFilters = if (!params.phoneNumber.isNullOrEmpty()) {
canonicalizeMixedPathPhoneFilters(phoneNumbers + params.phoneNumber)
} else {
canonicalizeMixedPathPhoneFilters(phoneNumbers)
}
if (exceedsMixedByPhoneCandidateWindow(params, mixedPathPhoneFilters)) {
val error = mixedByPhoneWindowError()
return@withContext queryError(error)
}
if (!params.contactName.isNullOrEmpty() && phoneNumbers.isEmpty() && params.phoneNumber.isNullOrEmpty()) {
val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, emptyList())
return@withContext queryOk(emptyList(), queryMetadata)
}
val messages = querySmsMessages(params, phoneNumbers)
val queryMetadata = buildQueryMetadata(params, mixedPathPhoneFilters, messages)
queryOk(messages, queryMetadata)
} catch (e: SecurityException) {
queryError("SMS_PERMISSION_REQUIRED: ${e.message}")
} catch (e: Throwable) {
queryError("SMS_QUERY_FAILED: ${e.message ?: "unknown error"}")
}
}
private suspend fun ensureSmsPermission(): Boolean {
if (hasSmsPermission()) return true
val requester = permissionRequester ?: return false
@@ -375,98 +779,31 @@ class SmsManager(private val context: Context) {
)
}
/**
* 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"}")
)
}
private fun queryOk(
messages: List<SmsMessage>,
queryMetadata: QueryMetadata? = null,
): SearchResult {
return SearchResult(
ok = true,
messages = messages,
error = null,
payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages, queryMetadata = queryMetadata),
)
}
private fun queryError(error: String): SearchResult {
return SearchResult(
ok = false,
messages = emptyList(),
error = error,
payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = 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 selection = buildContactNameLikeSelection()
val selectionArgs = arrayOf(buildContactNameLikeArg(contactName))
val cursor = context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
@@ -480,26 +817,19 @@ class SmsManager(private val context: Context) {
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (it.moveToNext()) {
val number = it.getString(numberIndex)
if (!number.isNullOrBlank()) {
phoneNumbers.add(normalizePhoneNumber(number))
}
sanitizeContactPhoneNumberOrNull(number)?.let(phoneNumbers::add)
}
}
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())
@@ -509,11 +839,17 @@ class SmsManager(private val context: Context) {
selectionArgs.add(params.endTime.toString())
}
// Phone numbers (from contact name or direct phone number)
val allPhoneNumbers = if (!params.phoneNumber.isNullOrEmpty()) {
phoneNumbers + normalizePhoneNumber(params.phoneNumber)
(phoneNumbers + normalizePhoneNumber(params.phoneNumber)).distinct()
} else {
phoneNumbers
phoneNumbers.distinct()
}
val mixedPathPhoneFilters = canonicalizeMixedPathPhoneFilters(allPhoneNumbers)
// Unified SMS+MMS query path is opt-in to keep sms.search semantics
// stable by default. Use includeMms=true for by-phone provider behavior.
if (params.includeMms && mixedPathPhoneFilters.size == 1) {
return querySmsMmsMessagesByPhone(mixedPathPhoneFilters.first(), params)
}
if (allPhoneNumbers.isNotEmpty()) {
@@ -526,19 +862,16 @@ class SmsManager(private val context: Context) {
}
}
// Keyword in body
if (!params.keyword.isNullOrEmpty()) {
selections.add("${Telephony.Sms.BODY} LIKE ?")
selectionArgs.add("%${params.keyword}%")
selections.add(buildKeywordLikeSelection())
selectionArgs.add(buildKeywordLikeArg(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")
@@ -556,7 +889,8 @@ class SmsManager(private val context: Context) {
null
}
// Query SMS with SQL-level LIMIT and OFFSET to avoid loading all matching rows
// Android SMS providers still honor LIMIT/OFFSET through sortOrder on this path.
// Keep the bounded interpolation here because parseQueryParams already clamps both values.
val sortOrder = "${Telephony.Sms.DATE} DESC LIMIT ${params.limit} OFFSET ${params.offset}"
val cursor = context.contentResolver.query(
Telephony.Sms.CONTENT_URI,
@@ -570,7 +904,7 @@ class SmsManager(private val context: Context) {
Telephony.Sms.READ,
Telephony.Sms.TYPE,
Telephony.Sms.BODY,
Telephony.Sms.STATUS
Telephony.Sms.STATUS,
),
selection,
selectionArgsArray,
@@ -601,7 +935,7 @@ class SmsManager(private val context: Context) {
read = it.getInt(readIndex) == 1,
type = it.getInt(typeIndex),
body = it.getString(bodyIndex),
status = it.getInt(statusIndex)
status = it.getInt(statusIndex),
)
messages.add(message)
count++
@@ -610,4 +944,184 @@ class SmsManager(private val context: Context) {
return messages
}
private fun querySmsMmsMessagesByPhone(phoneNumber: String, params: QueryParams): List<SmsMessage> {
val lookupNumber = toByPhoneLookupNumber(phoneNumber)
if (lookupNumber.isBlank()) {
return emptyList()
}
val uri = Uri.parse("$MMS_SMS_BY_PHONE_BASE/${Uri.encode(lookupNumber)}")
val projection = buildMixedByPhoneProjection()
val maxCandidates = params.offset + params.limit
if (maxCandidates <= 0) {
return emptyList()
}
val reviewMode = shouldUseConversationReviewByPhoneMode(params)
val topCandidates = mutableListOf<Pair<String, SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsMessage>()
val cursor = context.contentResolver.query(uri, projection, null, null, "date DESC")
cursor?.use {
val idIndex = it.getColumnIndex("_id")
val threadIdIndex = it.getColumnIndex("thread_id")
val transportTypeIndex = it.getColumnIndex("transport_type")
val addressIndex = it.getColumnIndex("address")
val dateIndex = it.getColumnIndex("date")
val dateSentIndex = it.getColumnIndex("date_sent")
val readIndex = it.getColumnIndex("read")
val typeIndex = it.getColumnIndex("type")
val bodyIndex = it.getColumnIndex("body")
val statusIndex = it.getColumnIndex("status")
while (it.moveToNext()) {
val id = if (idIndex >= 0 && !it.isNull(idIndex)) it.getLong(idIndex) else continue
val rawDate = if (dateIndex >= 0 && !it.isNull(dateIndex)) it.getLong(dateIndex) else 0L
val dateMs = normalizeProviderDateMillis(rawDate)
if (params.startTime != null && dateMs < params.startTime) continue
if (params.endTime != null && dateMs > params.endTime) continue
val threadId = if (threadIdIndex >= 0 && !it.isNull(threadIdIndex)) it.getLong(threadIdIndex) else 0L
val transportType = if (transportTypeIndex >= 0 && !it.isNull(transportTypeIndex)) it.getString(transportTypeIndex) else null
val providerAddress = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null
val mmsAddress = if (transportType.equals("mms", ignoreCase = true)) getMmsAddress(id, phoneNumber) else null
val address = resolveMixedByPhoneRowAddress(providerAddress, phoneNumber, mmsAddress)
var read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else true
var type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else 0
var body = if (bodyIndex >= 0 && !it.isNull(bodyIndex)) it.getString(bodyIndex) else null
val smsStatus = if (statusIndex >= 0 && !it.isNull(statusIndex)) it.getInt(statusIndex) else null
// Only MMS transport rows are allowed to hydrate from MMS storage.
if (shouldHydrateMmsByPhoneRow(transportType, body, type)) {
body = body?.takeIf { msg -> msg.isNotBlank() } ?: getMmsTextBody(id)
val mmsMeta = getMmsMeta(id)
if (type == 0) {
type = mmsMeta.first ?: type
}
if (readIndex < 0 || it.isNull(readIndex)) {
read = mmsMeta.second ?: read
}
}
val dateSentRaw = if (dateSentIndex >= 0 && !it.isNull(dateSentIndex)) it.getLong(dateSentIndex) else 0L
val dateSentMs = normalizeProviderDateMillis(dateSentRaw)
if (!params.keyword.isNullOrEmpty()) {
val keyword = params.keyword
if (body.isNullOrEmpty() || !body.contains(keyword, ignoreCase = true)) {
continue
}
}
if (params.type != null && type != params.type) continue
if (params.isRead != null && read != params.isRead) continue
val message = SmsMessage(
id = id,
threadId = threadId,
address = address,
person = null,
date = dateMs,
dateSent = dateSentMs,
read = read,
type = type,
body = body,
status = resolveMixedByPhoneRowStatus(transportType, smsStatus),
transportType = transportType,
)
val identityKey = buildMixedRowIdentity(id, transportType)
collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = identityKey,
message = message,
maxCandidates = maxCandidates,
reviewMode = reviewMode,
)
}
}
return pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params,
reviewMode = reviewMode,
)
}
private fun getMmsTextBody(messageId: Long): String? {
val cursor = context.contentResolver.query(
Uri.parse(MMS_PART_URI),
arrayOf("text", "ct"),
"mid=?",
arrayOf(messageId.toString()),
null,
)
cursor?.use {
val textIndex = it.getColumnIndex("text")
val ctIndex = it.getColumnIndex("ct")
while (it.moveToNext()) {
val contentType = if (ctIndex >= 0 && !it.isNull(ctIndex)) it.getString(ctIndex) else null
if (contentType != null && contentType != "text/plain") continue
val text = if (textIndex >= 0 && !it.isNull(textIndex)) it.getString(textIndex) else null
if (!text.isNullOrBlank()) return text
}
}
return null
}
private fun getMmsMeta(messageId: Long): Pair<Int?, Boolean?> {
val cursor = context.contentResolver.query(
Uri.parse("$MMS_CONTENT_BASE/$messageId"),
arrayOf("msg_box", "read"),
null,
null,
null,
)
cursor?.use {
if (it.moveToFirst()) {
val msgBoxIndex = it.getColumnIndex("msg_box")
val readIndex = it.getColumnIndex("read")
val msgBox = if (msgBoxIndex >= 0 && !it.isNull(msgBoxIndex)) it.getInt(msgBoxIndex) else null
val mappedType = mapMmsMsgBoxToSearchType(msgBox)
val read = if (readIndex >= 0 && !it.isNull(readIndex)) it.getInt(readIndex) == 1 else null
return mappedType to read
}
}
return null to null
}
private fun getMmsAddress(messageId: Long, phoneNumber: String): String? {
val lookupNumber = toByPhoneLookupNumber(phoneNumber)
if (lookupNumber.isBlank()) {
return null
}
val cursor = context.contentResolver.query(
Uri.parse("$MMS_CONTENT_BASE/$messageId/addr"),
arrayOf("address", "type"),
null,
null,
null,
)
cursor?.use {
val addressIndex = it.getColumnIndex("address")
val typeIndex = it.getColumnIndex("type")
val addressRows = mutableListOf<Pair<String?, Int?>>()
while (it.moveToNext()) {
val address = if (addressIndex >= 0 && !it.isNull(addressIndex)) it.getString(addressIndex) else null
val type = if (typeIndex >= 0 && !it.isNull(typeIndex)) it.getInt(typeIndex) else null
addressRows.add(address to type)
}
return selectPreferredMmsAddress(addressRows, lookupNumber)
}
return null
}
}

View File

@@ -316,10 +316,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
val sendOk = perms[Manifest.permission.SEND_SMS] == true
val readOk = perms[Manifest.permission.READ_SMS] == true
smsPermissionGranted = sendOk && readOk
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) ==
PackageManager.PERMISSION_GRANTED
viewModel.refreshGatewayConnection()
}
@@ -609,7 +611,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
shape = RoundedCornerShape(14.dp),
) {
Text(
if (smsPermissionGranted) "Manage" else "Grant",
if (smsPermissionGranted) {
"Manage"
} else {
"Grant"
},
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
@@ -1284,5 +1290,5 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
sensorManager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
}

View File

@@ -101,9 +101,74 @@ class DeviceHandlerTest {
val status = state.getValue("status").jsonPrimitive.content
assertTrue(status == "granted" || status == "denied")
state.getValue("promptable").jsonPrimitive.boolean
if (key == "sms") {
val capabilities = state.getValue("capabilities").jsonObject
for (capabilityKey in listOf("send", "read")) {
val capability = capabilities.getValue(capabilityKey).jsonObject
val capabilityStatus = capability.getValue("status").jsonPrimitive.content
assertTrue(capabilityStatus == "granted" || capabilityStatus == "denied")
capability.getValue("promptable").jsonPrimitive.boolean
}
}
}
}
@Test
fun smsTopLevelStatusTreatsSendOnlyPartialGrantAsGranted() {
assertTrue(
DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = false,
),
)
}
@Test
fun smsTopLevelStatusTreatsReadOnlyPartialGrantAsGranted() {
assertTrue(
DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = true,
),
)
}
@Test
fun smsTopLevelStatusTreatsNoSmsGrantAsDenied() {
assertTrue(
!DeviceHandler.hasAnySmsCapability(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = false,
smsReadGranted = false,
),
)
}
@Test
fun smsTopLevelPromptableStaysTrueUntilBothSmsPermissionsAreGranted() {
assertTrue(
DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = false,
),
)
assertTrue(
!DeviceHandler.isSmsPromptable(
smsEnabled = true,
telephonyAvailable = true,
smsSendGranted = true,
smsReadGranted = true,
),
)
}
@Test
fun handleDeviceHealth_returnsExpectedShape() {
val handler = DeviceHandler(appContext())

View File

@@ -86,6 +86,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
smsSearchPossible = true,
callLogAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
@@ -113,6 +114,7 @@ class InvokeCommandRegistryTest {
locationEnabled = true,
sendSmsAvailable = true,
readSmsAvailable = true,
smsSearchPossible = true,
callLogAvailable = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
@@ -132,6 +134,7 @@ class InvokeCommandRegistryTest {
locationEnabled = false,
sendSmsAvailable = false,
readSmsAvailable = false,
smsSearchPossible = false,
callLogAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = true,
@@ -148,17 +151,22 @@ class InvokeCommandRegistryTest {
fun advertisedCommands_splitsSmsSendAndSearchAvailability() {
val readOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(readSmsAvailable = true),
defaultFlags(readSmsAvailable = true, smsSearchPossible = true),
)
val sendOnlyCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(sendSmsAvailable = true),
)
val requestableSearchCommands =
InvokeCommandRegistry.advertisedCommands(
defaultFlags(smsSearchPossible = 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))
assertTrue(requestableSearchCommands.contains(OpenClawSmsCommand.Search.rawValue))
}
@Test
@@ -171,9 +179,14 @@ class InvokeCommandRegistryTest {
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(sendSmsAvailable = true),
)
val requestableSearchCapabilities =
InvokeCommandRegistry.advertisedCapabilities(
defaultFlags(smsSearchPossible = true),
)
assertTrue(readOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue))
assertFalse(requestableSearchCapabilities.contains(OpenClawCapability.Sms.rawValue))
}
@Test
@@ -195,6 +208,7 @@ class InvokeCommandRegistryTest {
locationEnabled: Boolean = false,
sendSmsAvailable: Boolean = false,
readSmsAvailable: Boolean = false,
smsSearchPossible: Boolean = false,
callLogAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
@@ -206,6 +220,7 @@ class InvokeCommandRegistryTest {
locationEnabled = locationEnabled,
sendSmsAvailable = sendSmsAvailable,
readSmsAvailable = readSmsAvailable,
smsSearchPossible = smsSearchPossible,
callLogAvailable = callLogAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,

View File

@@ -0,0 +1,79 @@
package ai.openclaw.app.node
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class InvokeDispatcherTest {
@Test
fun classifySmsSearchAvailability_returnsAvailable_whenReadSmsIsAvailable() {
assertEquals(
SmsSearchAvailabilityReason.Available,
classifySmsSearchAvailability(
readSmsAvailable = true,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsUnavailable_whenSmsFeatureDisabled() {
assertEquals(
SmsSearchAvailabilityReason.Unavailable,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsUnavailable_whenTelephonyUnavailable() {
assertEquals(
SmsSearchAvailabilityReason.Unavailable,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = false,
),
)
}
@Test
fun classifySmsSearchAvailability_returnsPermissionRequired_whenOnlyReadSmsPermissionIsMissing() {
assertEquals(
SmsSearchAvailabilityReason.PermissionRequired,
classifySmsSearchAvailability(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun smsSearchAvailabilityError_returnsNull_whenReadSmsPermissionIsRequestable() {
assertNull(
smsSearchAvailabilityError(
readSmsAvailable = false,
smsFeatureEnabled = true,
smsTelephonyAvailable = true,
),
)
}
@Test
fun smsSearchAvailabilityError_returnsUnavailable_whenSmsSearchIsImpossible() {
val result =
smsSearchAvailabilityError(
readSmsAvailable = false,
smsFeatureEnabled = false,
smsTelephonyAvailable = true,
)
assertEquals("SMS_UNAVAILABLE", result?.error?.code)
assertEquals("SMS_UNAVAILABLE: SMS not available on this device", result?.error?.message)
}
}

View File

@@ -1,15 +1,39 @@
package ai.openclaw.app.node
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SmsManagerTest {
private val json = SmsManager.JsonConfig
private fun smsMessage(
id: Long,
date: Long,
status: Int = 0,
body: String? = "msg-$id",
transportType: String? = null,
): SmsManager.SmsMessage =
SmsManager.SmsMessage(
id = id,
threadId = 1L,
address = "+15551234567",
person = null,
date = date,
dateSent = date,
read = true,
type = 1,
body = body,
status = status,
transportType = transportType,
)
@Test
fun parseParamsRejectsEmptyPayload() {
val result = SmsManager.parseParams("", json)
@@ -61,6 +85,73 @@ class SmsManagerTest {
assertEquals("Hello", ok.params.message)
}
@Test
fun parseQueryParamsDefaultsWhenPayloadEmpty() {
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)
assertEquals(null, ok.params.startTime)
assertEquals(null, ok.params.endTime)
}
@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 parseQueryParamsRejectsInvertedTimeRange() {
val result = SmsManager.parseQueryParams("{\"startTime\":200,\"endTime\":100}", json)
assertTrue(result is SmsManager.QueryParseResult.Error)
val error = result as SmsManager.QueryParseResult.Error
assertEquals("INVALID_REQUEST: startTime must be less than or equal to endTime", error.error)
}
@Test
fun parseQueryParamsClampsLimitAndOffset() {
val result = SmsManager.parseQueryParams("{\"limit\":999,\"offset\":-5}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(200, ok.params.limit)
assertEquals(0, ok.params.offset)
}
@Test
fun parseQueryParamsParsesAllSupportedFields() {
val result = SmsManager.parseQueryParams(
"""
{
"startTime": 100,
"endTime": 200,
"contactName": " Leah ",
"phoneNumber": " +1555 ",
"keyword": " ping ",
"type": 1,
"isRead": true,
"limit": 10,
"offset": 2
}
""".trimIndent(),
json,
)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(100L, ok.params.startTime)
assertEquals(200L, ok.params.endTime)
assertEquals("Leah", ok.params.contactName)
assertEquals("+1555", ok.params.phoneNumber)
assertEquals("ping", ok.params.keyword)
assertEquals(1, ok.params.type)
assertEquals(true, ok.params.isRead)
assertEquals(10, ok.params.limit)
assertEquals(2, ok.params.offset)
}
@Test
fun buildPayloadJsonEscapesFields() {
val payload = SmsManager.buildPayloadJson(
@@ -75,6 +166,69 @@ class SmsManagerTest {
assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesCountAndMessages() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = true,
messages = listOf(
SmsManager.SmsMessage(
id = 1L,
threadId = 2L,
address = "+1555",
person = null,
date = 123L,
dateSent = 124L,
read = true,
type = 1,
body = "hello",
status = 0,
)
),
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("true", parsed["ok"]?.jsonPrimitive?.content)
assertEquals(1, parsed["count"]?.jsonPrimitive?.content?.toInt())
val messages = parsed["messages"]?.jsonArray
assertEquals(1, messages?.size)
assertEquals("hello", messages?.get(0)?.jsonObject?.get("body")?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesErrorOnFailure() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = false,
messages = emptyList(),
error = "SMS_QUERY_FAILED: nope",
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("false", parsed["ok"]?.jsonPrimitive?.content)
assertEquals(0, parsed["count"]?.jsonPrimitive?.content?.toInt())
assertEquals("SMS_QUERY_FAILED: nope", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildQueryPayloadJsonIncludesMmsMetadataWhenProvided() {
val payload = SmsManager.buildQueryPayloadJson(
json = json,
ok = true,
messages = listOf(smsMessage(id = 1L, date = 1000L)),
queryMetadata =
SmsManager.QueryMetadata(
mmsRequested = true,
mmsEligible = true,
mmsAttempted = true,
mmsIncluded = false,
),
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("true", parsed["mmsRequested"]?.jsonPrimitive?.content)
assertEquals("true", parsed["mmsEligible"]?.jsonPrimitive?.content)
assertEquals("true", parsed["mmsAttempted"]?.jsonPrimitive?.content)
assertEquals("false", parsed["mmsIncluded"]?.jsonPrimitive?.content)
}
@Test
fun buildSendPlanUsesMultipartWhenMultipleParts() {
val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") }
@@ -98,14 +252,6 @@ class SmsManagerTest {
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)
@@ -179,4 +325,749 @@ class SmsManagerTest {
val ok = result as SmsManager.QueryParseResult.Ok
assertEquals(true, ok.params.isRead)
}
@Test
fun parseQueryParamsIncludeMmsDefaultsFalse() {
val result = SmsManager.parseQueryParams("{}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertFalse(ok.params.includeMms)
}
@Test
fun parseQueryParamsParsesIncludeMmsTrue() {
val result = SmsManager.parseQueryParams("{\"includeMms\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertTrue(ok.params.includeMms)
}
@Test
fun parseQueryParamsParsesConversationReviewTrue() {
val result = SmsManager.parseQueryParams("{\"conversationReview\":true}", json)
assertTrue(result is SmsManager.QueryParseResult.Ok)
val ok = result as SmsManager.QueryParseResult.Ok
assertTrue(ok.params.conversationReview)
}
@Test
fun toByPhoneLookupNumberStripsFormattingToDigits() {
assertEquals("12107588120", SmsManager.toByPhoneLookupNumber("+1 (210) 758-8120"))
}
@Test
fun normalizePhoneNumberOrNullReturnsNullForFormattingOnlyInput() {
assertNull(SmsManager.normalizePhoneNumberOrNull("() - "))
}
@Test
fun normalizePhoneNumberOrNullReturnsNullForPlusOnlyInput() {
assertNull(SmsManager.normalizePhoneNumberOrNull(" + "))
}
@Test
fun normalizePhoneNumberOrNullKeepsUsableNormalizedNumber() {
assertEquals("+15551234567", SmsManager.normalizePhoneNumberOrNull(" +1 (555) 123-4567 "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsFormattingOnlyInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" () - "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsPlusOnlyInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull(" + "))
}
@Test
fun sanitizeContactPhoneNumberOrNullKeepsUsableNormalizedNumber() {
assertEquals("+15551234567", SmsManager.sanitizeContactPhoneNumberOrNull(" +1 (555) 123-4567 "))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsPercentWildcardInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1%2"))
}
@Test
fun sanitizeContactPhoneNumberOrNullDropsUnderscoreWildcardInput() {
assertNull(SmsManager.sanitizeContactPhoneNumberOrNull("1_2"))
}
@Test
fun shouldPromptForContactNameSearchPermissionTrueForContactNameOnlyWithoutContactsAccess() {
assertTrue(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = null,
hasReadContactsPermission = false,
),
)
}
@Test
fun shouldPromptForContactNameSearchPermissionFalseWhenExplicitPhoneFallbackExists() {
assertFalse(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = "+15551234567",
hasReadContactsPermission = false,
),
)
}
@Test
fun shouldPromptForContactNameSearchPermissionFalseWhenContactsAlreadyGranted() {
assertFalse(
SmsManager.shouldPromptForContactNameSearchPermission(
contactName = "Alice",
phoneNumber = null,
hasReadContactsPermission = true,
),
)
}
@Test
fun escapeSqlLikeLiteralEscapesPercentUnderscoreAndBackslash() {
assertEquals("\\%a\\_b\\\\c", SmsManager.escapeSqlLikeLiteral("%a_b\\c"))
}
@Test
fun escapeSqlLikeLiteralLeavesOrdinaryTextUnchanged() {
assertEquals("Leah", SmsManager.escapeSqlLikeLiteral("Leah"))
}
@Test
fun buildContactNameLikeSelectionUsesSingleBackslashEscapeLiteral() {
assertEquals(
"display_name LIKE ? ESCAPE '\\'",
SmsManager.buildContactNameLikeSelection(),
)
}
@Test
fun buildContactNameLikeArgEscapesWildcardsAndBackslash() {
assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildContactNameLikeArg("%a_b\\c"))
}
@Test
fun buildKeywordLikeSelectionUsesSingleBackslashEscapeLiteral() {
assertEquals(
"body LIKE ? ESCAPE '\\'",
SmsManager.buildKeywordLikeSelection(),
)
}
@Test
fun buildKeywordLikeArgEscapesWildcardsAndBackslash() {
assertEquals("%\\%a\\_b\\\\c%", SmsManager.buildKeywordLikeArg("%a_b\\c"))
}
@Test
fun buildMixedByPhoneProjectionMatchesExpectedStatusAwareShape() {
assertArrayEquals(
arrayOf(
"_id",
"thread_id",
"transport_type",
"address",
"date",
"date_sent",
"read",
"type",
"body",
"status",
),
SmsManager.buildMixedByPhoneProjection(),
)
}
@Test
fun compareByPhoneCandidateOrderUsesDateThenIdDescending() {
val newer = smsMessage(id = 1L, date = 2000L)
val older = smsMessage(id = 2L, date = 1000L)
val sameDateHigherId = smsMessage(id = 9L, date = 1500L)
val sameDateLowerId = smsMessage(id = 3L, date = 1500L)
assertTrue(SmsManager.compareByPhoneCandidateOrder(newer, older) < 0)
assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateHigherId, sameDateLowerId) < 0)
assertTrue(SmsManager.compareByPhoneCandidateOrder(sameDateLowerId, sameDateHigherId) > 0)
}
@Test
fun upsertTopDateCandidatesKeepsDescendingOrderAndBounds() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = 2
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1700L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 1500L), max)
assertEquals(listOf(2L, 1L), candidates.map { it.second.id })
assertEquals(listOf(2000L, 1700L), candidates.map { it.second.date })
}
@Test
fun upsertTopDateCandidatesSupportsDefaultMixedPathBoundedWindow() {
val params = SmsManager.QueryParams(limit = 3, offset = 2, includeMms = true, phoneNumber = "+15551234567")
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = params.offset + params.limit
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 1000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:2", smsMessage(id = 2L, date = 2000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:3", smsMessage(id = 3L, date = 3000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:4", smsMessage(id = 4L, date = 4000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:5", smsMessage(id = 5L, date = 5000L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:6", smsMessage(id = 6L, date = 6000L), max)
assertEquals(5, candidates.size)
assertEquals(listOf(6L, 5L, 4L, 3L, 2L), candidates.map { it.second.id })
assertEquals(listOf(4000L, 3000L, 2000L), SmsManager.pageByPhoneCandidates(candidates.map { it.second }, params).map { it.date })
}
@Test
fun upsertTopDateCandidatesDedupesBySourceAwareIdentityAndKeepsBestOrdering() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val max = 5
SmsManager.upsertTopDateCandidates(candidates, "sms:1987", smsMessage(id = 1987L, date = 1773950752506L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1985", smsMessage(id = 1985L, date = 1773872989602L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1981", smsMessage(id = 1981L, date = 1773790733566L), max)
SmsManager.upsertTopDateCandidates(candidates, "sms:1976", smsMessage(id = 1976L, date = 1773784153770L), max)
// same source-aware identity should replace, not duplicate
SmsManager.upsertTopDateCandidates(candidates, "sms:1986", smsMessage(id = 1986L, date = 1773899354039L), max)
// different source-aware identity with same raw id must be preserved
SmsManager.upsertTopDateCandidates(candidates, "mms:1986", smsMessage(id = 1986L, date = 1773899354038L), max)
assertEquals(5, candidates.size)
assertEquals(2, candidates.count { it.second.id == 1986L })
assertEquals(listOf("sms:1987", "sms:1986", "mms:1986", "sms:1985", "sms:1981"), candidates.map { it.first })
}
@Test
fun materializeByPhoneCandidateDedupesBySourceAwareIdentity() {
val candidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 1000L))
SmsManager.materializeByPhoneCandidate(candidates, "sms:1", smsMessage(id = 1L, date = 2000L))
SmsManager.materializeByPhoneCandidate(candidates, "mms:1", smsMessage(id = 1L, date = 1500L))
assertEquals(2, candidates.size)
assertEquals(2000L, candidates["sms:1"]?.date)
assertEquals(1500L, candidates["mms:1"]?.date)
}
@Test
fun collectMixedByPhoneCandidateUsesBoundedCollectorWhenReviewModeDisabled() {
val topCandidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "sms:1",
message = smsMessage(id = 1L, date = 1000L),
maxCandidates = 1,
reviewMode = false,
)
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "mms:2",
message = smsMessage(id = 2L, date = 2000L, transportType = "mms"),
maxCandidates = 1,
reviewMode = false,
)
assertEquals(listOf(2L), topCandidates.map { it.second.id })
assertTrue(materializedCandidates.isEmpty())
}
@Test
fun collectMixedByPhoneCandidateMaterializesFullSetWhenReviewModeEnabled() {
val topCandidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
val materializedCandidates = linkedMapOf<String, SmsManager.SmsMessage>()
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "sms:1",
message = smsMessage(id = 1L, date = 1000L),
maxCandidates = 1,
reviewMode = true,
)
SmsManager.collectMixedByPhoneCandidate(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
identityKey = "mms:2",
message = smsMessage(id = 2L, date = 2000L, transportType = "mms"),
maxCandidates = 1,
reviewMode = true,
)
assertTrue(topCandidates.isEmpty())
assertEquals(listOf(1L, 2L), materializedCandidates.values.map { it.id })
}
@Test
fun pageMixedByPhoneCandidatesLetsReviewModeSurfaceOlderRowsBeyondBoundedDefaultWindow() {
val params =
SmsManager.QueryParams(
limit = 2,
offset = 2,
includeMms = true,
phoneNumber = "+15551234567",
conversationReview = true,
)
val topCandidates = listOf(
"sms:9" to smsMessage(id = 9L, date = 9000L),
"sms:8" to smsMessage(id = 8L, date = 8000L),
"sms:7" to smsMessage(id = 7L, date = 7000L),
)
val materializedCandidates =
linkedMapOf(
"sms:9" to smsMessage(id = 9L, date = 9000L),
"sms:8" to smsMessage(id = 8L, date = 8000L),
"sms:7" to smsMessage(id = 7L, date = 7000L),
"mms:6" to smsMessage(id = 6L, date = 6000L, transportType = "mms"),
)
val defaultPage =
SmsManager.pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params.copy(conversationReview = false),
reviewMode = false,
)
val reviewPage =
SmsManager.pageMixedByPhoneCandidates(
topCandidates = topCandidates,
materializedCandidates = materializedCandidates,
params = params,
reviewMode = true,
)
assertEquals(listOf(7L), defaultPage.map { it.id })
assertEquals(listOf(7L, 6L), reviewPage.map { it.id })
assertEquals(4, materializedCandidates.size)
}
@Test
fun pageByPhoneCandidatesHonorsDeepOffsetAfterStableSort() {
val params = SmsManager.QueryParams(limit = 5, offset = 5, includeMms = true)
val candidates = listOf(
smsMessage(id = 1399L, date = 1741112335720L),
smsMessage(id = 1976L, date = 1773784153770L),
smsMessage(id = 1981L, date = 1773790733566L),
smsMessage(id = 1985L, date = 1773872989602L),
smsMessage(id = 1986L, date = 1773899354039L),
smsMessage(id = 1987L, date = 1773950752506L),
)
assertEquals(listOf(1399L), SmsManager.pageByPhoneCandidates(candidates, params).map { it.id })
assertTrue(SmsManager.pageByPhoneCandidates(candidates, params.copy(offset = 10)).isEmpty())
}
@Test
fun upsertTopDateCandidatesNoOpWhenMaxIsZero() {
val candidates = mutableListOf<Pair<String, SmsManager.SmsMessage>>()
SmsManager.upsertTopDateCandidates(candidates, "sms:1", smsMessage(id = 1L, date = 2000L), 0)
assertTrue(candidates.isEmpty())
}
@Test
fun buildMixedRowIdentityUsesTransportTypeAndRowId() {
assertEquals("sms:7", SmsManager.buildMixedRowIdentity(7L, "sms"))
assertEquals("mms:7", SmsManager.buildMixedRowIdentity(7L, "mms"))
assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, null))
assertEquals("unknown:7", SmsManager.buildMixedRowIdentity(7L, ""))
}
@Test
fun normalizeProviderDateMillisConvertsSecondsToMillis() {
assertEquals(1773944910000L, SmsManager.normalizeProviderDateMillis(1773944910L))
}
@Test
fun normalizeProviderDateMillisKeepsMillisUnchanged() {
assertEquals(1773944910123L, SmsManager.normalizeProviderDateMillis(1773944910123L))
}
@Test
fun normalizeProviderDateMillisKeepsHistoricMillisUnchanged() {
assertEquals(946684800000L, SmsManager.normalizeProviderDateMillis(946684800000L))
}
@Test
fun resolveMixedByPhoneRowStatusPreservesRealSmsStatus() {
assertEquals(64, SmsManager.resolveMixedByPhoneRowStatus("sms", 64))
assertEquals(32, SmsManager.resolveMixedByPhoneRowStatus(null, 32))
}
@Test
fun resolveMixedByPhoneRowStatusKeepsMmsOnSentinelValue() {
assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("mms", 64))
assertEquals(-1, SmsManager.resolveMixedByPhoneRowStatus("MMS", null))
}
@Test
fun resolveMixedByPhoneRowStatusFallsBackToZeroWhenSmsStatusMissing() {
assertEquals(0, SmsManager.resolveMixedByPhoneRowStatus("sms", null))
}
@Test
fun resolveMixedByPhoneRowAddressPreservesProviderAddressWhenPresent() {
assertEquals(
"+12107588120",
SmsManager.resolveMixedByPhoneRowAddress("+12107588120", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressFallsBackToLookupNumberWhenProviderAddressMissing() {
assertEquals(
"12107588120",
SmsManager.resolveMixedByPhoneRowAddress(null, "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressCanPreserveLookupNumberWhenProviderAlreadyReturnsIt() {
assertEquals(
"12107588120",
SmsManager.resolveMixedByPhoneRowAddress("12107588120", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressPreservesNonMatchingProviderAddress() {
assertEquals(
"+13105550123",
SmsManager.resolveMixedByPhoneRowAddress("+13105550123", "12107588120"),
)
}
@Test
fun resolveMixedByPhoneRowAddressPrefersResolvedMmsParticipantAddress() {
assertEquals(
"+13105550123",
SmsManager.resolveMixedByPhoneRowAddress("insert-address-token", "12107588120", "+13105550123"),
)
}
@Test
fun selectPreferredMmsAddressPrefersType137AddressThatDoesNotMatchLookup() {
assertEquals(
"+13105550123",
SmsManager.selectPreferredMmsAddress(
listOf(
"+12107588120" to 151,
"+13105550123" to 137,
"+12107588120" to 130,
),
"12107588120",
),
)
}
@Test
fun selectPreferredMmsAddressFallsBackToFirstNormalizedAddressWhenOnlyLookupMatchesExist() {
assertEquals(
"+12107588120",
SmsManager.selectPreferredMmsAddress(
listOf(
"insert-address-token" to 137,
"+12107588120" to 151,
),
"12107588120",
),
)
}
@Test
fun isExplicitPhoneInputInvalidTrueWhenCallerSuppliesOnlyFormatting() {
val normalized = SmsManager.normalizePhoneNumberOrNull(" + ")
assertTrue(SmsManager.isExplicitPhoneInputInvalid(" + ", normalized))
}
@Test
fun hasSqlLikeWildcardDetectsPercentAndUnderscore() {
assertTrue(SmsManager.hasSqlLikeWildcard("+1555%1234"))
assertTrue(SmsManager.hasSqlLikeWildcard("+1555_1234"))
assertFalse(SmsManager.hasSqlLikeWildcard("+15551234"))
}
@Test
fun isExplicitPhoneInputInvalidRejectsLikeWildcardPhoneFilter() {
assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555%1234", "+1555%1234"))
assertTrue(SmsManager.isExplicitPhoneInputInvalid("+1555_1234", "+1555_1234"))
}
@Test
fun isExplicitPhoneInputInvalidFalseWhenPhoneWasOmitted() {
assertFalse(SmsManager.isExplicitPhoneInputInvalid(null, null))
assertFalse(SmsManager.isExplicitPhoneInputInvalid(" ", null))
}
@Test
fun mapMmsMsgBoxToSearchTypeCoversSearchRelevantMmsBoxes() {
assertEquals(1, SmsManager.mapMmsMsgBoxToSearchType(1))
assertEquals(2, SmsManager.mapMmsMsgBoxToSearchType(2))
assertEquals(3, SmsManager.mapMmsMsgBoxToSearchType(3))
assertEquals(4, SmsManager.mapMmsMsgBoxToSearchType(4))
assertEquals(5, SmsManager.mapMmsMsgBoxToSearchType(5))
assertEquals(6, SmsManager.mapMmsMsgBoxToSearchType(6))
}
@Test
fun mapMmsMsgBoxToSearchTypeLeavesUnsupportedBoxesUnmapped() {
assertNull(SmsManager.mapMmsMsgBoxToSearchType(0))
assertNull(SmsManager.mapMmsMsgBoxToSearchType(99))
assertNull(SmsManager.mapMmsMsgBoxToSearchType(null))
}
@Test
fun shouldUseConversationReviewByPhoneModeOnlyForMixedByPhoneReviewPulls() {
val active =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = null,
phoneNumber = "+12107588120",
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
val disabledByMode = active.copy(conversationReview = false)
val disabledByMms = active.copy(includeMms = false)
val disabledByPhone = active.copy(phoneNumber = null)
assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(active))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMode))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByMms))
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(disabledByPhone))
}
@Test
fun effectiveSearchParamsRaisesConversationReviewLimitFloor() {
val params =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = null,
phoneNumber = "+12107588120",
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
assertEquals(25, SmsManager.effectiveSearchParams(params).limit)
assertEquals(40, SmsManager.effectiveSearchParams(params.copy(limit = 40)).limit)
assertEquals(5, SmsManager.effectiveSearchParams(params.copy(conversationReview = false)).limit)
val singleResolvedContact = params.copy(phoneNumber = null, contactName = "Leah")
assertEquals(25, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit)
assertEquals(5, SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567", "15557654321")).limit)
assertEquals(
SmsManager.effectiveSearchParams(params).limit,
SmsManager.effectiveSearchParams(singleResolvedContact, listOf("15551234567")).limit,
)
}
@Test
fun resolveSearchParamsCarriesSingleResolvedContactIntoReviewMode() {
val params =
SmsManager.QueryParams(
limit = 5,
offset = 0,
isRead = null,
contactName = "Leah",
phoneNumber = null,
keyword = null,
startTime = null,
endTime = null,
includeMms = true,
conversationReview = true,
)
val beforeResolution = SmsManager.resolveSearchParams(params, normalizedPhoneNumber = null)
val singleResolved =
SmsManager.resolveSearchParams(
params,
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567"),
)
val multiResolved =
SmsManager.resolveSearchParams(
params,
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567", "15557654321"),
)
val explicit =
SmsManager.resolveSearchParams(
params.copy(contactName = null, phoneNumber = "+12107588120"),
normalizedPhoneNumber = "12107588120",
)
val nonReview =
SmsManager.resolveSearchParams(
params.copy(conversationReview = false),
normalizedPhoneNumber = null,
resolvedPhoneNumbers = listOf("15551234567"),
)
assertEquals(5, beforeResolution.limit)
assertEquals(25, singleResolved.limit)
assertEquals("15551234567", singleResolved.phoneNumber)
assertTrue(SmsManager.shouldUseConversationReviewByPhoneMode(singleResolved))
assertEquals(5, multiResolved.limit)
assertNull(multiResolved.phoneNumber)
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(multiResolved))
assertEquals(25, explicit.limit)
assertEquals("12107588120", explicit.phoneNumber)
assertEquals(5, nonReview.limit)
assertEquals("15551234567", nonReview.phoneNumber)
assertFalse(SmsManager.shouldUseConversationReviewByPhoneMode(nonReview))
}
@Test
fun canonicalizeMixedPathPhoneFiltersDedupesEquivalentExplicitAndContactNumbers() {
assertEquals(
listOf("15551234567"),
SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567")),
)
}
@Test
fun canonicalizeMixedPathPhoneFiltersDropsBlankByPhoneValues() {
assertEquals(
listOf("15551234567"),
SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "+", " ")),
)
}
@Test
fun buildQueryMetadataUsesCanonicalizedSingleMixedFilterAsEligible() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val canonical = SmsManager.canonicalizeMixedPathPhoneFilters(listOf("+15551234567", "15551234567"))
val metadata = SmsManager.buildQueryMetadata(params, canonical, emptyList())
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
}
@Test
fun requestedMixedByPhoneCandidateWindowAddsOffsetAndLimitSafely() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300)
assertEquals(500L, SmsManager.requestedMixedByPhoneCandidateWindow(params))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseAtSupportedBoundary() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 300)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowTrueWhenSingleNumberMixedWindowTooLarge() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567", limit = 200, offset = 301)
assertTrue(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseForSmsOnlyQueries() {
val params = SmsManager.QueryParams(includeMms = false, phoneNumber = "+15551234567", limit = 200, offset = 50000)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567")))
}
@Test
fun exceedsMixedByPhoneCandidateWindowFalseWhenMultiplePhoneNumbersDisableMixedByPhonePath() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = null, limit = 200, offset = 50000)
assertFalse(SmsManager.exceedsMixedByPhoneCandidateWindow(params, listOf("+15551234567", "+15557654321")))
}
@Test
fun mixedByPhoneWindowErrorMentionsSupportedWindow() {
assertEquals(
"INVALID_REQUEST: includeMms offset+limit exceeds supported window (500)",
SmsManager.mixedByPhoneWindowError(),
)
}
@Test
fun buildQueryMetadataMarksIneligibleWhenIncludeMmsNotRequested() {
val params = SmsManager.QueryParams(includeMms = false)
val metadata = SmsManager.buildQueryMetadata(params, emptyList(), emptyList())
assertFalse(metadata.mmsRequested)
assertFalse(metadata.mmsEligible)
assertFalse(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun buildQueryMetadataMarksEligibleAttemptedButNotIncludedForSingleNumberFallback() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val messages = listOf(smsMessage(id = 1L, date = 1000L))
val metadata = SmsManager.buildQueryMetadata(params, listOf("+15551234567"), messages)
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun isMmsTransportRowTrueOnlyForMmsTransport() {
assertTrue(SmsManager.isMmsTransportRow(smsMessage(id = 1L, date = 1000L, transportType = "mms")))
assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 2L, date = 1000L, transportType = "sms")))
assertFalse(SmsManager.isMmsTransportRow(smsMessage(id = 3L, date = 1000L, transportType = null)))
}
@Test
fun shouldHydrateMmsByPhoneRowTrueOnlyForMmsTransportWithBlankBodyOrZeroType() {
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", null, 1))
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "", 1))
assertTrue(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("sms", null, 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow(null, null, 0))
assertFalse(SmsManager.shouldHydrateMmsByPhoneRow("mms", "body", 1))
}
@Test
fun buildQueryMetadataDoesNotTreatSmsStatusSentinelAsMmsInclusion() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val smsLikeMessage = smsMessage(id = 7L, date = 1000L, status = -1, transportType = "sms")
val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(smsLikeMessage))
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertFalse(metadata.mmsIncluded)
}
@Test
fun buildQueryMetadataMarksIncludedWhenMixedQueryYieldsMmsTransportRow() {
val params = SmsManager.QueryParams(includeMms = true, phoneNumber = "+15551234567")
val mmsTransportMessage = smsMessage(id = 7L, date = 1000L, status = 0, body = null, transportType = "mms")
val metadata = SmsManager.buildQueryMetadata(params, listOf("15551234567"), listOf(mmsTransportMessage))
assertTrue(metadata.mmsRequested)
assertTrue(metadata.mmsEligible)
assertTrue(metadata.mmsAttempted)
assertTrue(metadata.mmsIncluded)
}
}

View File

@@ -86,13 +86,15 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.send", OpenClawSmsCommand.Send.rawValue)
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
@Test
fun smsCommandsUseStableStrings() {
assertEquals("sms.search", OpenClawSmsCommand.Search.rawValue)
}
}

View File

@@ -1,14 +1,18 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { parseNodeList, parsePairingList } from "../shared/node-list-parse.js";
import type { NodeListNode } from "../shared/node-list-types.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { unwrapRemoteConfigSnapshot } from "./android-node.capabilities.policy-config.js";
import { shouldFetchRemotePolicyConfig } from "./android-node.capabilities.policy-source.js";
import { buildGatewayConnectionDetails } from "./call.js";
import { GatewayClient } from "./client.js";
import { resolveGatewayCredentialsFromConfig } from "./credentials.js";
import { resolveNodeCommandAllowlist } from "./node-command-policy.js";
const LIVE = isLiveTestEnabled();
const LIVE_ANDROID_NODE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_ANDROID_NODE);
@@ -65,6 +69,14 @@ function parseErrorCode(message: string): string {
return "UNKNOWN";
}
function readGatewayErrorCode(err: unknown, fallbackMessage: string): string {
const byField = readString(asRecord(err).gatewayCode);
if (byField) {
return byField;
}
return parseErrorCode(fallbackMessage);
}
function assertObjectPayload(command: string, payload: unknown): Record<string, unknown> {
const obj = asRecord(payload);
expect(Object.keys(obj).length, `${command} payload must be a JSON object`).toBeGreaterThan(0);
@@ -214,6 +226,16 @@ const COMMAND_PROFILES: Record<string, CommandProfile> = {
outcome: "error",
allowedErrorCodes: ["INVALID_REQUEST"],
},
"sms.search": {
buildParams: () => ({}),
timeoutMs: 20_000,
outcome: "success",
onSuccess: (payload) => {
const obj = assertObjectPayload("sms.search", payload);
expect(typeof obj.count === "number" || typeof obj.count === "string").toBe(true);
expect(Array.isArray(obj.messages)).toBe(true);
},
},
"debug.logs": {
buildParams: () => ({}),
timeoutMs: 20_000,
@@ -251,12 +273,68 @@ function resolveGatewayConnection() {
},
});
return {
details,
url: details.url,
token: creds.token,
password: creds.password,
};
}
async function resolvePolicyConfigForRun(params: {
client: GatewayClient;
connectionDetails: ReturnType<typeof buildGatewayConnectionDetails>;
loadLocalConfig?: () => OpenClawConfig;
}): Promise<OpenClawConfig> {
if (shouldFetchRemotePolicyConfig(params.connectionDetails)) {
const raw = await params.client.request("config.get", {});
return unwrapRemoteConfigSnapshot(raw);
}
const loadLocalConfig = params.loadLocalConfig ?? loadConfig;
return loadLocalConfig();
}
describe("resolvePolicyConfigForRun", () => {
it("skips local config loading for remote runs", async () => {
const request = vi.fn().mockResolvedValue({ config: { gateway: { bind: "127.0.0.1" } } });
const loadLocalConfig = vi.fn(() => {
throw new Error("local config should not load in remote mode");
});
const result = await resolvePolicyConfigForRun({
client: { request } as unknown as GatewayClient,
connectionDetails: {
url: "wss://example.invalid/gateway",
urlSource: "env override",
message: "remote",
},
loadLocalConfig,
});
expect(loadLocalConfig).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith("config.get", {});
expect(asRecord(result.gateway)).toBeTruthy();
});
it("still uses local config loading for local loopback runs", async () => {
const localConfig = { gateway: { bind: "127.0.0.1" } } as unknown as OpenClawConfig;
const loadLocalConfig = vi.fn(() => localConfig);
const result = await resolvePolicyConfigForRun({
client: { request: vi.fn() } as unknown as GatewayClient,
connectionDetails: {
url: "ws://127.0.0.1:4000/gateway",
urlSource: "local loopback",
message: "local",
},
loadLocalConfig,
});
expect(loadLocalConfig).toHaveBeenCalledTimes(1);
expect(result).toBe(localConfig);
});
});
async function connectGatewayClient(params: {
url: string;
token?: string;
@@ -372,7 +450,7 @@ async function invokeNodeCommand(params: {
return {
command: params.command,
ok: false,
errorCode: parseErrorCode(message),
errorCode: readGatewayErrorCode(err, message),
errorMessage: message,
durationMs: Math.max(1, Date.now() - startedAt),
};
@@ -417,7 +495,7 @@ describeLive("android node capability integration (preconditioned)", () => {
const results = new Map<string, CommandResult>();
beforeAll(async () => {
const { url, token, password } = resolveGatewayConnection();
const { details, url, token, password } = resolveGatewayConnection();
client = await connectGatewayClient({ url, token, password });
const listRaw = await client.request("node.list", {});
@@ -448,10 +526,22 @@ describeLive("android node capability integration (preconditioned)", () => {
const describeObj = asRecord(describeRaw);
const commands = readStringArray(describeObj.commands);
expect(commands.length, "node.describe advertised no commands").toBeGreaterThan(0);
commandsToRun = commands.filter((command) => !SKIPPED_INTERACTIVE_COMMANDS.has(command));
const cfg = await resolvePolicyConfigForRun({
client,
connectionDetails: details,
});
const allowlist = resolveNodeCommandAllowlist(cfg, {
platform: target.platform,
deviceFamily: target.deviceFamily,
});
commandsToRun = commands.filter(
(command) => allowlist.has(command) && !SKIPPED_INTERACTIVE_COMMANDS.has(command),
);
expect(
commandsToRun.length,
"node.describe advertised only interactive commands (nothing runnable in CI/dev integration mode)",
"node.describe advertised no non-interactive allowlisted commands (check gateway.nodes allowCommands/denyCommands)",
).toBeGreaterThan(0);
const missingProfiles = commandsToRun.filter((command) => !COMMAND_PROFILES[command]);

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { unwrapRemoteConfigSnapshot } from "./android-node.capabilities.policy-config.js";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
describe("unwrapRemoteConfigSnapshot", () => {
it("reads direct config snapshot payload from GatewayClient.request", () => {
const cfg = unwrapRemoteConfigSnapshot({ gateway: { bind: "127.0.0.1" } });
expect(asRecord(cfg.gateway).bind).toBe("127.0.0.1");
});
it("prefers resolved snapshot payload when present", () => {
const cfg = unwrapRemoteConfigSnapshot({
config: { gateway: { bind: "127.0.0.2" } },
resolved: { gateway: { bind: "127.0.0.3" } },
});
expect(asRecord(cfg.gateway).bind).toBe("127.0.0.3");
});
it("supports wrapped config payload fallback", () => {
const cfg = unwrapRemoteConfigSnapshot({ config: { gateway: { bind: "127.0.0.2" } } });
expect(asRecord(cfg.gateway).bind).toBe("127.0.0.2");
});
it("supports legacy nested payload fallback", () => {
const cfg = unwrapRemoteConfigSnapshot({
payload: { config: { gateway: { bind: "::1" } } },
});
expect(asRecord(cfg.gateway).bind).toBe("::1");
});
it("supports legacy nested resolved payload fallback", () => {
const cfg = unwrapRemoteConfigSnapshot({
payload: { resolved: { gateway: { bind: "::2" } } },
});
expect(asRecord(cfg.gateway).bind).toBe("::2");
});
it("throws when no usable config payload exists", () => {
expect(() => unwrapRemoteConfigSnapshot({ payload: {} })).toThrow(
"remote gateway config.get returned empty config payload",
);
});
});

View File

@@ -0,0 +1,35 @@
import type { OpenClawConfig } from "../config/config.js";
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig {
const rawObj = asRecord(raw);
const resolved = asRecord(rawObj.resolved);
if (Object.keys(resolved).length > 0) {
return resolved as OpenClawConfig;
}
const wrapped = asRecord(rawObj.config);
if (Object.keys(wrapped).length > 0) {
return wrapped as OpenClawConfig;
}
const legacyPayload = asRecord(rawObj.payload);
const legacyResolved = asRecord(legacyPayload.resolved);
if (Object.keys(legacyResolved).length > 0) {
return legacyResolved as OpenClawConfig;
}
const legacyConfig = asRecord(legacyPayload.config);
if (Object.keys(legacyConfig).length > 0) {
return legacyConfig as OpenClawConfig;
}
if (Object.keys(rawObj).length > 0 && !Object.prototype.hasOwnProperty.call(rawObj, "payload")) {
return rawObj as OpenClawConfig;
}
throw new Error("remote gateway config.get returned empty config payload");
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { shouldFetchRemotePolicyConfig } from "./android-node.capabilities.policy-source.js";
import type { GatewayConnectionDetails } from "./call.js";
function details(overrides: Partial<GatewayConnectionDetails>): GatewayConnectionDetails {
return {
url: "ws://127.0.0.1:18789",
urlSource: "local loopback",
message: "test",
...overrides,
};
}
describe("shouldFetchRemotePolicyConfig", () => {
it("returns false for local loopback config", () => {
expect(shouldFetchRemotePolicyConfig(details({ urlSource: "local loopback" }))).toBe(false);
});
it("returns true for config-driven remote urls even if loopback-tunneled", () => {
expect(
shouldFetchRemotePolicyConfig(
details({ url: "ws://127.0.0.1:18789", urlSource: "config gateway.remote.url" }),
),
).toBe(true);
});
it("returns true for env and cli overrides", () => {
expect(shouldFetchRemotePolicyConfig(details({ urlSource: "env OPENCLAW_GATEWAY_URL" }))).toBe(
true,
);
expect(shouldFetchRemotePolicyConfig(details({ urlSource: "cli --url" }))).toBe(true);
});
it("returns true for remote fallback/misconfigured cases that did not use local loopback source", () => {
expect(
shouldFetchRemotePolicyConfig(
details({ urlSource: "missing gateway.remote.url (fallback local)" }),
),
).toBe(true);
});
});

View File

@@ -0,0 +1,5 @@
import type { GatewayConnectionDetails } from "./call.js";
export function shouldFetchRemotePolicyConfig(details: GatewayConnectionDetails): boolean {
return details.urlSource !== "local loopback";
}

View File

@@ -369,8 +369,12 @@ 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);
expect(allow.has("sms.search")).toBe(false);
});
it("treats sms.search as dangerous by default", () => {
expect(DEFAULT_DANGEROUS_NODE_COMMANDS).toContain("sms.search");
});
it("can explicitly allow dangerous commands via allowCommands", () => {

View File

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