diff --git a/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt b/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt index 8eff2c9..d9d9a59 100644 --- a/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt +++ b/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt @@ -5,9 +5,11 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build import com.inou.clawdnode.debug.DebugClient +import com.inou.clawdnode.gateway.DirectGateway import com.inou.clawdnode.security.AuditLog import com.inou.clawdnode.security.DeviceIdentity import com.inou.clawdnode.security.TokenStore +import com.inou.clawdnode.service.NotificationManager /** * ClawdNode Application @@ -42,6 +44,36 @@ class ClawdNodeApp : Application() { "gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"), "hasToken" to (tokenStore.gatewayToken != null) )) + + // Connect to our DirectGateway (bidirectional WebSocket) + setupDirectGateway() + } + + private fun setupDirectGateway() { + // Set up command handlers + DirectGateway.onNotificationAction = { notificationId, action, replyText -> + auditLog.log("COMMAND_RECEIVED", "notification.action: $action on $notificationId") + NotificationManager.getInstance()?.triggerAction(notificationId, action, replyText) + } + + DirectGateway.onCallAnswer = { callId -> + auditLog.log("COMMAND_RECEIVED", "call.answer: $callId") + // TODO: Implement call answering via TelecomManager + } + + DirectGateway.onCallReject = { callId -> + auditLog.log("COMMAND_RECEIVED", "call.reject: $callId") + // TODO: Implement call rejection + } + + DirectGateway.onCallHangup = { callId -> + auditLog.log("COMMAND_RECEIVED", "call.hangup: $callId") + // TODO: Implement call hangup + } + + // Connect + DirectGateway.connect() + DebugClient.lifecycle("DIRECT_GATEWAY_SETUP", "Command handlers registered, connecting...") } private fun createNotificationChannels() { diff --git a/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt b/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt index 4cf57b5..f25778d 100644 --- a/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt +++ b/app/src/main/java/com/inou/clawdnode/calls/CallScreener.kt @@ -10,6 +10,7 @@ import android.telecom.CallScreeningService import android.util.Log 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 @@ -61,6 +62,9 @@ class CallScreener : CallScreeningService() { state = "incoming" ) + // Send via DirectGateway + DirectGateway.sendCall(callId, number, null, "incoming") + // Look up contact name val contactName = lookupContact(number) diff --git a/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt b/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt new file mode 100644 index 0000000..c2a2eb9 --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/gateway/DirectGateway.kt @@ -0,0 +1,266 @@ +package com.inou.clawdnode.gateway + +import android.util.Log +import com.inou.clawdnode.ClawdNodeApp +import com.inou.clawdnode.debug.DebugClient +import kotlinx.coroutines.* +import okhttp3.* +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * Direct WebSocket connection to our own ClawdNode Gateway. + * No authentication, no restrictions - full bidirectional control. + */ +object DirectGateway { + private const val TAG = "DirectGateway" + + // Our gateway - Tailscale IP of james server + private const val GATEWAY_URL = "ws://100.123.216.65:9878" + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + private var webSocket: WebSocket? = null + private var isConnected = false + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Command handlers + var onNotificationAction: ((notificationId: String, action: String, replyText: String?) -> Unit)? = null + var onCallAnswer: ((callId: String) -> Unit)? = null + var onCallReject: ((callId: String) -> Unit)? = null + var onCallHangup: ((callId: String) -> Unit)? = null + + fun connect() { + if (webSocket != null) { + Log.d(TAG, "Already connected or connecting") + return + } + + Log.i(TAG, "Connecting to $GATEWAY_URL") + DebugClient.lifecycle("DIRECT_GATEWAY", "Connecting to $GATEWAY_URL") + + val request = Request.Builder() + .url(GATEWAY_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" + )) + } + + override fun onMessage(ws: WebSocket, text: String) { + Log.d(TAG, "Received: $text") + handleMessage(text) + } + + override fun onClosing(ws: WebSocket, code: Int, reason: String) { + Log.i(TAG, "Connection closing: $code $reason") + ws.close(1000, null) + } + + override fun onClosed(ws: WebSocket, code: Int, reason: String) { + Log.i(TAG, "Connection closed: $code $reason") + isConnected = false + webSocket = null + DebugClient.lifecycle("DIRECT_GATEWAY", "Disconnected: $code $reason") + scheduleReconnect() + } + + override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "Connection failed", t) + isConnected = false + webSocket = null + DebugClient.error("DirectGateway connection failed", t) + scheduleReconnect() + } + }) + } + + fun disconnect() { + webSocket?.close(1000, "Client disconnect") + webSocket = null + isConnected = false + } + + private fun scheduleReconnect() { + scope.launch { + delay(5000) + if (webSocket == null) { + connect() + } + } + } + + private fun handleMessage(text: String) { + try { + val json = JSONObject(text) + 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") + )) + } + + "command" -> { + val command = json.optString("command") + val params = json.optJSONObject("params") ?: JSONObject() + val commandId = json.optString("commandId") + + Log.i(TAG, "Received command: $command") + DebugClient.log("Command received", mapOf( + "command" to command, + "commandId" to commandId + )) + + handleCommand(command, params, commandId) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing message", e) + DebugClient.error("DirectGateway parse error", e) + } + } + + private fun handleCommand(command: String, params: JSONObject, commandId: String) { + try { + when (command) { + "notification.action" -> { + val notificationId = params.optString("notificationId") + val action = params.optString("action") + val replyText = params.optString("replyText", null) + + Log.i(TAG, "Triggering notification action: $action on $notificationId") + onNotificationAction?.invoke(notificationId, action, replyText) + + sendResponse(commandId, true) + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "notification.action: $action") + } + + "call.answer" -> { + val callId = params.optString("callId") + Log.i(TAG, "Answering call: $callId") + onCallAnswer?.invoke(callId) + + sendResponse(commandId, true) + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.answer") + } + + "call.reject" -> { + val callId = params.optString("callId") + Log.i(TAG, "Rejecting call: $callId") + onCallReject?.invoke(callId) + + sendResponse(commandId, true) + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.reject") + } + + "call.hangup" -> { + val callId = params.optString("callId") + Log.i(TAG, "Hanging up call: $callId") + onCallHangup?.invoke(callId) + + sendResponse(commandId, true) + ClawdNodeApp.instance.auditLog.log("COMMAND_EXECUTED", "call.hangup") + } + + else -> { + Log.w(TAG, "Unknown command: $command") + sendResponse(commandId, false, "Unknown command: $command") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error executing command", e) + sendResponse(commandId, false, e.message) + DebugClient.error("Command execution failed", e) + } + } + + private fun sendResponse(commandId: String, success: Boolean, error: String? = null) { + send(mapOf( + "type" to "response", + "commandId" to commandId, + "success" to success, + "error" to error + )) + } + + // ======================================== + // Outgoing events + // ======================================== + + fun sendNotification( + id: String, + app: String, + packageName: String, + title: String?, + text: String?, + actions: List + ) { + send(mapOf( + "type" to "notification", + "id" to id, + "app" to app, + "packageName" to packageName, + "title" to title, + "text" to text, + "actions" to actions, + "timestamp" to System.currentTimeMillis() + )) + } + + fun sendCall( + callId: String, + number: String?, + contact: String?, + state: String + ) { + send(mapOf( + "type" to "call", + "callId" to callId, + "number" to number, + "contact" to contact, + "state" to state, + "timestamp" to System.currentTimeMillis() + )) + } + + fun sendLog(message: String, data: Map = emptyMap()) { + send(mapOf("type" to "log", "message" to message) + data) + } + + fun sendLifecycle(event: String, message: String = "") { + send(mapOf("type" to "lifecycle", "event" to event, "message" to message)) + } + + private fun send(data: Map) { + if (!isConnected) { + Log.d(TAG, "Not connected, cannot send") + return + } + + try { + val json = JSONObject(data).toString() + webSocket?.send(json) + } catch (e: Exception) { + Log.e(TAG, "Send failed", e) + } + } + + fun isConnected() = isConnected +} diff --git a/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt b/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt index 93f668d..22a8543 100644 --- a/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt +++ b/app/src/main/java/com/inou/clawdnode/notifications/NotificationListener.kt @@ -13,6 +13,7 @@ import android.service.notification.StatusBarNotification import android.util.Log import com.inou.clawdnode.ClawdNodeApp import com.inou.clawdnode.debug.DebugClient +import com.inou.clawdnode.gateway.DirectGateway import com.inou.clawdnode.protocol.NotificationEvent import com.inou.clawdnode.service.NodeService import com.inou.clawdnode.service.NotificationManager @@ -147,6 +148,16 @@ class NotificationListener : NotificationListenerService() { actions = actions ) + // Send via DirectGateway (bidirectional WebSocket) + DirectGateway.sendNotification( + id = notificationId, + app = appName, + packageName = sbn.packageName, + title = title, + text = text, + actions = actions + ) + // Also log to local audit try { ClawdNodeApp.instance.auditLog.logNotification(