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:
parent
e0835e0626
commit
e3b68c9c21
|
|
@ -5,9 +5,11 @@ 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.debug.DebugClient
|
||||||
|
import com.inou.clawdnode.gateway.DirectGateway
|
||||||
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
|
||||||
|
import com.inou.clawdnode.service.NotificationManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClawdNode Application
|
* ClawdNode Application
|
||||||
|
|
@ -42,6 +44,36 @@ class ClawdNodeApp : Application() {
|
||||||
"gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"),
|
"gatewayUrl" to (tokenStore.gatewayUrl ?: "not set"),
|
||||||
"hasToken" to (tokenStore.gatewayToken != null)
|
"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() {
|
private fun createNotificationChannels() {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ 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.debug.DebugClient
|
||||||
|
import com.inou.clawdnode.gateway.DirectGateway
|
||||||
import com.inou.clawdnode.protocol.CallIncomingEvent
|
import com.inou.clawdnode.protocol.CallIncomingEvent
|
||||||
import com.inou.clawdnode.service.NodeService
|
import com.inou.clawdnode.service.NodeService
|
||||||
|
|
||||||
|
|
@ -61,6 +62,9 @@ class CallScreener : CallScreeningService() {
|
||||||
state = "incoming"
|
state = "incoming"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Send via DirectGateway
|
||||||
|
DirectGateway.sendCall(callId, number, null, "incoming")
|
||||||
|
|
||||||
// Look up contact name
|
// Look up contact name
|
||||||
val contactName = lookupContact(number)
|
val contactName = lookupContact(number)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ 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.debug.DebugClient
|
||||||
|
import com.inou.clawdnode.gateway.DirectGateway
|
||||||
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
|
||||||
|
|
@ -147,6 +148,16 @@ class NotificationListener : NotificationListenerService() {
|
||||||
actions = actions
|
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
|
// Also log to local audit
|
||||||
try {
|
try {
|
||||||
ClawdNodeApp.instance.auditLog.logNotification(
|
ClawdNodeApp.instance.auditLog.logNotification(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue