From 849ded12b12dd5f37b47f0f1be38f279525dd7b7 Mon Sep 17 00:00:00 2001 From: "James (ClawdBot)" Date: Sun, 8 Feb 2026 00:30:06 -0500 Subject: [PATCH] chore: auto-commit uncommitted changes --- .../com/inou/clawdnode/sms/SmsProvider.kt | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 app/src/main/java/com/inou/clawdnode/sms/SmsProvider.kt diff --git a/app/src/main/java/com/inou/clawdnode/sms/SmsProvider.kt b/app/src/main/java/com/inou/clawdnode/sms/SmsProvider.kt new file mode 100644 index 0000000..849d3fd --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/sms/SmsProvider.kt @@ -0,0 +1,224 @@ +package com.inou.clawdnode.sms + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.ContactsContract +import android.telephony.SmsManager +import android.util.Log +import com.inou.clawdnode.ClawdNodeApp +import com.inou.clawdnode.debug.DebugClient +import org.json.JSONArray +import org.json.JSONObject + +data class SmsMessage( + val id: Long, + val address: String, + val contactName: String?, + val body: String, + val date: Long, + val read: Boolean, + val type: Int, + val threadId: Long +) { + fun toJson(): JSONObject = JSONObject().apply { + put("id", id) + put("address", address) + put("contactName", contactName ?: JSONObject.NULL) + put("body", body) + put("date", date) + put("read", read) + put("type", type) + put("threadId", threadId) + } +} + +data class SmsThread( + val threadId: Long, + val address: String, + val contactName: String?, + val lastMessage: String, + val lastDate: Long, + val messageCount: Int +) { + fun toJson(): JSONObject = JSONObject().apply { + put("threadId", threadId) + put("address", address) + put("contactName", contactName ?: JSONObject.NULL) + put("lastMessage", lastMessage) + put("lastDate", lastDate) + put("messageCount", messageCount) + } +} + +object SmsProvider { + private const val TAG = "SmsProvider" + private val contentResolver: ContentResolver + get() = ClawdNodeApp.instance.contentResolver + + private val contactCache = mutableMapOf() + + fun resolveContact(address: String): String? { + if (address.isBlank()) return null + contactCache[address]?.let { return it } + + return try { + val uri = Uri.withAppendedPath( + ContactsContract.PhoneLookup.CONTENT_FILTER_URI, + Uri.encode(address) + ) + contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getString(0) + } else null + }.also { contactCache[address] = it } + } catch (e: Exception) { + Log.w(TAG, "Contact lookup failed for $address: ${e.message}") + null + } + } + + private fun cursorToMessage(cursor: Cursor): SmsMessage { + val address = cursor.getString(cursor.getColumnIndexOrThrow("address")) ?: "" + return SmsMessage( + id = cursor.getLong(cursor.getColumnIndexOrThrow("_id")), + address = address, + contactName = resolveContact(address), + body = cursor.getString(cursor.getColumnIndexOrThrow("body")) ?: "", + date = cursor.getLong(cursor.getColumnIndexOrThrow("date")), + read = cursor.getInt(cursor.getColumnIndexOrThrow("read")) == 1, + type = cursor.getInt(cursor.getColumnIndexOrThrow("type")), + threadId = cursor.getLong(cursor.getColumnIndexOrThrow("thread_id")) + ) + } + + fun listMessages(limit: Int = 50, offset: Int = 0, since: Long? = null, type: Int? = null): List { + val selection = mutableListOf() + val args = mutableListOf() + + since?.let { + selection.add("date > ?") + args.add(it.toString()) + } + type?.let { + selection.add("type = ?") + args.add(it.toString()) + } + + val where = if (selection.isEmpty()) null else selection.joinToString(" AND ") + val messages = mutableListOf() + + try { + contentResolver.query( + Uri.parse("content://sms/"), + arrayOf("_id", "address", "body", "date", "read", "type", "thread_id"), + where, + if (args.isEmpty()) null else args.toTypedArray(), + "date DESC LIMIT $limit OFFSET $offset" + )?.use { cursor -> + while (cursor.moveToNext()) { + messages.add(cursorToMessage(cursor)) + } + } + } catch (e: Exception) { + Log.e(TAG, "listMessages failed", e) + DebugClient.error("SmsProvider.listMessages failed", e) + } + + return messages + } + + fun getMessage(id: Long): SmsMessage? { + return try { + contentResolver.query( + Uri.parse("content://sms/$id"), + arrayOf("_id", "address", "body", "date", "read", "type", "thread_id"), + null, null, null + )?.use { cursor -> + if (cursor.moveToFirst()) cursorToMessage(cursor) else null + } + } catch (e: Exception) { + Log.e(TAG, "getMessage failed for id=$id", e) + null + } + } + + fun getConversation(threadId: Long, limit: Int = 50): List { + val messages = mutableListOf() + try { + contentResolver.query( + Uri.parse("content://sms/"), + arrayOf("_id", "address", "body", "date", "read", "type", "thread_id"), + "thread_id = ?", + arrayOf(threadId.toString()), + "date DESC LIMIT $limit" + )?.use { cursor -> + while (cursor.moveToNext()) { + messages.add(cursorToMessage(cursor)) + } + } + } catch (e: Exception) { + Log.e(TAG, "getConversation failed for thread=$threadId", e) + } + return messages + } + + fun getThreads(limit: Int = 20): List { + val threads = mutableListOf() + try { + // Get distinct thread IDs with latest message + contentResolver.query( + Uri.parse("content://sms/"), + arrayOf("_id", "address", "body", "date", "read", "type", "thread_id"), + null, null, "date DESC" + )?.use { cursor -> + val seenThreads = mutableSetOf() + val threadCounts = mutableMapOf() + + // First pass: count messages per thread + while (cursor.moveToNext()) { + val tid = cursor.getLong(cursor.getColumnIndexOrThrow("thread_id")) + threadCounts[tid] = (threadCounts[tid] ?: 0) + 1 + } + + // Second pass: get latest message per thread + cursor.moveToPosition(-1) + while (cursor.moveToNext() && seenThreads.size < limit) { + val tid = cursor.getLong(cursor.getColumnIndexOrThrow("thread_id")) + if (seenThreads.contains(tid)) continue + seenThreads.add(tid) + + val address = cursor.getString(cursor.getColumnIndexOrThrow("address")) ?: "" + threads.add(SmsThread( + threadId = tid, + address = address, + contactName = resolveContact(address), + lastMessage = cursor.getString(cursor.getColumnIndexOrThrow("body")) ?: "", + lastDate = cursor.getLong(cursor.getColumnIndexOrThrow("date")), + messageCount = threadCounts[tid] ?: 0 + )) + } + } + } catch (e: Exception) { + Log.e(TAG, "getThreads failed", e) + } + return threads + } + + fun sendSms(to: String, body: String) { + try { + val smsManager = SmsManager.getDefault() + val parts = smsManager.divideMessage(body) + if (parts.size == 1) { + smsManager.sendTextMessage(to, null, body, null, null) + } else { + smsManager.sendMultipartTextMessage(to, null, parts, null, null) + } + DebugClient.log("SMS sent", mapOf("to" to to, "length" to body.length)) + } catch (e: Exception) { + Log.e(TAG, "sendSms failed", e) + DebugClient.error("SmsProvider.sendSms failed", e) + throw e + } + } +}