From 780fb630e29261174cc1c105b504bc16059435cc Mon Sep 17 00:00:00 2001 From: "James (ClawdBot)" Date: Sun, 8 Feb 2026 00:30:55 -0500 Subject: [PATCH] feat: add SMS read/monitor/send support --- app/src/main/AndroidManifest.xml | 5 + .../inou/clawdnode/gateway/DirectGateway.kt | 83 ++++++++++++++++ .../com/inou/clawdnode/service/NodeService.kt | 6 ++ .../java/com/inou/clawdnode/sms/SmsMonitor.kt | 99 +++++++++++++++++++ .../com/inou/clawdnode/ui/MainActivity.kt | 5 +- 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/inou/clawdnode/sms/SmsMonitor.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea169d2..f262d7d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,11 @@ + + + + + diff --git a/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt b/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt index 06e4013..7593ee2 100644 --- a/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt +++ b/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt @@ -3,8 +3,10 @@ package com.inou.clawdnode.gateway import android.util.Log import com.inou.clawdnode.ClawdNodeApp import com.inou.clawdnode.debug.DebugClient +import com.inou.clawdnode.sms.SmsProvider import kotlinx.coroutines.* import okhttp3.* +import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit @@ -232,6 +234,60 @@ object DirectGateway { ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.hangup") } + "sms.list" -> { + val limit = params.optInt("limit", 50) + val offset = params.optInt("offset", 0) + val since = if (params.has("since")) params.optLong("since") else null + val type = if (params.has("type")) params.optInt("type") else null + val messages = SmsProvider.listMessages(limit, offset, since, type) + val arr = JSONArray() + messages.forEach { arr.put(it.toJson()) } + sendDataResponse(commandId, JSONObject().put("messages", arr)) + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "sms.list") + } + + "sms.read" -> { + val id = params.optLong("id") + val msg = SmsProvider.getMessage(id) + if (msg != null) { + sendDataResponse(commandId, msg.toJson()) + } else { + sendResponse(commandId, false, "Message not found: $id") + } + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "sms.read") + } + + "sms.conversation" -> { + val threadId = params.optLong("threadId") + val limit = params.optInt("limit", 50) + val messages = SmsProvider.getConversation(threadId, limit) + val arr = JSONArray() + messages.forEach { arr.put(it.toJson()) } + sendDataResponse(commandId, JSONObject().put("messages", arr)) + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "sms.conversation") + } + + "sms.threads" -> { + val limit = params.optInt("limit", 20) + val threads = SmsProvider.getThreads(limit) + val arr = JSONArray() + threads.forEach { arr.put(it.toJson()) } + sendDataResponse(commandId, JSONObject().put("threads", arr)) + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "sms.threads") + } + + "sms.send" -> { + val to = params.optString("to") + val body = params.optString("body") + if (to.isBlank() || body.isBlank()) { + sendResponse(commandId, false, "Missing 'to' or 'body'") + } else { + SmsProvider.sendSms(to, body) + sendResponse(commandId, true) + } + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "sms.send to=$to") + } + else -> { Log.w(TAG, "Unknown command: $command") sendResponse(commandId, false, "Unknown command: $command") @@ -252,6 +308,20 @@ object DirectGateway { "error" to error )) } + + private fun sendDataResponse(commandId: String, data: JSONObject) { + if (!isConnected) return + try { + val json = JSONObject() + json.put("type", "response") + json.put("commandId", commandId) + json.put("success", true) + json.put("data", data) + webSocket?.send(json.toString()) + } catch (e: Exception) { + Log.e(TAG, "sendDataResponse failed", e) + } + } // ======================================== // Outgoing events @@ -293,6 +363,19 @@ object DirectGateway { )) } + fun sendSmsReceived(id: Long, address: String, contactName: String?, body: String, date: Long, threadId: Long) { + send(mapOf( + "type" to "sms.received", + "id" to id, + "address" to address, + "contactName" to contactName, + "body" to body, + "date" to date, + "threadId" to threadId, + "timestamp" to System.currentTimeMillis() + )) + } + fun sendLog(message: String, data: Map = emptyMap()) { send(mapOf("type" to "log", "message" to message) + data) } diff --git a/app/src/main/java/com/inou/clawdnode/service/NodeService.kt b/app/src/main/java/com/inou/clawdnode/service/NodeService.kt index a8baa6e..b9d746c 100644 --- a/app/src/main/java/com/inou/clawdnode/service/NodeService.kt +++ b/app/src/main/java/com/inou/clawdnode/service/NodeService.kt @@ -12,6 +12,7 @@ import com.inou.clawdnode.ClawdNodeApp import com.inou.clawdnode.R import com.inou.clawdnode.gateway.DirectGateway import com.inou.clawdnode.protocol.* +import com.inou.clawdnode.sms.SmsMonitor import com.inou.clawdnode.ui.MainActivity /** @@ -24,6 +25,7 @@ class NodeService : Service() { private val binder = LocalBinder() private var isConnected = false + private val smsMonitor = SmsMonitor() // Callbacks for UI updates var onConnectionChange: ((Boolean) -> Unit)? = null @@ -62,6 +64,9 @@ class NodeService : Service() { DirectGateway.onCallHangup = { callId -> CallManager.hangup(callId) } + + // Start SMS monitor + smsMonitor.start() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -78,6 +83,7 @@ class NodeService : Service() { override fun onDestroy() { Log.i(tag, "NodeService destroyed") + smsMonitor.stop() DirectGateway.disconnect() ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed") super.onDestroy() diff --git a/app/src/main/java/com/inou/clawdnode/sms/SmsMonitor.kt b/app/src/main/java/com/inou/clawdnode/sms/SmsMonitor.kt new file mode 100644 index 0000000..671c910 --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/sms/SmsMonitor.kt @@ -0,0 +1,99 @@ +package com.inou.clawdnode.sms + +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.inou.clawdnode.ClawdNodeApp +import com.inou.clawdnode.debug.DebugClient +import com.inou.clawdnode.gateway.DirectGateway + +/** + * Monitors content://sms/ for new incoming messages via ContentObserver. + * Reports new SMS to the gateway as "sms.received" events. + */ +class SmsMonitor { + private val TAG = "SmsMonitor" + private var observer: ContentObserver? = null + private var lastSeenId: Long = -1 + private var started = false + + fun start() { + if (started) return + started = true + + // Initialize lastSeenId to current max + try { + ClawdNodeApp.instance.contentResolver.query( + Uri.parse("content://sms/"), + arrayOf("_id"), + null, null, "_id DESC LIMIT 1" + )?.use { cursor -> + if (cursor.moveToFirst()) { + lastSeenId = cursor.getLong(0) + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to get initial SMS ID", e) + } + + observer = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + checkNewMessages() + } + } + + ClawdNodeApp.instance.contentResolver.registerContentObserver( + Uri.parse("content://sms/"), + true, + observer!! + ) + + Log.i(TAG, "SmsMonitor started, lastSeenId=$lastSeenId") + DebugClient.log("SmsMonitor started", mapOf("lastSeenId" to lastSeenId)) + } + + fun stop() { + if (!started) return + observer?.let { + ClawdNodeApp.instance.contentResolver.unregisterContentObserver(it) + } + observer = null + started = false + Log.i(TAG, "SmsMonitor stopped") + } + + private fun checkNewMessages() { + try { + ClawdNodeApp.instance.contentResolver.query( + Uri.parse("content://sms/inbox"), + arrayOf("_id", "address", "body", "date", "thread_id"), + if (lastSeenId > 0) "_id > ?" else null, + if (lastSeenId > 0) arrayOf(lastSeenId.toString()) else null, + "_id ASC" + )?.use { cursor -> + while (cursor.moveToNext()) { + val id = cursor.getLong(0) + val address = cursor.getString(1) ?: "" + val body = cursor.getString(2) ?: "" + val date = cursor.getLong(3) + val threadId = cursor.getLong(4) + val contactName = SmsProvider.resolveContact(address) + + DirectGateway.sendSmsReceived(id, address, contactName, body, date, threadId) + lastSeenId = id + + DebugClient.log("New SMS detected", mapOf( + "id" to id, + "from" to address, + "contact" to (contactName ?: "unknown") + )) + } + } + } catch (e: Exception) { + Log.e(TAG, "checkNewMessages failed", e) + DebugClient.error("SmsMonitor.checkNewMessages failed", e) + } + } +} diff --git a/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt b/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt index bf849c6..db6f07e 100644 --- a/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt +++ b/app/src/main/java/com/inou/clawdnode/ui/MainActivity.kt @@ -275,7 +275,10 @@ class MainActivity : AppCompatActivity() { Manifest.permission.ANSWER_PHONE_CALLS, Manifest.permission.RECORD_AUDIO, Manifest.permission.READ_CONTACTS, - Manifest.permission.POST_NOTIFICATIONS + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.READ_SMS, + Manifest.permission.SEND_SMS, + Manifest.permission.RECEIVE_SMS ) } }