feat: Add DirectGateway - our own WebSocket server

- DirectGateway.kt: bidirectional WebSocket to ws://100.123.216.65:9878
- No auth, no restrictions - full control
- Sends notifications and calls
- Receives commands: notification.action, call.answer/reject/hangup
- App sets up command handlers and auto-connects
- NotificationListener & CallScreener now send to both debug + gateway

Server: /home/johan/dev/clawdnode-gateway/server.js
HTTP API: http://100.123.216.65:9877
WebSocket: ws://100.123.216.65:9878
This commit is contained in:
James (ClawdBot) 2026-01-28 21:55:50 +00:00
parent e0835e0626
commit e3b68c9c21
4 changed files with 313 additions and 0 deletions

View File

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

View File

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

View File

@ -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<String>
) {
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<String, Any?> = 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<String, Any?>) {
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
}

View File

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