diff --git a/apps/android/.editorconfig b/apps/android/.editorconfig index 7a4a46eb3aa..f829249bc4d 100644 --- a/apps/android/.editorconfig +++ b/apps/android/.editorconfig @@ -11,9 +11,6 @@ indent_style = space indent_size = 2 max_line_length = off ktlint_standard_filename = disabled -ktlint_standard_function-expression-body = disabled ktlint_standard_function-naming = disabled -ktlint_standard_if-else-bracing = disabled ktlint_standard_max-line-length = disabled -ktlint_standard_no-wildcard-imports = disabled ktlint_standard_property-naming = disabled diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index b23fc6b1b3d..363caefe383 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -33,10 +33,10 @@ if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) { } plugins { - id("com.android.application") - id("org.jlleitschuh.gradle.ktlint") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.kotlin.plugin.serialization") + alias(libs.plugins.android.application) + alias(libs.plugins.ktlint) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -78,13 +78,9 @@ android { productFlavors { create("play") { dimension = "store" - buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false") - buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false") } create("thirdParty") { dimension = "store" - buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true") - buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true") } } @@ -133,15 +129,7 @@ android { } lint { - disable += - setOf( - "AndroidGradlePluginVersion", - "GradleDependency", - "HighAppVersionCode", - "IconLauncherShape", - "NewerVersionAvailable", - "OldTargetApi", - ) + lintConfig = file("lint.xml") warningsAsErrors = true } @@ -184,57 +172,57 @@ ktlint { } dependencies { - val composeBom = platform("androidx.compose:compose-bom:2026.04.01") + val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) - implementation("androidx.core:core-ktx:1.18.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") - implementation("androidx.activity:activity-compose:1.13.0") - implementation("androidx.webkit:webkit:1.15.0") + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.webkit) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. // R8 will tree-shake unused icons when minify is enabled on release builds. - implementation("androidx.compose.material:material-icons-extended") + implementation(libs.androidx.compose.material.icons.extended) - debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation(libs.androidx.compose.ui.tooling) // Material Components (XML theme + resources) - implementation("com.google.android.material:material:1.13.0") + implementation(libs.material) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) - implementation("androidx.security:security-crypto:1.1.0") - implementation("androidx.exifinterface:exifinterface:1.4.2") - implementation("com.squareup.okhttp3:okhttp:5.3.2") - implementation("org.bouncycastle:bcprov-jdk18on:1.84") - implementation("org.commonmark:commonmark:0.28.0") - implementation("org.commonmark:commonmark-ext-autolink:0.28.0") - implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.28.0") - implementation("org.commonmark:commonmark-ext-gfm-tables:0.28.0") - implementation("org.commonmark:commonmark-ext-task-list-items:0.28.0") + implementation(libs.androidx.security.crypto) + implementation(libs.androidx.exifinterface) + implementation(libs.okhttp) + implementation(libs.bcprov) + implementation(libs.commonmark) + implementation(libs.commonmark.ext.autolink) + implementation(libs.commonmark.ext.gfm.strikethrough) + implementation(libs.commonmark.ext.gfm.tables) + implementation(libs.commonmark.ext.task.list.items) // CameraX (for node.invoke camera.* parity) - implementation("androidx.camera:camera-core:1.6.0") - implementation("androidx.camera:camera-camera2:1.6.0") - implementation("androidx.camera:camera-lifecycle:1.6.0") - implementation("androidx.camera:camera-video:1.6.0") - implementation("com.google.android.gms:play-services-code-scanner:16.1.0") + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.video) + implementation(libs.play.services.code.scanner) // Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains. - implementation("dnsjava:dnsjava:3.6.4") + implementation(libs.dnsjava) - testImplementation("junit:junit:4.13.2") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") - testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.11") - testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.11") - testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2") - testImplementation("org.robolectric:robolectric:4.16.1") - testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.3") + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.mockwebserver) + testImplementation(libs.robolectric) + testRuntimeOnly(libs.junit.vintage.engine) } tasks.withType().configureEach { diff --git a/apps/android/app/lint.xml b/apps/android/app/lint.xml new file mode 100644 index 00000000000..e0fee5d8c7c --- /dev/null +++ b/apps/android/app/lint.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index e3d82e0ee72..166c31d4557 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -13,7 +13,33 @@ import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.GatewayTlsProbeFailure import ai.openclaw.app.gateway.GatewayTlsProbeResult import ai.openclaw.app.gateway.probeGatewayTlsFingerprint -import ai.openclaw.app.node.* +import ai.openclaw.app.node.A2UIHandler +import ai.openclaw.app.node.CalendarHandler +import ai.openclaw.app.node.CallLogHandler +import ai.openclaw.app.node.CameraCaptureManager +import ai.openclaw.app.node.CameraHandler +import ai.openclaw.app.node.CanvasController +import ai.openclaw.app.node.ConnectionManager +import ai.openclaw.app.node.ContactsHandler +import ai.openclaw.app.node.DEFAULT_SEAM_COLOR_ARGB +import ai.openclaw.app.node.DebugHandler +import ai.openclaw.app.node.DeviceHandler +import ai.openclaw.app.node.DeviceNotificationListenerService +import ai.openclaw.app.node.InvokeDispatcher +import ai.openclaw.app.node.LocationCaptureManager +import ai.openclaw.app.node.LocationHandler +import ai.openclaw.app.node.MotionHandler +import ai.openclaw.app.node.NodePresenceAliveBeacon +import ai.openclaw.app.node.NotificationsHandler +import ai.openclaw.app.node.PhotosHandler +import ai.openclaw.app.node.Quad +import ai.openclaw.app.node.SmsHandler +import ai.openclaw.app.node.SmsManager +import ai.openclaw.app.node.SystemHandler +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import ai.openclaw.app.node.invokeErrorFromThrowable +import ai.openclaw.app.node.parseHexColorArgb import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction import ai.openclaw.app.voice.MicCaptureManager import ai.openclaw.app.voice.TalkModeManager @@ -103,8 +129,8 @@ class NodeRuntime( private val deviceHandler: DeviceHandler = DeviceHandler( appContext = appContext, - smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS, - callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG, + smsEnabled = SensitiveFeatureConfig.smsEnabled, + callLogEnabled = SensitiveFeatureConfig.callLogEnabled, ) private val notificationsHandler: NotificationsHandler = @@ -163,10 +189,10 @@ class NodeRuntime( voiceWakeMode = { VoiceWakeMode.Off }, motionActivityAvailable = { motionHandler.isActivityAvailable() }, 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 }, + sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() }, + readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() }, + smsSearchPossible = { SensitiveFeatureConfig.smsEnabled && sms.hasTelephonyFeature() }, + callLogAvailable = { SensitiveFeatureConfig.callLogEnabled }, hasRecordAudioPermission = { hasRecordAudioPermission() }, manualTls = { manualTls.value }, ) @@ -190,11 +216,11 @@ class NodeRuntime( isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, - sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() }, - readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() }, - smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS }, + sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() }, + readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() }, + smsFeatureEnabled = { SensitiveFeatureConfig.smsEnabled }, smsTelephonyAvailable = { sms.hasTelephonyFeature() }, - callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }, + callLogAvailable = { SensitiveFeatureConfig.callLogEnabled }, debugBuild = { BuildConfig.DEBUG }, refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() }, onCanvasA2uiPush = { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CallLogHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CallLogHandler.kt deleted file mode 100644 index 70b41df08c9..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CallLogHandler.kt +++ /dev/null @@ -1,257 +0,0 @@ -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/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index f810afd87d6..5ee5cf7a506 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -1,6 +1,7 @@ package ai.openclaw.app.node import ai.openclaw.app.BuildConfig +import ai.openclaw.app.SensitiveFeatureConfig import ai.openclaw.app.gateway.GatewaySession import android.Manifest import android.app.ActivityManager @@ -25,8 +26,8 @@ import java.util.Locale class DeviceHandler( private val appContext: Context, - private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS, - private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG, + private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled, + private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled, ) { companion object { internal fun hasAnySmsCapability( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt deleted file mode 100644 index 74c930a27ca..00000000000 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/SmsManager.kt +++ /dev/null @@ -1,1154 +0,0 @@ -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 - } -} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 599a7569a5c..741d3f3a4a3 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -1,8 +1,8 @@ package ai.openclaw.app.ui -import ai.openclaw.app.BuildConfig import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel +import ai.openclaw.app.SensitiveFeatureConfig import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.node.DeviceNotificationListenerService import android.Manifest @@ -248,10 +248,10 @@ fun OnboardingFlow( val smsAvailable = remember(context) { - BuildConfig.OPENCLAW_ENABLE_SMS && + SensitiveFeatureConfig.smsEnabled && context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true } - val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG } + val callLogAvailable = remember { SensitiveFeatureConfig.callLogEnabled } val motionAvailable = remember(context) { hasMotionCapabilities(context) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index 6b3b5a0322b..67cf721a005 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -4,6 +4,7 @@ import ai.openclaw.app.BuildConfig import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel import ai.openclaw.app.NotificationPackageFilterMode +import ai.openclaw.app.SensitiveFeatureConfig import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.normalizeLocalHourMinute import android.Manifest @@ -204,10 +205,10 @@ fun SettingsSheet(viewModel: MainViewModel) { val smsPermissionAvailable = remember { - BuildConfig.OPENCLAW_ENABLE_SMS && + SensitiveFeatureConfig.smsEnabled && context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true } - val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG } + val callLogPermissionAvailable = remember { SensitiveFeatureConfig.callLogEnabled } val photosPermission = if (Build.VERSION.SDK_INT >= 33) { Manifest.permission.READ_MEDIA_IMAGES diff --git a/apps/android/app/src/play/java/ai/openclaw/app/SensitiveFeatureConfig.kt b/apps/android/app/src/play/java/ai/openclaw/app/SensitiveFeatureConfig.kt new file mode 100644 index 00000000000..ce7abe1abbd --- /dev/null +++ b/apps/android/app/src/play/java/ai/openclaw/app/SensitiveFeatureConfig.kt @@ -0,0 +1,6 @@ +package ai.openclaw.app + +object SensitiveFeatureConfig { + const val smsEnabled: Boolean = false + const val callLogEnabled: Boolean = false +} diff --git a/apps/android/app/src/play/java/ai/openclaw/app/node/CallLogHandler.kt b/apps/android/app/src/play/java/ai/openclaw/app/node/CallLogHandler.kt new file mode 100644 index 00000000000..2beb1677b4f --- /dev/null +++ b/apps/android/app/src/play/java/ai/openclaw/app/node/CallLogHandler.kt @@ -0,0 +1,54 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.gateway.GatewaySession +import android.content.Context + +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, + val offset: Int, + val cachedName: String?, + val number: String?, + val date: Long?, + val dateStart: Long?, + val dateEnd: Long?, + val duration: Long?, + val type: Int?, +) + +internal interface CallLogDataSource { + fun hasReadPermission(context: Context): Boolean + + fun search( + context: Context, + request: CallLogSearchRequest, + ): List +} + +class CallLogHandler private constructor() { + constructor( + @Suppress("unused") appContext: Context, + ) : this() + + fun handleCallLogSearch( + @Suppress("unused") paramsJson: String?, + ): GatewaySession.InvokeResult = + GatewaySession.InvokeResult.error( + code = "CALL_LOG_UNAVAILABLE", + message = "CALL_LOG_UNAVAILABLE: call log not available on this build", + ) + + companion object { + internal fun forTesting( + @Suppress("unused") appContext: Context, + @Suppress("unused") dataSource: CallLogDataSource, + ): CallLogHandler = CallLogHandler() + } +} diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt b/apps/android/app/src/play/java/ai/openclaw/app/node/SmsHandler.kt similarity index 100% rename from apps/android/app/src/main/java/ai/openclaw/app/node/SmsHandler.kt rename to apps/android/app/src/play/java/ai/openclaw/app/node/SmsHandler.kt diff --git a/apps/android/app/src/play/java/ai/openclaw/app/node/SmsManager.kt b/apps/android/app/src/play/java/ai/openclaw/app/node/SmsManager.kt new file mode 100644 index 00000000000..7ef9aa7b0c1 --- /dev/null +++ b/apps/android/app/src/play/java/ai/openclaw/app/node/SmsManager.kt @@ -0,0 +1,69 @@ +package ai.openclaw.app.node + +import ai.openclaw.app.PermissionRequester +import android.content.Context + +class SmsManager( + @Suppress("unused") private val context: Context, +) { + data class SendResult( + val ok: Boolean, + val to: String, + val message: String?, + val error: String? = null, + val payloadJson: String, + ) + + 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, + ) + + fun attachPermissionRequester( + @Suppress("unused") requester: PermissionRequester, + ) { + } + + fun canSendSms(): Boolean = false + + fun canSearchSms(): Boolean = false + + fun canReadSms(): Boolean = false + + fun hasTelephonyFeature(): Boolean = false + + suspend fun send(paramsJson: String?): SendResult = + SendResult( + ok = false, + to = "", + message = null, + error = "SMS_PERMISSION_REQUIRED: grant SMS permission", + payloadJson = unavailablePayload(paramsJson), + ) + + suspend fun search(paramsJson: String?): SearchResult = + SearchResult( + ok = false, + messages = emptyList(), + error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission", + payloadJson = unavailablePayload(paramsJson), + ) + + private fun unavailablePayload(paramsJson: String?): String = """{"ok":false,"error":"SMS_UNAVAILABLE","paramsProvided":${!paramsJson.isNullOrBlank()}}""" +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/CallLogHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/CallLogHandlerTest.kt deleted file mode 100644 index ca2cc587e40..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/CallLogHandlerTest.kt +++ /dev/null @@ -1,291 +0,0 @@ -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/test/java/ai/openclaw/app/node/SmsManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt deleted file mode 100644 index efdd356977c..00000000000 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/SmsManagerTest.kt +++ /dev/null @@ -1,1089 +0,0 @@ -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/SensitiveFeatureConfig.kt b/apps/android/app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt new file mode 100644 index 00000000000..339d84f0519 --- /dev/null +++ b/apps/android/app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt @@ -0,0 +1,6 @@ +package ai.openclaw.app + +object SensitiveFeatureConfig { + const val smsEnabled: Boolean = true + const val callLogEnabled: Boolean = true +} diff --git a/apps/android/benchmark/build.gradle.kts b/apps/android/benchmark/build.gradle.kts index 58aa8667ab2..e3e2a6641bd 100644 --- a/apps/android/benchmark/build.gradle.kts +++ b/apps/android/benchmark/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("com.android.test") - id("org.jlleitschuh.gradle.ktlint") + alias(libs.plugins.android.test) + alias(libs.plugins.ktlint) } android { @@ -39,7 +39,7 @@ ktlint { } dependencies { - implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1") - implementation("androidx.test.ext:junit:1.3.0") - implementation("androidx.test.uiautomator:uiautomator:2.4.0-beta02") + implementation(libs.androidx.benchmark.macro.junit4) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.uiautomator) } diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index fbc5ba4e845..07feb467e64 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,7 +1,7 @@ plugins { - id("com.android.application") version "9.2.0" apply false - id("com.android.test") version "9.2.0" apply false - id("org.jlleitschuh.gradle.ktlint") version "14.2.0" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.test) apply false + alias(libs.plugins.ktlint) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false } diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml new file mode 100644 index 00000000000..555c5ac4b84 --- /dev/null +++ b/apps/android/gradle/libs.versions.toml @@ -0,0 +1,74 @@ +[versions] +agp = "9.2.0" +androidx-activity = "1.13.0" +androidx-benchmark = "1.4.1" +androidx-camera = "1.6.0" +androidx-compose-bom = "2026.04.01" +androidx-core = "1.18.0" +androidx-exifinterface = "1.4.2" +androidx-lifecycle = "2.10.0" +androidx-security = "1.1.0" +androidx-test-ext = "1.3.0" +androidx-uiautomator = "2.4.0-beta02" +androidx-webkit = "1.15.0" +bcprov = "1.84" +commonmark = "0.28.0" +coroutines = "1.10.2" +dnsjava = "3.6.4" +junit = "4.13.2" +junit-vintage = "6.0.3" +kotest = "6.1.11" +ktlint-gradle = "14.2.0" +kotlin = "2.3.21" +material = "1.13.0" +okhttp = "5.3.2" +play-services-code-scanner = "16.1.0" +robolectric = "4.16.1" +serialization-json = "1.11.0" + +[libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" } +androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "androidx-camera" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } +androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } +bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" } +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } +commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } +commonmark-ext-task-list-items = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" } +dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" } +junit = { module = "junit:junit", version.ref = "junit" } +junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" } +material = { module = "com.google.android.material:material", version.ref = "material" } +mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "play-services-code-scanner" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-test = { id = "com.android.test", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }