Compare commits

...

10 Commits

Author SHA1 Message Date
James (ClawdBot) 07e943dfaf docs: mark screenshot feature as complete 2026-03-03 11:01:17 +00:00
James (ClawdBot) 415703665d feat: implement MediaProjection screenshot capture
- Add ScreenshotManager singleton for coordinating screenshot capture
- Implement MediaProjection-based screen capture with user consent flow
- Add screenshot command handler to DirectGateway
- Add UI for screenshot permission in MainActivity
- Auto-scale images to max 1920px to save bandwidth
- Return base64-encoded PNG via command response

Remaining TODO: None - screenshot feature complete pending testing
2026-03-02 11:03:40 +00:00
James (ClawdBot) 585f921601 docs: update STATUS.md - mark TODOs as fixed 2026-03-02 01:04:47 +00:00
James (ClawdBot) 0cd111343f fix: implement TODOs for call duration, contact lookup, and event queue
- VoiceCallService: Track call start time and calculate actual duration on disconnect
- CallScreener: Implement contact lookup via ContactsContract.PhoneLookup
- GatewayClient: Add event queue (max 100) for offline buffering, flush on reconnect
- ClawdNodeApp: Route call handlers through CallManager instead of TODOs
2026-03-02 01:04:30 +00:00
James (ClawdBot) b7e213ff32 feat: add sms.delete and sms.delete_thread commands 2026-02-08 02:31:11 -05:00
James (ClawdBot) 1121728cd2 chore: auto-commit uncommitted changes 2026-02-08 02:30:07 -05:00
James (ClawdBot) 0a4ef6a47e chore: auto-commit uncommitted changes 2026-02-08 01:30:06 -05:00
James (ClawdBot) 780fb630e2 feat: add SMS read/monitor/send support 2026-02-08 00:30:55 -05:00
James (ClawdBot) 849ded12b1 chore: auto-commit uncommitted changes 2026-02-08 00:30:06 -05:00
James (ClawdBot) ed7e23003c Gateway and service improvements
- DirectGateway updates
- TokenStore improvements
- NodeService refinements
2026-02-04 22:58:46 -05:00
15 changed files with 1054 additions and 182 deletions

View File

@ -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

View File

@ -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" />

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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
))
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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 = ""
}
}

View File

@ -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() {

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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
}
}
}

View File

@ -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
)
}
}

View File

@ -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"