feat: Add direct HTTP debug logging

- Add DebugClient that POSTs directly to debug server (100.123.216.65:9876)
- NotificationListener: POST all events directly, full lifecycle logging
- CallScreener: POST all calls directly, full lifecycle logging
- App: Log startup and initialization
- Bypass WebSocket complexity for debugging visibility

Debug server: node /home/johan/dev/clawdnode-debug-server/server.js
Tail: tail -f /tmp/clawdnode-debug.log
This commit is contained in:
James (ClawdBot) 2026-01-28 21:15:11 +00:00
parent 5eb13b01b5
commit e0835e0626
4 changed files with 299 additions and 65 deletions

View File

@ -4,6 +4,7 @@ import android.app.Application
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.os.Build import android.os.Build
import com.inou.clawdnode.debug.DebugClient
import com.inou.clawdnode.security.AuditLog import com.inou.clawdnode.security.AuditLog
import com.inou.clawdnode.security.DeviceIdentity import com.inou.clawdnode.security.DeviceIdentity
import com.inou.clawdnode.security.TokenStore import com.inou.clawdnode.security.TokenStore
@ -34,6 +35,13 @@ class ClawdNodeApp : Application() {
createNotificationChannels() createNotificationChannels()
auditLog.log("APP_START", "ClawdNode v0.1.0 started") auditLog.log("APP_START", "ClawdNode v0.1.0 started")
// Initialize debug client and log startup
DebugClient.lifecycle("APP_CREATE", "ClawdNode v0.1.0 started")
DebugClient.log("App initialized", mapOf(
"gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"),
"hasToken" to (tokenStore.gatewayToken != null)
))
} }
private fun createNotificationChannels() { private fun createNotificationChannels() {

View File

@ -9,6 +9,7 @@ import android.telecom.Call
import android.telecom.CallScreeningService import android.telecom.CallScreeningService
import android.util.Log import android.util.Log
import com.inou.clawdnode.ClawdNodeApp import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.debug.DebugClient
import com.inou.clawdnode.protocol.CallIncomingEvent import com.inou.clawdnode.protocol.CallIncomingEvent
import com.inou.clawdnode.service.NodeService import com.inou.clawdnode.service.NodeService
@ -33,9 +34,11 @@ class CallScreener : CallScreeningService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(tag, "CallScreener created") Log.i(tag, "CallScreener created")
DebugClient.lifecycle("CALL_SCREENER_CREATE", "Service started")
Intent(this, NodeService::class.java).also { intent -> Intent(this, NodeService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) val bound = bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
DebugClient.lifecycle("CALL_SCREENER", "bindService returned: $bound")
} }
} }
@ -50,19 +53,32 @@ class CallScreener : CallScreeningService() {
Log.i(tag, "Screening call from: $number") Log.i(tag, "Screening call from: $number")
// POST to debug server immediately
DebugClient.call(
callId = callId,
number = number,
contact = null,
state = "incoming"
)
// Look up contact name // Look up contact name
val contactName = lookupContact(number) val contactName = lookupContact(number)
// Store call details for later action // Store call details for later action
ActiveCalls.add(callId, callDetails) ActiveCalls.add(callId, callDetails)
// Send event to Gateway // Send event to Gateway via WebSocket
try {
val event = CallIncomingEvent( val event = CallIncomingEvent(
callId = callId, callId = callId,
number = number, number = number,
contact = contactName contact = contactName
) )
nodeService?.sendEvent(event) 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)
}
ClawdNodeApp.instance.auditLog.logCall( ClawdNodeApp.instance.auditLog.logCall(
"CALL_INCOMING", "CALL_INCOMING",
@ -80,6 +96,8 @@ class CallScreener : CallScreeningService() {
.setSkipNotification(false) .setSkipNotification(false)
.build() .build()
) )
DebugClient.call(callId, number, contactName, "allowed_to_ring")
} }
private fun lookupContact(number: String): String? { private fun lookupContact(number: String): String? {

View File

@ -0,0 +1,131 @@
package com.inou.clawdnode.debug
import android.util.Log
import kotlinx.coroutines.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
/**
* Direct HTTP client for debugging.
* Posts events directly to the debug server, bypassing WebSocket complexity.
*/
object DebugClient {
private const val TAG = "DebugClient"
// Debug server URL - Tailscale IP of james server
private const val DEBUG_SERVER = "http://100.123.216.65:9876"
private val client = OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.writeTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.build()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val JSON = "application/json".toMediaType()
var enabled = true
fun log(message: String, data: Map<String, Any?> = emptyMap()) {
post("/log", mapOf("message" to message) + data)
}
fun error(message: String, throwable: Throwable? = null, data: Map<String, Any?> = emptyMap()) {
post("/error", mapOf(
"message" to message,
"error" to (throwable?.message ?: ""),
"stack" to (throwable?.stackTraceToString()?.take(500) ?: "")
) + data)
}
fun lifecycle(event: String, message: String = "") {
post("/lifecycle", mapOf("event" to event, "message" to message))
}
fun notification(
id: String,
app: String,
packageName: String,
title: String?,
text: String?,
actions: List<String> = emptyList()
) {
post("/notification", mapOf(
"id" to id,
"app" to app,
"packageName" to packageName,
"title" to title,
"text" to text,
"actions" to actions,
"timestamp" to System.currentTimeMillis()
))
}
fun call(
callId: String,
number: String?,
contact: String?,
state: String
) {
post("/call", mapOf(
"callId" to callId,
"number" to number,
"contact" to contact,
"state" to state,
"timestamp" to System.currentTimeMillis()
))
}
fun event(type: String, data: Map<String, Any?> = emptyMap()) {
post("/event", mapOf("type" to type) + data)
}
private fun post(endpoint: String, data: Map<String, Any?>) {
if (!enabled) return
scope.launch {
try {
val json = JSONObject(data).toString()
val request = Request.Builder()
.url("$DEBUG_SERVER$endpoint")
.post(json.toRequestBody(JSON))
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
Log.w(TAG, "Debug post failed: ${response.code}")
}
}
} catch (e: IOException) {
// Silently fail - debug server might not be running
Log.d(TAG, "Debug post failed: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "Debug post error", e)
}
}
}
fun testConnection(callback: (Boolean) -> Unit) {
scope.launch {
try {
val request = Request.Builder()
.url("$DEBUG_SERVER/health")
.get()
.build()
client.newCall(request).execute().use { response ->
withContext(Dispatchers.Main) {
callback(response.isSuccessful)
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
callback(false)
}
}
}
}
}

View File

@ -12,13 +12,14 @@ import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import android.util.Log import android.util.Log
import com.inou.clawdnode.ClawdNodeApp import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.debug.DebugClient
import com.inou.clawdnode.protocol.NotificationEvent import com.inou.clawdnode.protocol.NotificationEvent
import com.inou.clawdnode.service.NodeService import com.inou.clawdnode.service.NodeService
import com.inou.clawdnode.service.NotificationManager import com.inou.clawdnode.service.NotificationManager
/** /**
* Listens to all notifications and forwards them to Gateway. * Listens to all notifications and forwards them to Gateway.
* Can also trigger notification actions (reply, dismiss, etc.) * Also posts directly to debug server for visibility.
*/ */
class NotificationListener : NotificationListenerService() { class NotificationListener : NotificationListenerService() {
@ -28,74 +29,159 @@ class NotificationListener : NotificationListenerService() {
private val activeNotifications = mutableMapOf<String, StatusBarNotification>() private val activeNotifications = mutableMapOf<String, StatusBarNotification>()
private var nodeService: NodeService? = null private var nodeService: NodeService? = null
private var serviceConnected = false
private val serviceConnection = object : ServiceConnection { private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.i(tag, "NodeService connected")
DebugClient.lifecycle("NOTIFICATION_LISTENER", "NodeService bound successfully")
nodeService = (service as NodeService.LocalBinder).getService() nodeService = (service as NodeService.LocalBinder).getService()
serviceConnected = true
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
Log.i(tag, "NodeService disconnected")
DebugClient.lifecycle("NOTIFICATION_LISTENER", "NodeService disconnected")
nodeService = null nodeService = null
serviceConnected = false
} }
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(tag, "NotificationListener created") Log.i(tag, "NotificationListener created")
DebugClient.lifecycle("NOTIFICATION_LISTENER_CREATE", "Service onCreate called")
try {
NotificationManager.register(this) NotificationManager.register(this)
DebugClient.lifecycle("NOTIFICATION_LISTENER", "Registered with NotificationManager")
} catch (e: Exception) {
Log.e(tag, "Failed to register with NotificationManager", e)
DebugClient.error("NotificationManager registration failed", e)
}
// Bind to NodeService // Bind to NodeService
try {
Intent(this, NodeService::class.java).also { intent -> Intent(this, NodeService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) val bound = bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
Log.i(tag, "bindService returned: $bound")
DebugClient.lifecycle("NOTIFICATION_LISTENER", "bindService returned: $bound")
} }
} catch (e: Exception) {
Log.e(tag, "Failed to bind to NodeService", e)
DebugClient.error("NodeService bind failed", e)
}
}
override fun onListenerConnected() {
super.onListenerConnected()
Log.i(tag, "NotificationListener connected to system")
DebugClient.lifecycle("NOTIFICATION_LISTENER_CONNECTED", "System has connected us to notification stream")
// Log existing notifications
try {
val existing = activeNotifications.size
DebugClient.log("Listener connected, ${existing} active notifications tracked")
} catch (e: Exception) {
DebugClient.error("Error in onListenerConnected", e)
}
}
override fun onListenerDisconnected() {
super.onListenerDisconnected()
Log.w(tag, "NotificationListener disconnected from system")
DebugClient.lifecycle("NOTIFICATION_LISTENER_DISCONNECTED", "System has disconnected us from notification stream")
} }
override fun onDestroy() { override fun onDestroy() {
Log.i(tag, "NotificationListener destroyed") Log.i(tag, "NotificationListener destroyed")
DebugClient.lifecycle("NOTIFICATION_LISTENER_DESTROY", "Service onDestroy called")
try {
unbindService(serviceConnection) unbindService(serviceConnection)
} catch (e: Exception) {
// Ignore
}
super.onDestroy() super.onDestroy()
} }
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
// Log immediately to confirm listener is working // ALWAYS log to debug server first
Log.i(tag, "onNotificationPosted called: ${sbn.packageName}") Log.i(tag, "onNotificationPosted: ${sbn.packageName}")
ClawdNodeApp.instance.auditLog.log( DebugClient.log("onNotificationPosted called", mapOf(
"NOTIFICATION_RAW", "package" to sbn.packageName,
"Received: ${sbn.packageName}", "id" to sbn.id,
mapOf("id" to sbn.id.toString(), "key" to sbn.key) "key" to sbn.key,
) "isOngoing" to sbn.isOngoing
))
// Skip our own notifications // Skip our own notifications
if (sbn.packageName == packageName) { if (sbn.packageName == packageName) {
Log.d(tag, "Skipping own notification") DebugClient.log("Skipping own notification")
return return
} }
// Skip ongoing/persistent notifications (media players, etc.) // Skip ongoing/persistent notifications (media players, etc.)
if (sbn.isOngoing && !isImportantOngoing(sbn)) { if (sbn.isOngoing && !isImportantOngoing(sbn)) {
Log.d(tag, "Skipping ongoing notification") DebugClient.log("Skipping ongoing notification", mapOf("package" to sbn.packageName))
return return
} }
Log.d(tag, "Processing notification: ${sbn.packageName}")
val notificationId = generateNotificationId(sbn) val notificationId = generateNotificationId(sbn)
activeNotifications[notificationId] = sbn activeNotifications[notificationId] = sbn
val event = createNotificationEvent(sbn, notificationId) // Extract notification data
val notification = sbn.notification
val extras = notification.extras
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
val actions = notification.actions?.map { it.title.toString() } ?: emptyList()
val appName = getAppName(sbn.packageName)
// POST directly to debug server
DebugClient.notification(
id = notificationId,
app = appName,
packageName = sbn.packageName,
title = title,
text = text,
actions = actions
)
// Also log to local audit
try {
ClawdNodeApp.instance.auditLog.logNotification( ClawdNodeApp.instance.auditLog.logNotification(
"NOTIFICATION_POSTED", "NOTIFICATION_POSTED",
sbn.packageName, sbn.packageName,
event.title title
) )
} catch (e: Exception) {
DebugClient.error("Audit log failed", e)
}
// Send via WebSocket if connected
try {
val event = NotificationEvent(
id = notificationId,
app = appName,
packageName = sbn.packageName,
title = title,
text = text,
actions = actions,
timestamp = sbn.postTime
)
nodeService?.sendEvent(event) nodeService?.sendEvent(event)
Log.i(tag, "Notification event sent to gateway") } catch (e: Exception) {
DebugClient.error("WebSocket send failed", e)
}
} }
override fun onNotificationRemoved(sbn: StatusBarNotification) { override fun onNotificationRemoved(sbn: StatusBarNotification) {
val notificationId = generateNotificationId(sbn) val notificationId = generateNotificationId(sbn)
activeNotifications.remove(notificationId) activeNotifications.remove(notificationId)
Log.d(tag, "Notification removed: ${sbn.packageName}") DebugClient.log("Notification removed", mapOf(
"package" to sbn.packageName,
"id" to notificationId
))
} }
// ======================================== // ========================================
@ -103,8 +189,15 @@ class NotificationListener : NotificationListenerService() {
// ======================================== // ========================================
fun triggerAction(notificationId: String, actionTitle: String, replyText: String?) { fun triggerAction(notificationId: String, actionTitle: String, replyText: String?) {
DebugClient.log("triggerAction called", mapOf(
"notificationId" to notificationId,
"action" to actionTitle,
"hasReply" to (replyText != null)
))
val sbn = activeNotifications[notificationId] ?: run { val sbn = activeNotifications[notificationId] ?: run {
Log.w(tag, "Notification not found: $notificationId") Log.w(tag, "Notification not found: $notificationId")
DebugClient.error("Notification not found for action", null, mapOf("id" to notificationId))
return return
} }
@ -114,18 +207,23 @@ class NotificationListener : NotificationListenerService() {
val action = actions.find { it.title.toString().equals(actionTitle, ignoreCase = true) } val action = actions.find { it.title.toString().equals(actionTitle, ignoreCase = true) }
if (action == null) { if (action == null) {
Log.w(tag, "Action not found: $actionTitle") Log.w(tag, "Action not found: $actionTitle")
DebugClient.error("Action not found", null, mapOf("action" to actionTitle))
return return
} }
try { try {
if (replyText != null && action.remoteInputs?.isNotEmpty() == true) { if (replyText != null && action.remoteInputs?.isNotEmpty() == true) {
// This is a reply action
sendReply(action, replyText) sendReply(action, replyText)
} else { } else {
// Regular action
action.actionIntent.send() action.actionIntent.send()
} }
DebugClient.event("NOTIFICATION_ACTION_TRIGGERED", mapOf(
"notificationId" to notificationId,
"action" to actionTitle,
"isReply" to (replyText != null)
))
ClawdNodeApp.instance.auditLog.log( ClawdNodeApp.instance.auditLog.log(
"NOTIFICATION_ACTION", "NOTIFICATION_ACTION",
"Triggered action: $actionTitle", "Triggered action: $actionTitle",
@ -133,18 +231,15 @@ class NotificationListener : NotificationListenerService() {
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Failed to trigger action", e) Log.e(tag, "Failed to trigger action", e)
DebugClient.error("Action trigger failed", e)
} }
} }
fun dismissNotification(notificationId: String) { fun dismissNotification(notificationId: String) {
DebugClient.log("dismissNotification called", mapOf("id" to notificationId))
val sbn = activeNotifications[notificationId] ?: return val sbn = activeNotifications[notificationId] ?: return
cancelNotification(sbn.key) cancelNotification(sbn.key)
DebugClient.event("NOTIFICATION_DISMISSED", mapOf("id" to notificationId))
ClawdNodeApp.instance.auditLog.log(
"NOTIFICATION_DISMISS",
"Dismissed notification",
mapOf("notification_id" to notificationId)
)
} }
private fun sendReply(action: Notification.Action, text: String) { private fun sendReply(action: Notification.Action, text: String) {
@ -156,6 +251,7 @@ class NotificationListener : NotificationListenerService() {
RemoteInput.addResultsToIntent(arrayOf(remoteInput), intent, bundle) RemoteInput.addResultsToIntent(arrayOf(remoteInput), intent, bundle)
action.actionIntent.send(this, 0, intent) action.actionIntent.send(this, 0, intent)
DebugClient.event("NOTIFICATION_REPLY_SENT", mapOf("textLength" to text.length))
} }
// ======================================== // ========================================
@ -166,40 +262,21 @@ class NotificationListener : NotificationListenerService() {
return "${sbn.packageName}:${sbn.id}:${sbn.postTime}" return "${sbn.packageName}:${sbn.id}:${sbn.postTime}"
} }
private fun createNotificationEvent(sbn: StatusBarNotification, id: String): NotificationEvent { private fun getAppName(packageName: String): String {
val notification = sbn.notification return try {
val extras = notification.extras val appInfo = packageManager.getApplicationInfo(packageName, 0)
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()
val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()
?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString()
val actions = notification.actions?.map { it.title.toString() } ?: emptyList()
val appName = try {
val appInfo = packageManager.getApplicationInfo(sbn.packageName, 0)
packageManager.getApplicationLabel(appInfo).toString() packageManager.getApplicationLabel(appInfo).toString()
} catch (e: Exception) { } catch (e: Exception) {
sbn.packageName packageName
} }
return NotificationEvent(
id = id,
app = appName,
packageName = sbn.packageName,
title = title,
text = text,
actions = actions,
timestamp = sbn.postTime
)
} }
private fun isImportantOngoing(sbn: StatusBarNotification): Boolean { private fun isImportantOngoing(sbn: StatusBarNotification): Boolean {
// Whitelist certain ongoing notifications we care about
val importantPackages = setOf( val importantPackages = setOf(
"com.whatsapp", "com.whatsapp",
"org.telegram.messenger", "org.telegram.messenger",
"com.google.android.apps.messaging" "com.google.android.apps.messaging",
"org.thoughtcrime.securesms" // Signal
) )
return sbn.packageName in importantPackages return sbn.packageName in importantPackages
} }