Compare commits
10 Commits
b8d1705c9c
...
07e943dfaf
| Author | SHA1 | Date |
|---|---|---|
|
|
07e943dfaf | |
|
|
415703665d | |
|
|
585f921601 | |
|
|
0cd111343f | |
|
|
b7e213ff32 | |
|
|
1121728cd2 | |
|
|
0a4ef6a47e | |
|
|
780fb630e2 | |
|
|
849ded12b1 | |
|
|
ed7e23003c |
14
STATUS.md
14
STATUS.md
|
|
@ -1,6 +1,6 @@
|
|||
# ClawdNode Android - Status Report
|
||||
|
||||
**Last Updated:** 2025-01-27
|
||||
**Last Updated:** 2026-03-02
|
||||
**Version:** 0.1.0
|
||||
**Branch:** main (clean, up to date with origin)
|
||||
|
||||
|
|
@ -44,12 +44,12 @@
|
|||
|
||||
## 🔧 TODOs Found in Code
|
||||
|
||||
| File | Line | TODO |
|
||||
|------|------|------|
|
||||
| `VoiceCallService.kt` | 143 | Calculate actual call duration |
|
||||
| `CallScreener.kt` | 86 | Contact lookup (returns null currently) |
|
||||
| `GatewayClient.kt` | 150 | Queue events for retry when disconnected |
|
||||
| `NodeService.kt` | 122 | Screenshot capture via MediaProjection |
|
||||
| File | Line | TODO | Status |
|
||||
|------|------|------|--------|
|
||||
| `VoiceCallService.kt` | - | ~~Calculate actual call duration~~ | ✅ Fixed |
|
||||
| `CallScreener.kt` | - | ~~Contact lookup~~ | ✅ Fixed |
|
||||
| `GatewayClient.kt` | - | ~~Queue events for retry when disconnected~~ | ✅ Fixed |
|
||||
| `NodeService.kt` | - | ~~Screenshot capture via MediaProjection~~ | ✅ Fixed |
|
||||
|
||||
## 📋 What's Ready for Testing
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<!-- Foreground service -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
|
|
@ -26,6 +27,11 @@
|
|||
<!-- Contacts for caller ID -->
|
||||
<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) -->
|
||||
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import android.app.NotificationManager
|
|||
import android.os.Build
|
||||
import com.inou.clawdnode.debug.DebugClient
|
||||
import com.inou.clawdnode.gateway.DirectGateway
|
||||
import com.inou.clawdnode.screenshot.ScreenshotManager
|
||||
import com.inou.clawdnode.security.AuditLog
|
||||
import com.inou.clawdnode.security.DeviceIdentity
|
||||
import com.inou.clawdnode.security.TokenStore
|
||||
import com.inou.clawdnode.service.CallManager
|
||||
import com.inou.clawdnode.service.NotificationManager as AppNotificationManager
|
||||
|
||||
/**
|
||||
|
|
@ -33,6 +35,9 @@ class ClawdNodeApp : Application() {
|
|||
tokenStore = TokenStore(this)
|
||||
auditLog = AuditLog(this)
|
||||
|
||||
// Initialize screenshot capture
|
||||
ScreenshotManager.init(this)
|
||||
|
||||
// Create notification channels
|
||||
createNotificationChannels()
|
||||
|
||||
|
|
@ -58,17 +63,17 @@ class ClawdNodeApp : Application() {
|
|||
|
||||
DirectGateway.onCallAnswer = { callId ->
|
||||
auditLog.log("COMMAND_RECEIVED", "call.answer: $callId")
|
||||
// TODO: Implement call answering via TelecomManager
|
||||
CallManager.answer(callId, null)
|
||||
}
|
||||
|
||||
DirectGateway.onCallReject = { callId ->
|
||||
auditLog.log("COMMAND_RECEIVED", "call.reject: $callId")
|
||||
// TODO: Implement call rejection
|
||||
CallManager.reject(callId, null)
|
||||
}
|
||||
|
||||
DirectGateway.onCallHangup = { callId ->
|
||||
auditLog.log("COMMAND_RECEIVED", "call.hangup: $callId")
|
||||
// TODO: Implement call hangup
|
||||
CallManager.hangup(callId)
|
||||
}
|
||||
|
||||
// Connect
|
||||
|
|
|
|||
|
|
@ -4,14 +4,16 @@ import android.content.ComponentName
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.provider.ContactsContract
|
||||
import android.telecom.Call
|
||||
import android.telecom.CallScreeningService
|
||||
import android.util.Log
|
||||
import com.inou.clawdnode.gateway.DirectGateway
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.debug.DebugClient
|
||||
import com.inou.clawdnode.gateway.DirectGateway
|
||||
import com.inou.clawdnode.protocol.CallIncomingEvent
|
||||
import com.inou.clawdnode.service.NodeService
|
||||
|
||||
/**
|
||||
|
|
@ -73,12 +75,12 @@ class CallScreener : CallScreeningService() {
|
|||
|
||||
// Send event to Gateway via WebSocket
|
||||
try {
|
||||
val event = CallIncomingEvent(
|
||||
DirectGateway.sendCall(
|
||||
callId = callId,
|
||||
number = number,
|
||||
contact = contactName
|
||||
contact = contactName,
|
||||
state = "incoming"
|
||||
)
|
||||
nodeService?.sendEvent(event)
|
||||
DebugClient.log("Call event sent to gateway", mapOf("callId" to callId))
|
||||
} catch (e: Exception) {
|
||||
DebugClient.error("Failed to send call event", e)
|
||||
|
|
@ -105,9 +107,26 @@ class CallScreener : CallScreeningService() {
|
|||
}
|
||||
|
||||
private fun lookupContact(number: String): String? {
|
||||
// TODO: Look up in contacts
|
||||
// For now, return null (unknown caller)
|
||||
return null
|
||||
return try {
|
||||
val uri = Uri.withAppendedPath(
|
||||
ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
|
||||
Uri.encode(number)
|
||||
)
|
||||
val projection = arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME)
|
||||
|
||||
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getString(
|
||||
cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Contact lookup failed for $number: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import android.speech.SpeechRecognizer
|
|||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.telecom.Call
|
||||
import com.inou.clawdnode.gateway.DirectGateway
|
||||
import android.telecom.InCallService
|
||||
import android.telecom.VideoProfile
|
||||
import android.util.Log
|
||||
|
|
@ -39,6 +40,7 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
private var audioManager: AudioManager? = null
|
||||
|
||||
private val activeCalls = mutableMapOf<String, Call>()
|
||||
private val callStartTimes = mutableMapOf<String, Long>()
|
||||
private var currentCallId: String? = null
|
||||
private var isListening = false
|
||||
|
||||
|
|
@ -134,25 +136,32 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
|
||||
when (state) {
|
||||
Call.STATE_ACTIVE -> {
|
||||
// Call is active, start listening
|
||||
// Call is active, record start time and start listening
|
||||
callStartTimes[callId] = System.currentTimeMillis()
|
||||
currentCallId = callId
|
||||
startListening()
|
||||
}
|
||||
Call.STATE_DISCONNECTED -> {
|
||||
// Call ended
|
||||
val duration = 0 // TODO: Calculate actual duration
|
||||
val event = CallEndedEvent(
|
||||
callId = callId,
|
||||
durationSeconds = duration,
|
||||
outcome = "completed"
|
||||
)
|
||||
nodeService?.sendEvent(event)
|
||||
// Call ended - calculate duration
|
||||
val startTime = callStartTimes.remove(callId)
|
||||
val duration = if (startTime != null) {
|
||||
((System.currentTimeMillis() - startTime) / 1000).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
DirectGateway.sendLog("call.ended", mapOf(
|
||||
"callId" to callId,
|
||||
"number" to (call.details.handle?.schemeSpecificPart ?: "unknown"),
|
||||
"duration" to duration,
|
||||
"outcome" to "completed"
|
||||
))
|
||||
|
||||
ClawdNodeApp.instance.auditLog.logCall(
|
||||
"CALL_ENDED",
|
||||
call.details.handle?.schemeSpecificPart,
|
||||
null,
|
||||
"completed"
|
||||
"completed (${duration}s)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -308,12 +317,11 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
Log.i(tag, "STT result: $transcript")
|
||||
|
||||
currentCallId?.let { callId ->
|
||||
val event = CallAudioEvent(
|
||||
callId = callId,
|
||||
transcript = transcript,
|
||||
isFinal = true
|
||||
)
|
||||
nodeService?.sendEvent(event)
|
||||
DirectGateway.sendLog("call.audio", mapOf(
|
||||
"callId" to callId,
|
||||
"transcript" to transcript,
|
||||
"isFinal" to true
|
||||
))
|
||||
}
|
||||
|
||||
// Continue listening
|
||||
|
|
@ -329,12 +337,11 @@ class VoiceCallService : InCallService(), TextToSpeech.OnInitListener {
|
|||
Log.d(tag, "STT partial: $transcript")
|
||||
|
||||
currentCallId?.let { callId ->
|
||||
val event = CallAudioEvent(
|
||||
callId = callId,
|
||||
transcript = transcript,
|
||||
isFinal = false
|
||||
)
|
||||
nodeService?.sendEvent(event)
|
||||
DirectGateway.sendLog("call.audio.partial", mapOf(
|
||||
"callId" to callId,
|
||||
"transcript" to transcript,
|
||||
"isFinal" to false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,37 @@ package com.inou.clawdnode.gateway
|
|||
import android.util.Log
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.debug.DebugClient
|
||||
import com.inou.clawdnode.screenshot.ScreenshotManager
|
||||
import com.inou.clawdnode.sms.SmsProvider
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Direct WebSocket connection to our own ClawdNode Gateway.
|
||||
* No authentication, no restrictions - full bidirectional control.
|
||||
* Direct WebSocket connection to ClawdNode Gateway.
|
||||
* Uses ClawdNode protocol (not Clawdbot protocol).
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Server sends: {"type":"welcome","protocol":"clawdnode/1.0",...}
|
||||
* 2. Client sends: {"type":"hello","client":"clawdnode-android","version":"..."}
|
||||
* 3. Server sends: {"type":"ready","message":"Connected"}
|
||||
* 4. Bidirectional events/commands
|
||||
*/
|
||||
object DirectGateway {
|
||||
private const val TAG = "DirectGateway"
|
||||
private const val PROTOCOL_VERSION = "clawdnode/1.0"
|
||||
|
||||
// Our gateway - Tailscale IP of james server
|
||||
private const val GATEWAY_URL = "ws://100.123.216.65:9878"
|
||||
// Default gateway URL (can be overridden via TokenStore)
|
||||
private const val DEFAULT_GATEWAY_URL = "ws://100.123.216.65:9878"
|
||||
|
||||
// Get URL from TokenStore or use default
|
||||
private val gatewayUrl: String
|
||||
get() = ClawdNodeApp.instance.tokenStore.gatewayUrl?.let { url ->
|
||||
// Ensure it's a WebSocket URL
|
||||
url.replace("http://", "ws://").replace("https://", "wss://").trimEnd('/')
|
||||
} ?: DEFAULT_GATEWAY_URL
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
|
|
@ -34,31 +51,34 @@ object DirectGateway {
|
|||
var onCallReject: ((callId: String) -> Unit)? = null
|
||||
var onCallHangup: ((callId: String) -> Unit)? = null
|
||||
|
||||
// Connection state callback
|
||||
var onConnectionChange: ((Boolean) -> Unit)? = null
|
||||
var onLog: ((String) -> Unit)? = null
|
||||
|
||||
private fun log(message: String) {
|
||||
Log.d(TAG, message)
|
||||
onLog?.invoke(message)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
if (webSocket != null) {
|
||||
Log.d(TAG, "Already connected or connecting")
|
||||
log("Already connected or connecting")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "Connecting to $GATEWAY_URL")
|
||||
DebugClient.lifecycle("DIRECT_GATEWAY", "Connecting to $GATEWAY_URL")
|
||||
val url = gatewayUrl
|
||||
log("Connecting to $url")
|
||||
DebugClient.lifecycle("DIRECT_GATEWAY", "Connecting to $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(GATEWAY_URL)
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(ws: WebSocket, response: Response) {
|
||||
Log.i(TAG, "Connected to gateway")
|
||||
isConnected = true
|
||||
DebugClient.lifecycle("DIRECT_GATEWAY", "Connected")
|
||||
|
||||
// Send hello
|
||||
send(mapOf(
|
||||
"type" to "hello",
|
||||
"client" to "clawdnode-android",
|
||||
"version" to "0.1.0"
|
||||
))
|
||||
log("WebSocket connected, waiting for welcome...")
|
||||
DebugClient.lifecycle("DIRECT_GATEWAY", "Connected, waiting for welcome")
|
||||
// Don't set isConnected yet - wait for protocol handshake
|
||||
}
|
||||
|
||||
override fun onMessage(ws: WebSocket, text: String) {
|
||||
|
|
@ -72,17 +92,19 @@ object DirectGateway {
|
|||
}
|
||||
|
||||
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
|
||||
Log.i(TAG, "Connection closed: $code $reason")
|
||||
log("Connection closed: $code $reason")
|
||||
isConnected = false
|
||||
webSocket = null
|
||||
onConnectionChange?.invoke(false)
|
||||
DebugClient.lifecycle("DIRECT_GATEWAY", "Disconnected: $code $reason")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "Connection failed", t)
|
||||
log("Connection failed: ${t.message}")
|
||||
isConnected = false
|
||||
webSocket = null
|
||||
onConnectionChange?.invoke(false)
|
||||
DebugClient.error("DirectGateway connection failed", t)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
|
@ -110,11 +132,45 @@ object DirectGateway {
|
|||
val type = json.optString("type", "")
|
||||
|
||||
when (type) {
|
||||
"hello" -> {
|
||||
Log.i(TAG, "Received hello from server")
|
||||
DebugClient.log("DirectGateway hello received", mapOf(
|
||||
"clientId" to json.optString("clientId")
|
||||
"welcome" -> {
|
||||
// Server welcome - send our hello
|
||||
val protocol = json.optString("protocol", "unknown")
|
||||
log("Received welcome (protocol: $protocol)")
|
||||
DebugClient.log("DirectGateway welcome received", mapOf(
|
||||
"protocol" to protocol
|
||||
))
|
||||
|
||||
// Check protocol compatibility
|
||||
if (!protocol.startsWith("clawdnode/")) {
|
||||
log("WARNING: Unexpected protocol: $protocol")
|
||||
}
|
||||
|
||||
// Send hello
|
||||
webSocket?.send(JSONObject(mapOf(
|
||||
"type" to "hello",
|
||||
"client" to "clawdnode-android",
|
||||
"version" to "0.1.0"
|
||||
)).toString())
|
||||
}
|
||||
|
||||
"ready" -> {
|
||||
// Server confirmed connection
|
||||
log("Connection ready: ${json.optString("message")}")
|
||||
isConnected = true
|
||||
onConnectionChange?.invoke(true)
|
||||
DebugClient.lifecycle("DIRECT_GATEWAY", "Ready - fully connected")
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
// Server error
|
||||
val code = json.optString("code")
|
||||
val message = json.optString("message")
|
||||
log("Server error [$code]: $message")
|
||||
DebugClient.error("Gateway error: $code - $message", null)
|
||||
|
||||
if (code == "WRONG_PROTOCOL") {
|
||||
log("ERROR: Connected to wrong server! Check gateway URL and port.")
|
||||
}
|
||||
}
|
||||
|
||||
"command" -> {
|
||||
|
|
@ -179,6 +235,106 @@ 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.delete" -> {
|
||||
val id = params.optLong("id", -1)
|
||||
if (id == -1L) {
|
||||
sendResponse(commandId, false, "Missing 'id'")
|
||||
} else {
|
||||
val deleted = SmsProvider.deleteMessage(id)
|
||||
sendDataResponse(commandId, JSONObject().put("deleted", deleted).put("id", id))
|
||||
}
|
||||
ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "sms.delete id=$id")
|
||||
}
|
||||
|
||||
"sms.delete_thread" -> {
|
||||
val threadId = params.optLong("threadId", -1)
|
||||
if (threadId == -1L) {
|
||||
sendResponse(commandId, false, "Missing 'threadId'")
|
||||
} else {
|
||||
val rows = SmsProvider.deleteThread(threadId)
|
||||
sendDataResponse(commandId, JSONObject().put("deleted", rows).put("threadId", threadId))
|
||||
}
|
||||
ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "sms.delete_thread threadId=$threadId")
|
||||
}
|
||||
|
||||
"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")
|
||||
}
|
||||
|
||||
"screenshot" -> {
|
||||
Log.i(TAG, "Taking screenshot")
|
||||
if (!ScreenshotManager.hasPermission()) {
|
||||
sendResponse(commandId, false, "MediaProjection permission not granted. Open app to enable.")
|
||||
return
|
||||
}
|
||||
|
||||
ScreenshotManager.capture { result ->
|
||||
result.fold(
|
||||
onSuccess = { screenshot ->
|
||||
sendDataResponse(commandId, JSONObject().apply {
|
||||
put("width", screenshot.width)
|
||||
put("height", screenshot.height)
|
||||
put("base64", screenshot.base64)
|
||||
})
|
||||
},
|
||||
onFailure = { error ->
|
||||
sendResponse(commandId, false, error.message ?: "Screenshot failed")
|
||||
}
|
||||
)
|
||||
}
|
||||
ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "screenshot")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown command: $command")
|
||||
sendResponse(commandId, false, "Unknown command: $command")
|
||||
|
|
@ -199,6 +355,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
|
||||
|
|
@ -240,6 +410,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()) {
|
||||
send(mapOf("type" to "log", "message" to message) + data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,16 +171,14 @@ class NotificationListener : NotificationListenerService() {
|
|||
|
||||
// Send via WebSocket if connected
|
||||
try {
|
||||
val event = NotificationEvent(
|
||||
DirectGateway.sendNotification(
|
||||
id = notificationId,
|
||||
app = appName,
|
||||
packageName = sbn.packageName,
|
||||
title = title,
|
||||
text = text,
|
||||
actions = actions,
|
||||
timestamp = sbn.postTime
|
||||
actions = actions
|
||||
)
|
||||
nodeService?.sendEvent(event)
|
||||
} catch (e: Exception) {
|
||||
DebugClient.error("WebSocket send failed", e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,267 @@
|
|||
package com.inou.clawdnode.screenshot
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.Image
|
||||
import android.media.ImageReader
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Base64
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* Singleton manager for screenshot capture via MediaProjection.
|
||||
*
|
||||
* MediaProjection requires user consent, so the flow is:
|
||||
* 1. App requests permission via startActivityForResult (in MainActivity)
|
||||
* 2. User grants permission (one-time, survives until app restart)
|
||||
* 3. Permission intent stored here via setProjectionIntent()
|
||||
* 4. When screenshot requested, we create projection and capture
|
||||
*
|
||||
* Note: MediaProjection can be reused until revoked or app dies.
|
||||
*/
|
||||
object ScreenshotManager {
|
||||
private const val TAG = "ScreenshotManager"
|
||||
|
||||
private var projectionManager: MediaProjectionManager? = null
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private var projectionIntent: Intent? = null
|
||||
private var projectionResultCode: Int = 0
|
||||
|
||||
private val isCapturing = AtomicBoolean(false)
|
||||
private var imageReader: ImageReader? = null
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
|
||||
private val handlerThread = HandlerThread("ScreenshotHandler").apply { start() }
|
||||
private val handler = Handler(handlerThread.looper)
|
||||
|
||||
// Callbacks
|
||||
private var pendingCallback: ((Result<ScreenshotResult>) -> Unit)? = null
|
||||
|
||||
data class ScreenshotResult(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val base64: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Initialize the manager. Call from Application.onCreate().
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
projectionManager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
||||
as MediaProjectionManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the intent to request MediaProjection permission.
|
||||
* Start this intent with startActivityForResult.
|
||||
*/
|
||||
fun getProjectionIntent(): Intent? {
|
||||
return projectionManager?.createScreenCaptureIntent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the granted projection permission.
|
||||
* Call this from onActivityResult when user grants permission.
|
||||
*/
|
||||
fun setProjectionIntent(resultCode: Int, data: Intent?) {
|
||||
if (resultCode == android.app.Activity.RESULT_OK && data != null) {
|
||||
projectionResultCode = resultCode
|
||||
projectionIntent = data
|
||||
Log.i(TAG, "MediaProjection permission granted and stored")
|
||||
ClawdNodeApp.instance.auditLog.log("SCREENSHOT_PERMISSION", "Granted")
|
||||
} else {
|
||||
Log.w(TAG, "MediaProjection permission denied")
|
||||
ClawdNodeApp.instance.auditLog.log("SCREENSHOT_PERMISSION", "Denied")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have permission to capture.
|
||||
*/
|
||||
fun hasPermission(): Boolean = projectionIntent != null
|
||||
|
||||
/**
|
||||
* Capture a screenshot.
|
||||
* Callback receives Result with base64-encoded PNG or error.
|
||||
*/
|
||||
fun capture(callback: (Result<ScreenshotResult>) -> Unit) {
|
||||
if (!hasPermission()) {
|
||||
callback(Result.failure(IllegalStateException("MediaProjection permission not granted")))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCapturing.compareAndSet(false, true)) {
|
||||
callback(Result.failure(IllegalStateException("Screenshot already in progress")))
|
||||
return
|
||||
}
|
||||
|
||||
pendingCallback = callback
|
||||
|
||||
try {
|
||||
// Get display metrics
|
||||
val context = ClawdNodeApp.instance.applicationContext
|
||||
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
val metrics = DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(metrics)
|
||||
|
||||
val width = metrics.widthPixels
|
||||
val height = metrics.heightPixels
|
||||
val density = metrics.densityDpi
|
||||
|
||||
Log.d(TAG, "Capturing screenshot: ${width}x${height} @ $density dpi")
|
||||
|
||||
// Create projection (if needed)
|
||||
if (mediaProjection == null) {
|
||||
mediaProjection = projectionManager?.getMediaProjection(
|
||||
projectionResultCode,
|
||||
projectionIntent!!.clone() as Intent
|
||||
)
|
||||
|
||||
mediaProjection?.registerCallback(object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.i(TAG, "MediaProjection stopped")
|
||||
cleanup()
|
||||
}
|
||||
}, handler)
|
||||
}
|
||||
|
||||
// Create ImageReader
|
||||
imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||
|
||||
// Create VirtualDisplay
|
||||
virtualDisplay = mediaProjection?.createVirtualDisplay(
|
||||
"ClawdNodeScreenshot",
|
||||
width, height, density,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
imageReader?.surface,
|
||||
null, handler
|
||||
)
|
||||
|
||||
// Set up image listener with delay to let display render
|
||||
handler.postDelayed({
|
||||
captureFrame()
|
||||
}, 100) // Small delay to ensure frame is rendered
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Screenshot capture failed", e)
|
||||
isCapturing.set(false)
|
||||
callback(Result.failure(e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureFrame() {
|
||||
try {
|
||||
val image = imageReader?.acquireLatestImage()
|
||||
|
||||
if (image == null) {
|
||||
// No frame yet, retry briefly
|
||||
handler.postDelayed({ captureFrame() }, 50)
|
||||
return
|
||||
}
|
||||
|
||||
val result = processImage(image)
|
||||
image.close()
|
||||
|
||||
cleanupCapture()
|
||||
isCapturing.set(false)
|
||||
|
||||
pendingCallback?.invoke(Result.success(result))
|
||||
pendingCallback = null
|
||||
|
||||
Log.i(TAG, "Screenshot captured: ${result.width}x${result.height}")
|
||||
ClawdNodeApp.instance.auditLog.log("SCREENSHOT_CAPTURED",
|
||||
"${result.width}x${result.height}, ${result.base64.length} bytes")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Frame capture failed", e)
|
||||
cleanupCapture()
|
||||
isCapturing.set(false)
|
||||
pendingCallback?.invoke(Result.failure(e))
|
||||
pendingCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun processImage(image: Image): ScreenshotResult {
|
||||
val planes = image.planes
|
||||
val buffer = planes[0].buffer
|
||||
val pixelStride = planes[0].pixelStride
|
||||
val rowStride = planes[0].rowStride
|
||||
val rowPadding = rowStride - pixelStride * image.width
|
||||
|
||||
// Create bitmap with padding
|
||||
val bitmapWidth = image.width + rowPadding / pixelStride
|
||||
val bitmap = Bitmap.createBitmap(bitmapWidth, image.height, Bitmap.Config.ARGB_8888)
|
||||
bitmap.copyPixelsFromBuffer(buffer)
|
||||
|
||||
// Crop to actual size if needed
|
||||
val croppedBitmap = if (bitmapWidth != image.width) {
|
||||
Bitmap.createBitmap(bitmap, 0, 0, image.width, image.height)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
|
||||
// Scale down if too large (max 1920px on longest edge for bandwidth)
|
||||
val maxDimension = 1920
|
||||
val scaledBitmap = if (croppedBitmap.width > maxDimension || croppedBitmap.height > maxDimension) {
|
||||
val scale = maxDimension.toFloat() / maxOf(croppedBitmap.width, croppedBitmap.height)
|
||||
val newWidth = (croppedBitmap.width * scale).toInt()
|
||||
val newHeight = (croppedBitmap.height * scale).toInt()
|
||||
Bitmap.createScaledBitmap(croppedBitmap, newWidth, newHeight, true)
|
||||
} else {
|
||||
croppedBitmap
|
||||
}
|
||||
|
||||
// Convert to PNG base64
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
scaledBitmap.compress(Bitmap.CompressFormat.PNG, 90, outputStream)
|
||||
val base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
|
||||
|
||||
// Clean up bitmaps
|
||||
if (bitmap != croppedBitmap) bitmap.recycle()
|
||||
if (croppedBitmap != scaledBitmap) croppedBitmap.recycle()
|
||||
scaledBitmap.recycle()
|
||||
|
||||
return ScreenshotResult(
|
||||
width = scaledBitmap.width,
|
||||
height = scaledBitmap.height,
|
||||
base64 = base64
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanupCapture() {
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
cleanupCapture()
|
||||
mediaProjection = null
|
||||
// Don't clear intent - user consent remains valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Release all resources. Call when app is shutting down.
|
||||
*/
|
||||
fun release() {
|
||||
cleanup()
|
||||
mediaProjection?.stop()
|
||||
projectionIntent = null
|
||||
handlerThread.quitSafely()
|
||||
}
|
||||
}
|
||||
|
|
@ -50,8 +50,8 @@ class TokenStore(context: Context) {
|
|||
private const val KEY_GATEWAY_TOKEN = "gateway_token"
|
||||
private const val KEY_NODE_ID = "node_id"
|
||||
|
||||
// Default values for testing
|
||||
private const val DEFAULT_GATEWAY_URL = "ws://100.123.216.65:18789"
|
||||
private const val DEFAULT_GATEWAY_TOKEN = "2dee57cc3ce2947c27ce9e848d5c3e95cc452f25a1477462"
|
||||
// Default values - ClawdNode custom gateway
|
||||
private const val DEFAULT_GATEWAY_URL = "ws://192.168.1.16:9878"
|
||||
private const val DEFAULT_GATEWAY_TOKEN = ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ class GatewayClient(
|
|||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val requestIdCounter = AtomicInteger(0)
|
||||
|
||||
// Queue for events when disconnected (max 100 to prevent memory issues)
|
||||
private val eventQueue = mutableListOf<NodeEvent>()
|
||||
private val maxQueueSize = 100
|
||||
|
||||
private val auditLog get() = ClawdNodeApp.instance.auditLog
|
||||
private val tokenStore get() = ClawdNodeApp.instance.tokenStore
|
||||
|
||||
|
|
@ -146,8 +150,33 @@ class GatewayClient(
|
|||
if (isConnected && isHandshakeComplete) {
|
||||
webSocket?.send(json)
|
||||
} else {
|
||||
log("Not connected or handshake incomplete, cannot send event")
|
||||
// TODO: Queue for retry
|
||||
// Queue for retry when reconnected
|
||||
synchronized(eventQueue) {
|
||||
if (eventQueue.size >= maxQueueSize) {
|
||||
// Drop oldest event to make room
|
||||
val dropped = eventQueue.removeAt(0)
|
||||
log("Queue full, dropped oldest event: ${dropped.type}")
|
||||
}
|
||||
eventQueue.add(event)
|
||||
log("Queued event for retry (queue size: ${eventQueue.size})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushEventQueue() {
|
||||
val eventsToSend: List<NodeEvent>
|
||||
synchronized(eventQueue) {
|
||||
eventsToSend = eventQueue.toList()
|
||||
eventQueue.clear()
|
||||
}
|
||||
|
||||
if (eventsToSend.isNotEmpty()) {
|
||||
log("Flushing ${eventsToSend.size} queued events")
|
||||
eventsToSend.forEach { event ->
|
||||
val requestId = generateRequestId()
|
||||
val json = event.toProtocolFrame(requestId)
|
||||
webSocket?.send(json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -365,6 +394,9 @@ class GatewayClient(
|
|||
isHandshakeComplete = true
|
||||
onConnectionChange(true)
|
||||
auditLog.log("GATEWAY_CONNECTED", "Protocol v$protocol handshake complete")
|
||||
|
||||
// Send any queued events
|
||||
flushEventQueue()
|
||||
}
|
||||
|
||||
private fun handleDisconnect() {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import android.util.Log
|
|||
import androidx.core.app.NotificationCompat
|
||||
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
|
||||
|
||||
/**
|
||||
|
|
@ -22,9 +24,8 @@ class NodeService : Service() {
|
|||
private val tag = "NodeService"
|
||||
private val binder = LocalBinder()
|
||||
|
||||
private lateinit var gatewayClient: GatewayClient
|
||||
|
||||
private var isConnected = false
|
||||
private val smsMonitor = SmsMonitor()
|
||||
|
||||
// Callbacks for UI updates
|
||||
var onConnectionChange: ((Boolean) -> Unit)? = null
|
||||
|
|
@ -40,15 +41,32 @@ class NodeService : Service() {
|
|||
super.onCreate()
|
||||
Log.i(tag, "NodeService created")
|
||||
|
||||
gatewayClient = GatewayClient(
|
||||
onCommand = { command -> handleCommand(command) },
|
||||
onConnectionChange = { connected ->
|
||||
isConnected = connected
|
||||
updateNotification()
|
||||
onConnectionChange?.invoke(connected)
|
||||
},
|
||||
onLog = { message -> onLogMessage?.invoke(message) }
|
||||
)
|
||||
// Use DirectGateway (ClawdNode protocol) instead of GatewayClient (Clawdbot protocol)
|
||||
DirectGateway.onConnectionChange = { connected ->
|
||||
isConnected = connected
|
||||
updateNotification()
|
||||
onConnectionChange?.invoke(connected)
|
||||
}
|
||||
DirectGateway.onLog = { message ->
|
||||
onLogMessage?.invoke(message)
|
||||
}
|
||||
|
||||
// Wire up command handlers
|
||||
DirectGateway.onNotificationAction = { notificationId, action, replyText ->
|
||||
NotificationManager.triggerAction(notificationId, action, replyText)
|
||||
}
|
||||
DirectGateway.onCallAnswer = { callId ->
|
||||
CallManager.answer(callId, null)
|
||||
}
|
||||
DirectGateway.onCallReject = { callId ->
|
||||
CallManager.reject(callId, null)
|
||||
}
|
||||
DirectGateway.onCallHangup = { callId ->
|
||||
CallManager.hangup(callId)
|
||||
}
|
||||
|
||||
// Start SMS monitor
|
||||
smsMonitor.start()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
|
@ -57,19 +75,16 @@ class NodeService : Service() {
|
|||
// Start as foreground service
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
|
||||
// Connect to gateway
|
||||
if (ClawdNodeApp.instance.tokenStore.isConfigured) {
|
||||
gatewayClient.connect()
|
||||
} else {
|
||||
Log.w(tag, "Gateway not configured, waiting for setup")
|
||||
}
|
||||
// Connect to gateway (DirectGateway uses URL from TokenStore)
|
||||
DirectGateway.connect()
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(tag, "NodeService destroyed")
|
||||
gatewayClient.disconnect()
|
||||
smsMonitor.stop()
|
||||
DirectGateway.disconnect()
|
||||
ClawdNodeApp.instance.auditLog.log("SERVICE_STOP", "NodeService destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
|
@ -79,102 +94,16 @@ class NodeService : Service() {
|
|||
// ========================================
|
||||
|
||||
fun connect() {
|
||||
gatewayClient.connect()
|
||||
DirectGateway.connect()
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
gatewayClient.disconnect()
|
||||
DirectGateway.disconnect()
|
||||
}
|
||||
|
||||
fun isConnected() = isConnected
|
||||
|
||||
fun sendEvent(event: NodeEvent) {
|
||||
gatewayClient.send(event)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// COMMAND HANDLING
|
||||
// ========================================
|
||||
|
||||
private fun handleCommand(command: NodeCommand) {
|
||||
Log.d(tag, "Handling command: ${command::class.simpleName}")
|
||||
|
||||
try {
|
||||
when (command) {
|
||||
is ScreenshotCommand -> handleScreenshot(command)
|
||||
is NotificationActionCommand -> handleNotificationAction(command)
|
||||
is NotificationDismissCommand -> handleNotificationDismiss(command)
|
||||
is CallAnswerCommand -> handleCallAnswer(command)
|
||||
is CallRejectCommand -> handleCallReject(command)
|
||||
is CallSilenceCommand -> handleCallSilence(command)
|
||||
is CallSpeakCommand -> handleCallSpeak(command)
|
||||
is CallHangupCommand -> handleCallHangup(command)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Error handling command", e)
|
||||
command.requestId?.let {
|
||||
gatewayClient.sendResponse(it, false, error = e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleScreenshot(cmd: ScreenshotCommand) {
|
||||
// TODO: Implement screenshot capture via MediaProjection
|
||||
Log.d(tag, "Screenshot requested - not yet implemented")
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, false, error = "Screenshot not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNotificationAction(cmd: NotificationActionCommand) {
|
||||
// Delegate to NotificationListener
|
||||
NotificationManager.triggerAction(cmd.notificationId, cmd.action, cmd.text)
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, true, payload = mapOf("triggered" to true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNotificationDismiss(cmd: NotificationDismissCommand) {
|
||||
NotificationManager.dismiss(cmd.notificationId)
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, true, payload = mapOf("dismissed" to true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallAnswer(cmd: CallAnswerCommand) {
|
||||
CallManager.answer(cmd.callId, cmd.greeting)
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, true, payload = mapOf("answered" to true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallReject(cmd: CallRejectCommand) {
|
||||
CallManager.reject(cmd.callId, cmd.reason)
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, true, payload = mapOf("rejected" to true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallSilence(cmd: CallSilenceCommand) {
|
||||
CallManager.silence(cmd.callId)
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, true, payload = mapOf("silenced" to true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallSpeak(cmd: CallSpeakCommand) {
|
||||
CallManager.speak(cmd.callId, cmd.text, cmd.voice)
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, true, payload = mapOf("speaking" to true))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallHangup(cmd: CallHangupCommand) {
|
||||
CallManager.hangup(cmd.callId)
|
||||
cmd.requestId?.let {
|
||||
gatewayClient.sendResponse(it, true, payload = mapOf("hungup" to true))
|
||||
}
|
||||
}
|
||||
// Note: Command handling is done via DirectGateway callbacks set in onCreate()
|
||||
|
||||
// ========================================
|
||||
// NOTIFICATION
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
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<String, String?>()
|
||||
|
||||
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<SmsMessage> {
|
||||
val selection = mutableListOf<String>()
|
||||
val args = mutableListOf<String>()
|
||||
|
||||
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<SmsMessage>()
|
||||
|
||||
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<SmsMessage> {
|
||||
val messages = mutableListOf<SmsMessage>()
|
||||
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<SmsThread> {
|
||||
val threads = mutableListOf<SmsThread>()
|
||||
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<Long>()
|
||||
val threadCounts = mutableMapOf<Long, Int>()
|
||||
|
||||
// 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 deleteMessage(id: Long): Boolean {
|
||||
return try {
|
||||
val rows = contentResolver.delete(Uri.parse("content://sms/$id"), null, null)
|
||||
Log.i(TAG, "deleteMessage id=$id rows=$rows")
|
||||
DebugClient.log("SMS deleted", mapOf("id" to id, "rows" to rows))
|
||||
rows > 0
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "deleteMessage failed for id=$id", e)
|
||||
DebugClient.error("SmsProvider.deleteMessage failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteThread(threadId: Long): Int {
|
||||
return try {
|
||||
val rows = contentResolver.delete(
|
||||
Uri.parse("content://sms/"),
|
||||
"thread_id = ?",
|
||||
arrayOf(threadId.toString())
|
||||
)
|
||||
Log.i(TAG, "deleteThread threadId=$threadId rows=$rows")
|
||||
DebugClient.log("SMS thread deleted", mapOf("threadId" to threadId, "rows" to rows))
|
||||
rows
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "deleteThread failed for threadId=$threadId", e)
|
||||
DebugClient.error("SmsProvider.deleteThread failed", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.core.content.ContextCompat
|
||||
import com.inou.clawdnode.ClawdNodeApp
|
||||
import com.inou.clawdnode.databinding.ActivityMainBinding
|
||||
import com.inou.clawdnode.screenshot.ScreenshotManager
|
||||
import com.inou.clawdnode.service.NodeService
|
||||
|
||||
/**
|
||||
|
|
@ -67,6 +68,13 @@ class MainActivity : AppCompatActivity() {
|
|||
updatePermissionStatus()
|
||||
}
|
||||
|
||||
private val mediaProjectionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
ScreenshotManager.setProjectionIntent(result.resultCode, result.data)
|
||||
updatePermissionStatus()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
|
|
@ -112,6 +120,10 @@ class MainActivity : AppCompatActivity() {
|
|||
requestRuntimePermissions()
|
||||
}
|
||||
|
||||
binding.btnGrantScreenshot.setOnClickListener {
|
||||
requestScreenshotPermission()
|
||||
}
|
||||
|
||||
// Connection control
|
||||
binding.btnConnect.setOnClickListener {
|
||||
nodeService?.connect()
|
||||
|
|
@ -198,6 +210,10 @@ class MainActivity : AppCompatActivity() {
|
|||
// Runtime permissions
|
||||
val permissionsGranted = areRuntimePermissionsGranted()
|
||||
binding.tvPermissionsStatus.text = if (permissionsGranted) "✓ All granted" else "✗ Some missing"
|
||||
|
||||
// Screenshot/Screen capture
|
||||
val screenshotEnabled = isScreenshotPermissionGranted()
|
||||
binding.tvScreenshotStatus.text = if (screenshotEnabled) "✓ Granted" else "✗ Not granted"
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -244,6 +260,19 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun requestScreenshotPermission() {
|
||||
val intent = ScreenshotManager.getProjectionIntent()
|
||||
if (intent != null) {
|
||||
mediaProjectionLauncher.launch(intent)
|
||||
} else {
|
||||
Toast.makeText(this, "Screenshot not available", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenshotPermissionGranted(): Boolean {
|
||||
return ScreenshotManager.hasPermission()
|
||||
}
|
||||
|
||||
private fun showAuditLog() {
|
||||
val entries = ClawdNodeApp.instance.auditLog.getRecentEntries(50)
|
||||
val text = entries.joinToString("\n\n") { entry ->
|
||||
|
|
@ -275,7 +304,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@
|
|||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
|
|
@ -241,6 +241,47 @@
|
|||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Screenshot Permission -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Screen Capture"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#1C1917" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvScreenshotStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="✗ Not granted"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#78716C" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnGrantScreenshot"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Grant"
|
||||
android:backgroundTint="#E5E2DE"
|
||||
android:textColor="#1C1917" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Live Log -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
|||
Loading…
Reference in New Issue