feat: add SMS read/monitor/send support
This commit is contained in:
parent
849ded12b1
commit
780fb630e2
|
|
@ -26,6 +26,11 @@
|
||||||
<!-- Contacts for caller ID -->
|
<!-- Contacts for caller ID -->
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
|
|
||||||
|
<!-- SMS -->
|
||||||
|
<uses-permission android:name="android.permission.READ_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||||
|
|
||||||
<!-- Notifications (bind to notification listener) -->
|
<!-- Notifications (bind to notification listener) -->
|
||||||
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package com.inou.clawdnode.gateway
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.inou.clawdnode.ClawdNodeApp
|
import com.inou.clawdnode.ClawdNodeApp
|
||||||
import com.inou.clawdnode.debug.DebugClient
|
import com.inou.clawdnode.debug.DebugClient
|
||||||
|
import com.inou.clawdnode.sms.SmsProvider
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
@ -232,6 +234,60 @@ object DirectGateway {
|
||||||
ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.hangup")
|
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 -> {
|
else -> {
|
||||||
Log.w(TAG, "Unknown command: $command")
|
Log.w(TAG, "Unknown command: $command")
|
||||||
sendResponse(commandId, false, "Unknown command: $command")
|
sendResponse(commandId, false, "Unknown command: $command")
|
||||||
|
|
@ -253,6 +309,20 @@ object DirectGateway {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// 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<String, Any?> = emptyMap()) {
|
fun sendLog(message: String, data: Map<String, Any?> = emptyMap()) {
|
||||||
send(mapOf("type" to "log", "message" to message) + data)
|
send(mapOf("type" to "log", "message" to message) + data)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.inou.clawdnode.ClawdNodeApp
|
||||||
import com.inou.clawdnode.R
|
import com.inou.clawdnode.R
|
||||||
import com.inou.clawdnode.gateway.DirectGateway
|
import com.inou.clawdnode.gateway.DirectGateway
|
||||||
import com.inou.clawdnode.protocol.*
|
import com.inou.clawdnode.protocol.*
|
||||||
|
import com.inou.clawdnode.sms.SmsMonitor
|
||||||
import com.inou.clawdnode.ui.MainActivity
|
import com.inou.clawdnode.ui.MainActivity
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,6 +25,7 @@ class NodeService : Service() {
|
||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
private var isConnected = false
|
private var isConnected = false
|
||||||
|
private val smsMonitor = SmsMonitor()
|
||||||
|
|
||||||
// Callbacks for UI updates
|
// Callbacks for UI updates
|
||||||
var onConnectionChange: ((Boolean) -> Unit)? = null
|
var onConnectionChange: ((Boolean) -> Unit)? = null
|
||||||
|
|
@ -62,6 +64,9 @@ class NodeService : Service() {
|
||||||
DirectGateway.onCallHangup = { callId ->
|
DirectGateway.onCallHangup = { callId ->
|
||||||
CallManager.hangup(callId)
|
CallManager.hangup(callId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start SMS monitor
|
||||||
|
smsMonitor.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
|
@ -78,6 +83,7 @@ class NodeService : Service() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.i(tag, "NodeService destroyed")
|
Log.i(tag, "NodeService destroyed")
|
||||||
|
smsMonitor.stop()
|
||||||
DirectGateway.disconnect()
|
DirectGateway.disconnect()
|
||||||
ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed")
|
ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed")
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -275,7 +275,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
Manifest.permission.ANSWER_PHONE_CALLS,
|
Manifest.permission.ANSWER_PHONE_CALLS,
|
||||||
Manifest.permission.RECORD_AUDIO,
|
Manifest.permission.RECORD_AUDIO,
|
||||||
Manifest.permission.READ_CONTACTS,
|
Manifest.permission.READ_CONTACTS,
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
Manifest.permission.READ_SMS,
|
||||||
|
Manifest.permission.SEND_SMS,
|
||||||
|
Manifest.permission.RECEIVE_SMS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue