From 8178b621878f0eead5539d24ef4c37aa416994d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 09:27:59 +0100 Subject: [PATCH] fix(android): include third-party sensitive handlers --- .../openclaw/app/node/CallLogHandlerTest.kt | 291 +++++ .../ai/openclaw/app/node/SmsManagerTest.kt | 1089 ++++++++++++++++ .../ai/openclaw/app/node/CallLogHandler.kt | 257 ++++ .../java/ai/openclaw/app/node/SmsHandler.kt | 39 + .../java/ai/openclaw/app/node/SmsManager.kt | 1154 +++++++++++++++++ 5 files changed, 2830 insertions(+) create mode 100644 apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt create mode 100644 apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt create mode 100644 apps/android/app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt create mode 100644 apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt create mode 100644 apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt diff --git a/apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt b/apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt new file mode 100644 index 00000000000..ca2cc587e40 --- /dev/null +++ b/apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/CallLogHandlerTest.kt @@ -0,0 +1,291 @@ +package ai.openclaw.app.node + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CallLogHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleCallLogSearch_requiresPermission() { + val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false)) + + val result = handler.handleCallLogSearch(null) + + assertFalse(result.ok) + assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleCallLogSearch_rejectsInvalidJson() { + val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true)) + + val result = handler.handleCallLogSearch("invalid json") + + assertFalse(result.ok) + assertEquals("INVALID_REQUEST", result.error?.code) + } + + @Test + fun handleCallLogSearch_returnsCallLogs() { + val callLog = + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 60L, + type = 1, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = handler.handleCallLogSearch("""{"limit":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + assertEquals( + "+123456", + callLogs + .first() + .jsonObject + .getValue("number") + .jsonPrimitive.content, + ) + assertEquals( + "lixuankai", + callLogs + .first() + .jsonObject + .getValue("cachedName") + .jsonPrimitive.content, + ) + assertEquals( + 1709280000000L, + callLogs + .first() + .jsonObject + .getValue("date") + .jsonPrimitive.content + .toLong(), + ) + assertEquals( + 60L, + callLogs + .first() + .jsonObject + .getValue("duration") + .jsonPrimitive.content + .toLong(), + ) + assertEquals( + 1, + callLogs + .first() + .jsonObject + .getValue("type") + .jsonPrimitive.content + .toInt(), + ) + } + + @Test + fun handleCallLogSearch_withFilters() { + val callLog = + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 120L, + type = 2, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = + handler.handleCallLogSearch( + """{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}""", + ) + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + assertEquals( + "lixuankai", + callLogs + .first() + .jsonObject + .getValue("cachedName") + .jsonPrimitive.content, + ) + } + + @Test + fun handleCallLogSearch_withPagination() { + val callLogs = + listOf( + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 60L, + type = 1, + ), + CallLogRecord( + number = "+654321", + cachedName = "lixuankai2", + date = 1709280001000L, + duration = 120L, + type = 2, + ), + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = callLogs), + ) + + val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogsResult = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogsResult.size) + assertEquals( + "lixuankai2", + callLogsResult + .first() + .jsonObject + .getValue("cachedName") + .jsonPrimitive.content, + ) + } + + @Test + fun handleCallLogSearch_withDefaultParams() { + val callLog = + CallLogRecord( + number = "+123456", + cachedName = "lixuankai", + date = 1709280000000L, + duration = 60L, + type = 1, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = handler.handleCallLogSearch(null) + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + assertEquals( + "+123456", + callLogs + .first() + .jsonObject + .getValue("number") + .jsonPrimitive.content, + ) + } + + @Test + fun handleCallLogSearch_withNullFields() { + val callLog = + CallLogRecord( + number = null, + cachedName = null, + date = 1709280000000L, + duration = 60L, + type = 1, + ) + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)), + ) + + val result = handler.handleCallLogSearch("""{"limit":1}""") + + assertTrue(result.ok) + val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject + val callLogs = payload.getValue("callLogs").jsonArray + assertEquals(1, callLogs.size) + // Verify null values are properly serialized + val callLogObj = callLogs.first().jsonObject + assertTrue(callLogObj.containsKey("number")) + assertTrue(callLogObj.containsKey("cachedName")) + } + + @Test + fun handleCallLogSearch_clampsLimitAndOffsetBeforeSearch() { + val source = FakeCallLogDataSource(canRead = true) + val handler = CallLogHandler.forTesting(appContext(), source) + + val result = handler.handleCallLogSearch("""{"limit":999,"offset":-5}""") + + assertTrue(result.ok) + assertEquals(200, source.lastRequest?.limit) + assertEquals(0, source.lastRequest?.offset) + } + + @Test + fun handleCallLogSearch_mapsSearchFailuresToUnavailable() { + val handler = + CallLogHandler.forTesting( + appContext(), + FakeCallLogDataSource( + canRead = true, + failure = IllegalStateException("provider down"), + ), + ) + + val result = handler.handleCallLogSearch(null) + + assertFalse(result.ok) + assertEquals("CALL_LOG_UNAVAILABLE", result.error?.code) + assertEquals("CALL_LOG_UNAVAILABLE: provider down", result.error?.message) + } +} + +private class FakeCallLogDataSource( + private val canRead: Boolean, + private val searchResults: List = emptyList(), + private val failure: Throwable? = null, +) : CallLogDataSource { + var lastRequest: CallLogSearchRequest? = null + + override fun hasReadPermission(context: Context): Boolean = canRead + + override fun search( + context: Context, + request: CallLogSearchRequest, + ): List { + lastRequest = request + failure?.let { throw it } + val startIndex = request.offset.coerceAtLeast(0) + val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size) + return if (startIndex < searchResults.size) { + searchResults.subList(startIndex, endIndex) + } else { + emptyList() + } + } +} diff --git a/apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt b/apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt new file mode 100644 index 00000000000..efdd356977c --- /dev/null +++ b/apps/android/app/src/testThirdParty/java/ai/openclaw/app/node/SmsManagerTest.kt @@ -0,0 +1,1089 @@ +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) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: paramsJSON required", error.error) + } + + @Test + fun parseParamsRejectsInvalidJson() { + val result = SmsManager.parseParams("not-json", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsNonObjectJson() { + val result = SmsManager.parseParams("[]", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: expected JSON object", error.error) + } + + @Test + fun parseParamsRejectsMissingTo() { + val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'to' phone number required", error.error) + assertEquals("Hi", error.message) + } + + @Test + fun parseParamsRejectsMissingMessage() { + val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json) + assertTrue(result is SmsManager.ParseResult.Error) + val error = result as SmsManager.ParseResult.Error + assertEquals("INVALID_REQUEST: 'message' text required", error.error) + assertEquals("+1234", error.to) + } + + @Test + fun parseParamsTrimsToField() { + val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json) + assertTrue(result is SmsManager.ParseResult.Ok) + val ok = result as SmsManager.ParseResult.Ok + assertEquals("+1555", ok.params.to) + assertEquals("Hello", ok.params.message) + } + + @Test + fun 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( + json = json, + ok = false, + to = "+1\"23", + error = "SMS_SEND_FAILED: \"nope\"", + ) + val parsed = json.parseToJsonElement(payload).jsonObject + assertEquals("false", parsed["ok"]?.jsonPrimitive?.content) + assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content) + assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content) + } + + @Test + fun 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") } + assertTrue(plan.useMultipart) + assertEquals(listOf("a", "b"), plan.parts) + } + + @Test + fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() { + val plan = SmsManager.buildSendPlan("hello") { emptyList() } + assertFalse(plan.useMultipart) + assertEquals(listOf("hello"), plan.parts) + } + + @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 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) + } + + @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>() + 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>() + 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>() + 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() + + 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>() + val materializedCandidates = linkedMapOf() + + 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>() + val materializedCandidates = linkedMapOf() + + 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>() + 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) + } +} diff --git a/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt b/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt new file mode 100644 index 00000000000..70b41df08c9 --- /dev/null +++ b/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/CallLogHandler.kt @@ -0,0 +1,257 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession +import android.Manifest +import android.content.Context +import android.provider.CallLog +import androidx.core.content.ContextCompat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private const val DEFAULT_CALL_LOG_LIMIT = 25 + +internal data class CallLogRecord( + val number: String?, + val cachedName: String?, + val date: Long, + val duration: Long, + val type: Int, +) + +internal data class CallLogSearchRequest( + val limit: Int, // Number of records to return + val offset: Int, // Offset value + val cachedName: String?, // Search by contact name + val number: String?, // Search by phone number + val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd) + val dateStart: Long?, // Query start time (timestamp) + val dateEnd: Long?, // Query end time (timestamp) + val duration: Long?, // Search by duration (seconds) + val type: Int?, // Search by call log type +) + +internal interface CallLogDataSource { + fun hasReadPermission(context: Context): Boolean + + fun search( + context: Context, + request: CallLogSearchRequest, + ): List +} + +private object SystemCallLogDataSource : CallLogDataSource { + override fun hasReadPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CALL_LOG, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + + override fun search( + context: Context, + request: CallLogSearchRequest, + ): List { + val resolver = context.contentResolver + val projection = + arrayOf( + CallLog.Calls.NUMBER, + CallLog.Calls.CACHED_NAME, + CallLog.Calls.DATE, + CallLog.Calls.DURATION, + CallLog.Calls.TYPE, + ) + + // Build selection and selectionArgs for filtering + val selections = mutableListOf() + val selectionArgs = mutableListOf() + + request.cachedName?.let { + selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?") + selectionArgs.add("%$it%") + } + + request.number?.let { + selections.add("${CallLog.Calls.NUMBER} LIKE ?") + selectionArgs.add("%$it%") + } + + // Support time range query + if (request.dateStart != null && request.dateEnd != null) { + selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?") + selectionArgs.add(request.dateStart.toString()) + selectionArgs.add(request.dateEnd.toString()) + } else if (request.dateStart != null) { + selections.add("${CallLog.Calls.DATE} >= ?") + selectionArgs.add(request.dateStart.toString()) + } else if (request.dateEnd != null) { + selections.add("${CallLog.Calls.DATE} <= ?") + selectionArgs.add(request.dateEnd.toString()) + } else if (request.date != null) { + // Compatible with the old date parameter (exact match) + selections.add("${CallLog.Calls.DATE} = ?") + selectionArgs.add(request.date.toString()) + } + + request.duration?.let { + selections.add("${CallLog.Calls.DURATION} = ?") + selectionArgs.add(it.toString()) + } + + request.type?.let { + selections.add("${CallLog.Calls.TYPE} = ?") + selectionArgs.add(it.toString()) + } + + val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null + val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null + + val sortOrder = "${CallLog.Calls.DATE} DESC" + + resolver + .query( + CallLog.Calls.CONTENT_URI, + projection, + selection, + selectionArgsArray, + sortOrder, + ).use { cursor -> + if (cursor == null) return emptyList() + + val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER) + val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME) + val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE) + val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION) + val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE) + + // Skip offset rows + if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) { + // Successfully moved to offset position + } + + val out = mutableListOf() + var count = 0 + while (cursor.moveToNext() && count < request.limit) { + out += + CallLogRecord( + number = cursor.getString(numberIndex), + cachedName = cursor.getString(cachedNameIndex), + date = cursor.getLong(dateIndex), + duration = cursor.getLong(durationIndex), + type = cursor.getInt(typeIndex), + ) + count++ + } + return out + } + } +} + +class CallLogHandler private constructor( + private val appContext: Context, + private val dataSource: CallLogDataSource, +) { + constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource) + + fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult { + if (!dataSource.hasReadPermission(appContext)) { + return GatewaySession.InvokeResult.error( + code = "CALL_LOG_PERMISSION_REQUIRED", + message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission", + ) + } + + val request = + parseSearchRequest(paramsJson) + ?: return GatewaySession.InvokeResult.error( + code = "INVALID_REQUEST", + message = "INVALID_REQUEST: expected JSON object", + ) + + return try { + val callLogs = dataSource.search(appContext, request) + GatewaySession.InvokeResult.ok( + buildJsonObject { + put( + "callLogs", + buildJsonArray { + callLogs.forEach { add(callLogJson(it)) } + }, + ) + }.toString(), + ) + } catch (err: Throwable) { + GatewaySession.InvokeResult.error( + code = "CALL_LOG_UNAVAILABLE", + message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}", + ) + } + } + + private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? { + if (paramsJson.isNullOrBlank()) { + return CallLogSearchRequest( + limit = DEFAULT_CALL_LOG_LIMIT, + offset = 0, + cachedName = null, + number = null, + date = null, + dateStart = null, + dateEnd = null, + duration = null, + type = null, + ) + } + + val params = + try { + Json.parseToJsonElement(paramsJson).asObjectOrNull() + } catch (_: Throwable) { + null + } ?: return null + + val limit = + ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT) + .coerceIn(1, 200) + val offset = + ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0) + .coerceAtLeast(0) + val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() } + val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() } + val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull() + val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull() + val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull() + val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull() + val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull() + + return CallLogSearchRequest( + limit = limit, + offset = offset, + cachedName = cachedName, + number = number, + date = date, + dateStart = dateStart, + dateEnd = dateEnd, + duration = duration, + type = type, + ) + } + + private fun callLogJson(callLog: CallLogRecord): JsonObject = + buildJsonObject { + put("number", JsonPrimitive(callLog.number)) + put("cachedName", JsonPrimitive(callLog.cachedName)) + put("date", JsonPrimitive(callLog.date)) + put("duration", JsonPrimitive(callLog.duration)) + put("type", JsonPrimitive(callLog.type)) + } + + companion object { + internal fun forTesting( + appContext: Context, + dataSource: CallLogDataSource, + ): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource) + } +} diff --git a/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt b/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt new file mode 100644 index 00000000000..c5c82aa5005 --- /dev/null +++ b/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt @@ -0,0 +1,39 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession + +class SmsHandler( + private val sms: SmsManager, +) { + suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult { + val res = sms.send(paramsJson) + if (res.ok) { + return GatewaySession.InvokeResult.ok(res.payloadJson) + } + 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) + } + 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) + } +} diff --git a/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt b/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt new file mode 100644 index 00000000000..74c930a27ca --- /dev/null +++ b/apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsManager.kt @@ -0,0 +1,1154 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.PermissionRequester +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.ContactsContract +import android.provider.Telephony +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +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 android.telephony.SmsManager as AndroidSmsManager + +/** + * 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, +) { + private val json = JsonConfig + + @Volatile private var permissionRequester: PermissionRequester? = null + + data class SendResult( + val ok: Boolean, + val to: String, + val message: String?, + val error: String? = null, + 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, + val transportType: String? = null, + ) + + data class SearchResult( + val ok: Boolean, + val messages: List, + val error: String? = null, + 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, + ) + + internal sealed class ParseResult { + data class Ok( + val params: ParsedParams, + ) : ParseResult() + + data class Error( + val error: String, + val to: String = "", + val message: String? = null, + ) : ParseResult() + } + + internal data class 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 includeMms: Boolean = false, + val conversationReview: Boolean = false, + val limit: Int = DEFAULT_SMS_LIMIT, + val offset: Int = 0, + ) + + internal sealed class QueryParseResult { + data class Ok( + val params: QueryParams, + ) : QueryParseResult() + + data class Error( + val error: String, + ) : QueryParseResult() + } + + internal data class SendPlan( + val parts: List, + val useMultipart: Boolean, + ) + + companion object { + private const val DEFAULT_SMS_LIMIT = 25 + internal 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 { + val params = paramsJson?.trim().orEmpty() + if (params.isEmpty()) { + return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required") + } + + val obj = + try { + json.parseToJsonElement(params).jsonObject + } catch (_: Throwable) { + null + } + + if (obj == null) { + return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object") + } + + val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty() + val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty() + + if (to.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'to' phone number required", + message = message, + ) + } + + if (message.isEmpty()) { + return ParseResult.Error( + error = "INVALID_REQUEST: 'message' text required", + to = to, + ) + } + + return ParseResult.Ok(ParsedParams(to = to, message = message)) + } + + internal fun 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 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) + + 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, + includeMms = includeMms, + conversationReview = conversationReview, + limit = limit, + offset = offset, + ), + ) + } + + private fun normalizePhoneNumber(phone: String): String = 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 = !contactName.isNullOrEmpty() && phoneNumber.isNullOrEmpty() && !hasReadContactsPermission + + internal fun mapMmsMsgBoxToSearchType(msgBox: Int?): Int? = + 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 = + buildString(value.length) { + for (ch in value) { + when (ch) { + '\\', '%', '_' -> { + append('\\') + append(ch) + } + else -> append(ch) + } + } + } + + internal fun buildContactNameLikeSelection(): String = "${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} LIKE ? ESCAPE '\\'" + + internal fun buildContactNameLikeArg(contactName: String): String = "%${escapeSqlLikeLiteral(contactName)}%" + + internal fun buildKeywordLikeSelection(): String = "${Telephony.Sms.BODY} LIKE ? ESCAPE '\\'" + + internal fun buildKeywordLikeArg(keyword: String): String = "%${escapeSqlLikeLiteral(keyword)}%" + + internal fun buildMixedByPhoneProjection(): Array = + arrayOf( + "_id", + "thread_id", + "transport_type", + "address", + "date", + "date_sent", + "read", + "type", + "body", + "status", + ) + + internal fun hasSqlLikeWildcard(value: String): Boolean = 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 = 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>, + 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? = + 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 = 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 = 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 = emptyList(), + ): QueryParams { + val effectivePhoneNumber = normalizedPhoneNumber ?: resolvedPhoneNumbers.singleOrNull() + val normalizedParams = params.copy(phoneNumber = effectivePhoneNumber) + return effectiveSearchParams(normalizedParams, resolvedPhoneNumbers) + } + + internal fun toByPhoneLookupNumber(phone: String): String = phone.filter { it.isDigit() } + + internal fun normalizeProviderDateMillis(rawDate: Long): Long = if (rawDate in 1..99_999_999_999L) rawDate * 1000L else rawDate + + internal fun canonicalizeMixedPathPhoneFilters(phoneNumbers: List): List = + phoneNumbers + .map(::toByPhoneLookupNumber) + .filter { it.isNotBlank() } + .distinct() + + internal fun requestedMixedByPhoneCandidateWindow(params: QueryParams): Long = params.offset.toLong() + params.limit.toLong() + + internal fun exceedsMixedByPhoneCandidateWindow( + params: QueryParams, + allPhoneNumbers: List, + ): Boolean = + params.includeMms && + allPhoneNumbers.size == 1 && + requestedMixedByPhoneCandidateWindow(params) > MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW + + internal fun mixedByPhoneWindowError(): String = "INVALID_REQUEST: includeMms offset+limit exceeds supported window ($MAX_MIXED_BY_PHONE_CANDIDATE_WINDOW)" + + internal fun isMmsTransportRow(message: SmsMessage): Boolean = message.transportType.equals("mms", ignoreCase = true) + + internal fun shouldHydrateMmsByPhoneRow( + transportType: String?, + body: String?, + type: Int, + ): Boolean = transportType.equals("mms", ignoreCase = true) && (body.isNullOrBlank() || type == 0) + + internal fun buildQueryMetadata( + params: QueryParams, + allPhoneNumbers: List, + messages: List, + ): 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 = + 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 = "${transportType?.ifBlank { "unknown" } ?: "unknown"}:$rowId" + + internal fun upsertTopDateCandidates( + candidates: MutableList>, + 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, + identityKey: String, + message: SmsMessage, + ) { + candidates[identityKey] = message + } + + internal fun collectMixedByPhoneCandidate( + topCandidates: MutableList>, + materializedCandidates: MutableMap, + 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>, + materializedCandidates: Map, + params: QueryParams, + reviewMode: Boolean, + ): List = + if (reviewMode) { + pageByPhoneCandidates(materializedCandidates.values, params) + } else { + pageByPhoneCandidates(topCandidates.map { it.second }, params) + } + + internal fun pageByPhoneCandidates( + candidates: Collection, + params: QueryParams, + ): List = + candidates + .sortedWith(::compareByPhoneCandidateOrder) + .drop(params.offset) + .take(params.limit) + + internal fun buildSendPlan( + message: String, + divider: (String) -> List, + ): SendPlan { + val parts = divider(message).ifEmpty { listOf(message) } + return SendPlan(parts = parts, useMultipart = parts.size > 1) + } + + internal fun buildPayloadJson( + json: Json = JsonConfig, + ok: Boolean, + to: String, + error: String?, + ): String { + val payload = + mutableMapOf( + "ok" to JsonPrimitive(ok), + "to" to JsonPrimitive(to), + ) + if (!ok) { + payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED") + } + return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) + } + + internal fun buildQueryPayloadJson( + json: Json = JsonConfig, + ok: Boolean, + messages: List, + error: String? = null, + queryMetadata: QueryMetadata? = null, + ): String { + val messagesArray = json.encodeToString(messages) + val messagesElement = json.parseToJsonElement(messagesArray) + val payload = + mutableMapOf( + "ok" to JsonPrimitive(ok), + "count" to JsonPrimitive(messages.size), + "messages" to messagesElement, + ) + 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) + } + return json.encodeToString(JsonObject.serializer(), JsonObject(payload)) + } + } + + fun hasSmsPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.SEND_SMS, + ) == PackageManager.PERMISSION_GRANTED + + fun hasReadSmsPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_SMS, + ) == PackageManager.PERMISSION_GRANTED + + fun hasReadContactsPermission(): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) == PackageManager.PERMISSION_GRANTED + + fun canSendSms(): Boolean = hasSmsPermission() && hasTelephonyFeature() + + fun canSearchSms(): Boolean = hasReadSmsPermission() && hasTelephonyFeature() + + fun canReadSms(): Boolean = canSearchSms() + + fun hasTelephonyFeature(): Boolean = context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + + fun attachPermissionRequester(requester: PermissionRequester) { + permissionRequester = requester + } + + /** + * Send an SMS message. + * + * @param paramsJson JSON with "to" (phone number) and "message" (text) fields + * @return SendResult indicating success or failure + */ + suspend fun send(paramsJson: String?): SendResult { + if (!hasTelephonyFeature()) { + return errorResult( + error = "SMS_UNAVAILABLE: telephony not available", + ) + } + + if (!ensureSmsPermission()) { + return errorResult( + error = "SMS_PERMISSION_REQUIRED: grant SMS permission", + ) + } + + val parseResult = parseParams(paramsJson, json) + if (parseResult is ParseResult.Error) { + return errorResult( + error = parseResult.error, + to = parseResult.to, + message = parseResult.message, + ) + } + val params = (parseResult as ParseResult.Ok).params + + return try { + val smsManager = + context.getSystemService(AndroidSmsManager::class.java) + ?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available") + + val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) } + if (plan.useMultipart) { + smsManager.sendMultipartTextMessage( + params.to, + null, + ArrayList(plan.parts), + null, + null, + ) + } else { + smsManager.sendTextMessage( + params.to, + null, + params.message, + null, + null, + ) + } + + okResult(to = params.to, message = params.message) + } catch (e: SecurityException) { + errorResult( + error = "SMS_PERMISSION_REQUIRED: ${e.message}", + to = params.to, + message = params.message, + ) + } catch (e: Throwable) { + errorResult( + error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}", + to = params.to, + message = params.message, + ) + } + } + + /** + * 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 + val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS)) + 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 = + SendResult( + ok = true, + to = to, + message = message, + error = null, + payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null), + ) + + private fun errorResult( + error: String, + to: String = "", + message: String? = null, + ): SendResult = + SendResult( + ok = false, + to = to, + message = message, + error = error, + payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error), + ) + + private fun queryOk( + messages: List, + queryMetadata: QueryMetadata? = null, + ): SearchResult = + SearchResult( + ok = true, + messages = messages, + error = null, + payloadJson = buildQueryPayloadJson(json, ok = true, messages = messages, queryMetadata = queryMetadata), + ) + + private fun queryError(error: String): SearchResult = + SearchResult( + ok = false, + messages = emptyList(), + error = error, + payloadJson = buildQueryPayloadJson(json, ok = false, messages = emptyList(), error = error), + ) + + private fun getPhoneNumbersFromContactName(contactName: String): List { + val phoneNumbers = mutableListOf() + val selection = buildContactNameLikeSelection() + val selectionArgs = arrayOf(buildContactNameLikeArg(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) + sanitizeContactPhoneNumberOrNull(number)?.let(phoneNumbers::add) + } + } + + return phoneNumbers + } + + private fun querySmsMessages( + params: QueryParams, + phoneNumbers: List, + ): List { + val messages = mutableListOf() + + val selections = mutableListOf() + val selectionArgs = mutableListOf() + + 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()) + } + + val allPhoneNumbers = + if (!params.phoneNumber.isNullOrEmpty()) { + (phoneNumbers + normalizePhoneNumber(params.phoneNumber)).distinct() + } else { + 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()) { + val addressSelection = + allPhoneNumbers.joinToString(" OR ") { + "${Telephony.Sms.ADDRESS} LIKE ?" + } + selections.add("($addressSelection)") + allPhoneNumbers.forEach { + selectionArgs.add("%$it%") + } + } + + if (!params.keyword.isNullOrEmpty()) { + selections.add(buildKeywordLikeSelection()) + selectionArgs.add(buildKeywordLikeArg(params.keyword)) + } + + if (params.type != null) { + selections.add("${Telephony.Sms.TYPE} = ?") + selectionArgs.add(params.type.toString()) + } + + 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 + } + + // 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, + 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 + } + + private fun querySmsMmsMessagesByPhone( + phoneNumber: String, + params: QueryParams, + ): List { + val lookupNumber = toByPhoneLookupNumber(phoneNumber) + if (lookupNumber.isBlank()) { + return emptyList() + } + + val uri = "$MMS_SMS_BY_PHONE_BASE/${Uri.encode(lookupNumber)}".toUri() + val projection = buildMixedByPhoneProjection() + + val maxCandidates = params.offset + params.limit + if (maxCandidates <= 0) { + return emptyList() + } + + val reviewMode = shouldUseConversationReviewByPhoneMode(params) + val topCandidates = mutableListOf>() + val materializedCandidates = linkedMapOf() + 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( + MMS_PART_URI.toUri(), + 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 { + val cursor = + context.contentResolver.query( + "$MMS_CONTENT_BASE/$messageId".toUri(), + 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( + "$MMS_CONTENT_BASE/$messageId/addr".toUri(), + arrayOf("address", "type"), + null, + null, + null, + ) + + cursor?.use { + val addressIndex = it.getColumnIndex("address") + val typeIndex = it.getColumnIndex("type") + val addressRows = mutableListOf>() + 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 + } +}