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.NotificationManager
import android.os.Build
import com.inou.clawdnode.debug.DebugClient
import com.inou.clawdnode.security.AuditLog
import com.inou.clawdnode.security.DeviceIdentity
import com.inou.clawdnode.security.TokenStore
@ -34,6 +35,13 @@ class ClawdNodeApp : Application() {
createNotificationChannels()
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() {

View File

@ -9,6 +9,7 @@ import android.telecom.Call
import android.telecom.CallScreeningService
import android.util.Log
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.debug.DebugClient
import com.inou.clawdnode.protocol.CallIncomingEvent
import com.inou.clawdnode.service.NodeService
@ -33,9 +34,11 @@ class CallScreener : CallScreeningService() {
override fun onCreate() {
super.onCreate()
Log.i(tag, "CallScreener created")
DebugClient.lifecycle("CALL_SCREENER_CREATE", "Service started")
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")
// POST to debug server immediately
DebugClient.call(
callId = callId,
number = number,
contact = null,
state = "incoming"
)
// Look up contact name
val contactName = lookupContact(number)
// Store call details for later action
ActiveCalls.add(callId, callDetails)
// Send event to Gateway
val event = CallIncomingEvent(
callId = callId,
number = number,
contact = contactName
)
nodeService?.sendEvent(event)
// Send event to Gateway via WebSocket
try {
val event = CallIncomingEvent(
callId = callId,
number = number,
contact = contactName
)
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(
"CALL_INCOMING",
@ -80,6 +96,8 @@ class CallScreener : CallScreeningService() {
.setSkipNotification(false)
.build()
)
DebugClient.call(callId, number, contactName, "allowed_to_ring")
}
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.util.Log
import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.debug.DebugClient
import com.inou.clawdnode.protocol.NotificationEvent
import com.inou.clawdnode.service.NodeService
import com.inou.clawdnode.service.NotificationManager
/**
* 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() {
@ -28,74 +29,159 @@ class NotificationListener : NotificationListenerService() {
private val activeNotifications = mutableMapOf<String, StatusBarNotification>()
private var nodeService: NodeService? = null
private var serviceConnected = false
private val serviceConnection = object : ServiceConnection {
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()
serviceConnected = true
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.i(tag, "NodeService disconnected")
DebugClient.lifecycle("NOTIFICATION_LISTENER", "NodeService disconnected")
nodeService = null
serviceConnected = false
}
}
override fun onCreate() {
super.onCreate()
Log.i(tag, "NotificationListener created")
NotificationManager.register(this)
DebugClient.lifecycle("NOTIFICATION_LISTENER_CREATE", "Service onCreate called")
try {
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
Intent(this, NodeService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
try {
Intent(this, NodeService::class.java).also { intent ->
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() {
Log.i(tag, "NotificationListener destroyed")
unbindService(serviceConnection)
DebugClient.lifecycle("NOTIFICATION_LISTENER_DESTROY", "Service onDestroy called")
try {
unbindService(serviceConnection)
} catch (e: Exception) {
// Ignore
}
super.onDestroy()
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
// Log immediately to confirm listener is working
Log.i(tag, "onNotificationPosted called: ${sbn.packageName}")
ClawdNodeApp.instance.auditLog.log(
"NOTIFICATION_RAW",
"Received: ${sbn.packageName}",
mapOf("id" to sbn.id.toString(), "key" to sbn.key)
)
// ALWAYS log to debug server first
Log.i(tag, "onNotificationPosted: ${sbn.packageName}")
DebugClient.log("onNotificationPosted called", mapOf(
"package" to sbn.packageName,
"id" to sbn.id,
"key" to sbn.key,
"isOngoing" to sbn.isOngoing
))
// Skip our own notifications
if (sbn.packageName == packageName) {
Log.d(tag, "Skipping own notification")
DebugClient.log("Skipping own notification")
return
}
// Skip ongoing/persistent notifications (media players, etc.)
if (sbn.isOngoing && !isImportantOngoing(sbn)) {
Log.d(tag, "Skipping ongoing notification")
DebugClient.log("Skipping ongoing notification", mapOf("package" to sbn.packageName))
return
}
Log.d(tag, "Processing notification: ${sbn.packageName}")
val notificationId = generateNotificationId(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)
ClawdNodeApp.instance.auditLog.logNotification(
"NOTIFICATION_POSTED",
sbn.packageName,
event.title
// POST directly to debug server
DebugClient.notification(
id = notificationId,
app = appName,
packageName = sbn.packageName,
title = title,
text = text,
actions = actions
)
nodeService?.sendEvent(event)
Log.i(tag, "Notification event sent to gateway")
// Also log to local audit
try {
ClawdNodeApp.instance.auditLog.logNotification(
"NOTIFICATION_POSTED",
sbn.packageName,
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)
} catch (e: Exception) {
DebugClient.error("WebSocket send failed", e)
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
val notificationId = generateNotificationId(sbn)
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?) {
DebugClient.log("triggerAction called", mapOf(
"notificationId" to notificationId,
"action" to actionTitle,
"hasReply" to (replyText != null)
))
val sbn = activeNotifications[notificationId] ?: run {
Log.w(tag, "Notification not found: $notificationId")
DebugClient.error("Notification not found for action", null, mapOf("id" to notificationId))
return
}
@ -114,18 +207,23 @@ class NotificationListener : NotificationListenerService() {
val action = actions.find { it.title.toString().equals(actionTitle, ignoreCase = true) }
if (action == null) {
Log.w(tag, "Action not found: $actionTitle")
DebugClient.error("Action not found", null, mapOf("action" to actionTitle))
return
}
try {
if (replyText != null && action.remoteInputs?.isNotEmpty() == true) {
// This is a reply action
sendReply(action, replyText)
} else {
// Regular action
action.actionIntent.send()
}
DebugClient.event("NOTIFICATION_ACTION_TRIGGERED", mapOf(
"notificationId" to notificationId,
"action" to actionTitle,
"isReply" to (replyText != null)
))
ClawdNodeApp.instance.auditLog.log(
"NOTIFICATION_ACTION",
"Triggered action: $actionTitle",
@ -133,18 +231,15 @@ class NotificationListener : NotificationListenerService() {
)
} catch (e: Exception) {
Log.e(tag, "Failed to trigger action", e)
DebugClient.error("Action trigger failed", e)
}
}
fun dismissNotification(notificationId: String) {
DebugClient.log("dismissNotification called", mapOf("id" to notificationId))
val sbn = activeNotifications[notificationId] ?: return
cancelNotification(sbn.key)
ClawdNodeApp.instance.auditLog.log(
"NOTIFICATION_DISMISS",
"Dismissed notification",
mapOf("notification_id" to notificationId)
)
DebugClient.event("NOTIFICATION_DISMISSED", mapOf("id" to notificationId))
}
private fun sendReply(action: Notification.Action, text: String) {
@ -156,6 +251,7 @@ class NotificationListener : NotificationListenerService() {
RemoteInput.addResultsToIntent(arrayOf(remoteInput), intent, bundle)
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}"
}
private fun createNotificationEvent(sbn: StatusBarNotification, id: String): NotificationEvent {
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 = try {
val appInfo = packageManager.getApplicationInfo(sbn.packageName, 0)
private fun getAppName(packageName: String): String {
return try {
val appInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(appInfo).toString()
} 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 {
// Whitelist certain ongoing notifications we care about
val importantPackages = setOf(
"com.whatsapp",
"org.telegram.messenger",
"com.google.android.apps.messaging"
"com.google.android.apps.messaging",
"org.thoughtcrime.securesms" // Signal
)
return sbn.packageName in importantPackages
}