Fix: Implement proper Gateway WebSocket protocol

- Connect to /ws endpoint instead of /ws/node?token=...
- Handle connect.challenge event and send proper handshake
- Implement protocol v3 frame types (req/res/event)
- Add node.invoke command handling for gateway RPC
- Create DeviceIdentity for challenge signing (EC P-256 via Keystore)
- Declare caps: notifications, calls, voice
- Declare commands: notification.action/dismiss, call.answer/reject/speak/hangup
- Send proper responses for node.invoke requests
This commit is contained in:
James (ClawdBot) 2026-01-28 05:24:48 +00:00
parent 16c0fce034
commit 97a095e073
5 changed files with 782 additions and 124 deletions

View File

@ -5,6 +5,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.os.Build import android.os.Build
import com.inou.clawdnode.security.AuditLog import com.inou.clawdnode.security.AuditLog
import com.inou.clawdnode.security.DeviceIdentity
import com.inou.clawdnode.security.TokenStore import com.inou.clawdnode.security.TokenStore
/** /**

View File

@ -1,93 +1,65 @@
package com.inou.clawdnode.protocol package com.inou.clawdnode.protocol
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
/** /**
* Protocol messages between ClawdNode and Gateway. * Protocol messages between ClawdNode and Gateway.
* *
* Gateway Protocol v3:
* - Connect to /ws endpoint
* - Receive connect.challenge event with nonce
* - Send connect request with role, caps, commands, auth
* - Receive hello-ok response
*
* Phone Gateway: Events (notifications, calls, etc.) * Phone Gateway: Events (notifications, calls, etc.)
* Gateway Phone: Commands (answer call, screenshot, etc.) * Gateway Phone: Commands (answer call, screenshot, etc.)
*/ */
// ============================================ // ============================================
// EVENTS (Phone → Gateway) // PROTOCOL CONSTANTS
// ============================================ // ============================================
sealed class NodeEvent { object Protocol {
abstract val type: String const val VERSION = 3
const val CLIENT_ID = "android-node"
fun toJson(): String = Gson().toJson(this) const val PLATFORM = "android"
const val MODE = "node"
const val ROLE = "node"
} }
data class NotificationEvent(
@SerializedName("type") override val type: String = "notification",
@SerializedName("id") val id: String,
@SerializedName("app") val app: String,
@SerializedName("package") val packageName: String,
@SerializedName("title") val title: String?,
@SerializedName("text") val text: String?,
@SerializedName("actions") val actions: List<String> = emptyList(),
@SerializedName("timestamp") val timestamp: Long = System.currentTimeMillis()
) : NodeEvent()
data class CallIncomingEvent(
@SerializedName("type") override val type: String = "call_incoming",
@SerializedName("call_id") val callId: String,
@SerializedName("number") val number: String?,
@SerializedName("contact") val contact: String?,
@SerializedName("timestamp") val timestamp: Long = System.currentTimeMillis()
) : NodeEvent()
data class CallEndedEvent(
@SerializedName("type") override val type: String = "call_ended",
@SerializedName("call_id") val callId: String,
@SerializedName("duration") val durationSeconds: Int,
@SerializedName("outcome") val outcome: String, // answered, rejected, missed, voicemail
@SerializedName("transcript") val transcript: String? = null
) : NodeEvent()
data class CallAudioEvent(
@SerializedName("type") override val type: String = "call_audio",
@SerializedName("call_id") val callId: String,
@SerializedName("transcript") val transcript: String,
@SerializedName("is_final") val isFinal: Boolean = false
) : NodeEvent()
data class ScreenshotEvent(
@SerializedName("type") override val type: String = "screenshot",
@SerializedName("width") val width: Int,
@SerializedName("height") val height: Int,
@SerializedName("base64") val base64: String
) : NodeEvent()
data class StatusEvent(
@SerializedName("type") override val type: String = "status",
@SerializedName("connected") val connected: Boolean,
@SerializedName("battery") val batteryPercent: Int,
@SerializedName("permissions") val permissions: Map<String, Boolean>
) : NodeEvent()
// ============================================ // ============================================
// COMMANDS (Gateway → Phone) // HANDSHAKE MESSAGES
// ============================================ // ============================================
sealed class NodeCommand { /**
* Protocol frame types
*/
enum class FrameType {
@SerializedName("req") REQUEST,
@SerializedName("res") RESPONSE,
@SerializedName("event") EVENT
}
/**
* Base protocol frame
*/
data class ProtocolFrame(
@SerializedName("type") val type: String,
@SerializedName("id") val id: String? = null,
@SerializedName("method") val method: String? = null,
@SerializedName("event") val event: String? = null,
@SerializedName("params") val params: JsonObject? = null,
@SerializedName("payload") val payload: JsonObject? = null,
@SerializedName("ok") val ok: Boolean? = null,
@SerializedName("error") val error: JsonObject? = null
) {
companion object { companion object {
fun fromJson(json: String): NodeCommand? { fun fromJson(json: String): ProtocolFrame? {
return try { return try {
val base = Gson().fromJson(json, BaseCommand::class.java) Gson().fromJson(json, ProtocolFrame::class.java)
when (base.cmd) {
"screenshot" -> Gson().fromJson(json, ScreenshotCommand::class.java)
"notification_action" -> Gson().fromJson(json, NotificationActionCommand::class.java)
"notification_dismiss" -> Gson().fromJson(json, NotificationDismissCommand::class.java)
"call_answer" -> Gson().fromJson(json, CallAnswerCommand::class.java)
"call_reject" -> Gson().fromJson(json, CallRejectCommand::class.java)
"call_silence" -> Gson().fromJson(json, CallSilenceCommand::class.java)
"call_speak" -> Gson().fromJson(json, CallSpeakCommand::class.java)
"call_hangup" -> Gson().fromJson(json, CallHangupCommand::class.java)
else -> null
}
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@ -95,52 +67,353 @@ sealed class NodeCommand {
} }
} }
data class BaseCommand( /**
@SerializedName("cmd") val cmd: String, * Connect challenge event payload
@SerializedName("id") val id: String? = null * Gateway Client (first message after connect)
*/
data class ConnectChallengePayload(
@SerializedName("nonce") val nonce: String,
@SerializedName("ts") val timestamp: Long
) )
/**
* Client info for connect request
*/
data class ClientInfo(
@SerializedName("id") val id: String = Protocol.CLIENT_ID,
@SerializedName("version") val version: String,
@SerializedName("platform") val platform: String = Protocol.PLATFORM,
@SerializedName("mode") val mode: String = Protocol.MODE
)
/**
* Device identity for connect request
*/
data class DeviceInfo(
@SerializedName("id") val id: String,
@SerializedName("publicKey") val publicKey: String? = null,
@SerializedName("signature") val signature: String? = null,
@SerializedName("signedAt") val signedAt: Long? = null,
@SerializedName("nonce") val nonce: String? = null
)
/**
* Auth info for connect request
*/
data class AuthInfo(
@SerializedName("token") val token: String
)
/**
* Connect request parameters
*/
data class ConnectParams(
@SerializedName("minProtocol") val minProtocol: Int = Protocol.VERSION,
@SerializedName("maxProtocol") val maxProtocol: Int = Protocol.VERSION,
@SerializedName("client") val client: ClientInfo,
@SerializedName("role") val role: String = Protocol.ROLE,
@SerializedName("scopes") val scopes: List<String> = emptyList(),
@SerializedName("caps") val caps: List<String>,
@SerializedName("commands") val commands: List<String>,
@SerializedName("permissions") val permissions: Map<String, Boolean> = emptyMap(),
@SerializedName("auth") val auth: AuthInfo,
@SerializedName("locale") val locale: String = "en-US",
@SerializedName("userAgent") val userAgent: String,
@SerializedName("device") val device: DeviceInfo
)
/**
* Connect request frame
*/
data class ConnectRequest(
@SerializedName("type") val type: String = "req",
@SerializedName("id") val id: String,
@SerializedName("method") val method: String = "connect",
@SerializedName("params") val params: ConnectParams
) {
fun toJson(): String = Gson().toJson(this)
}
/**
* Hello-ok response payload
*/
data class HelloOkPayload(
@SerializedName("type") val type: String, // "hello-ok"
@SerializedName("protocol") val protocol: Int,
@SerializedName("policy") val policy: PolicyInfo? = null,
@SerializedName("auth") val auth: AuthResponse? = null
)
data class PolicyInfo(
@SerializedName("tickIntervalMs") val tickIntervalMs: Long? = null
)
data class AuthResponse(
@SerializedName("deviceToken") val deviceToken: String? = null,
@SerializedName("role") val role: String? = null,
@SerializedName("scopes") val scopes: List<String>? = null
)
// ============================================
// NODE INVOKE (Gateway → Phone)
// ============================================
/**
* Node invoke request from gateway
* This is how the gateway sends commands to nodes
*/
data class NodeInvokeParams(
@SerializedName("command") val command: String,
@SerializedName("args") val args: JsonObject? = null
)
// ============================================
// EVENTS (Phone → Gateway)
// ============================================
sealed class NodeEvent {
abstract fun toProtocolFrame(requestId: String): String
}
/**
* Generic event wrapper for sending events to gateway
*/
data class EventFrame(
@SerializedName("type") val type: String = "event",
@SerializedName("event") val event: String,
@SerializedName("payload") val payload: Any
) {
fun toJson(): String = Gson().toJson(this)
}
/**
* Response frame for node.invoke requests
*/
data class ResponseFrame(
@SerializedName("type") val type: String = "res",
@SerializedName("id") val id: String,
@SerializedName("ok") val ok: Boolean,
@SerializedName("payload") val payload: Any? = null,
@SerializedName("error") val error: ErrorPayload? = null
) {
fun toJson(): String = Gson().toJson(this)
}
data class ErrorPayload(
@SerializedName("code") val code: String,
@SerializedName("message") val message: String
)
data class NotificationEvent(
val id: String,
val app: String,
val packageName: String,
val title: String?,
val text: String?,
val actions: List<String> = emptyList(),
val timestamp: Long = System.currentTimeMillis()
) : NodeEvent() {
override fun toProtocolFrame(requestId: String): String {
return EventFrame(
event = "node.notification",
payload = mapOf(
"id" to id,
"app" to app,
"package" to packageName,
"title" to title,
"text" to text,
"actions" to actions,
"timestamp" to timestamp
)
).toJson()
}
}
data class CallIncomingEvent(
val callId: String,
val number: String?,
val contact: String?,
val timestamp: Long = System.currentTimeMillis()
) : NodeEvent() {
override fun toProtocolFrame(requestId: String): String {
return EventFrame(
event = "node.call.incoming",
payload = mapOf(
"callId" to callId,
"number" to number,
"contact" to contact,
"timestamp" to timestamp
)
).toJson()
}
}
data class CallEndedEvent(
val callId: String,
val durationSeconds: Int,
val outcome: String, // answered, rejected, missed, voicemail
val transcript: String? = null
) : NodeEvent() {
override fun toProtocolFrame(requestId: String): String {
return EventFrame(
event = "node.call.ended",
payload = mapOf(
"callId" to callId,
"duration" to durationSeconds,
"outcome" to outcome,
"transcript" to transcript
)
).toJson()
}
}
data class CallAudioEvent(
val callId: String,
val transcript: String,
val isFinal: Boolean = false
) : NodeEvent() {
override fun toProtocolFrame(requestId: String): String {
return EventFrame(
event = "node.call.audio",
payload = mapOf(
"callId" to callId,
"transcript" to transcript,
"isFinal" to isFinal
)
).toJson()
}
}
data class ScreenshotEvent(
val width: Int,
val height: Int,
val base64: String
) : NodeEvent() {
override fun toProtocolFrame(requestId: String): String {
return EventFrame(
event = "node.screenshot",
payload = mapOf(
"width" to width,
"height" to height,
"base64" to base64
)
).toJson()
}
}
data class StatusEvent(
val connected: Boolean,
val batteryPercent: Int,
val permissions: Map<String, Boolean>
) : NodeEvent() {
override fun toProtocolFrame(requestId: String): String {
return EventFrame(
event = "node.status",
payload = mapOf(
"connected" to connected,
"battery" to batteryPercent,
"permissions" to permissions
)
).toJson()
}
}
// ============================================
// COMMANDS (Gateway → Phone via node.invoke)
// ============================================
sealed class NodeCommand {
abstract val requestId: String?
companion object {
/**
* Parse a node.invoke request from the gateway
*/
fun fromInvoke(requestId: String, params: NodeInvokeParams): NodeCommand? {
val args = params.args
return when (params.command) {
"screenshot" -> ScreenshotCommand(requestId)
"notification.action" -> NotificationActionCommand(
requestId = requestId,
notificationId = args?.get("id")?.asString ?: return null,
action = args.get("action")?.asString ?: return null,
text = args.get("text")?.asString
)
"notification.dismiss" -> NotificationDismissCommand(
requestId = requestId,
notificationId = args?.get("id")?.asString ?: return null
)
"call.answer" -> CallAnswerCommand(
requestId = requestId,
callId = args?.get("callId")?.asString ?: return null,
greeting = args.get("greeting")?.asString
)
"call.reject" -> CallRejectCommand(
requestId = requestId,
callId = args?.get("callId")?.asString ?: return null,
reason = args.get("reason")?.asString
)
"call.silence" -> CallSilenceCommand(
requestId = requestId,
callId = args?.get("callId")?.asString ?: return null
)
"call.speak" -> CallSpeakCommand(
requestId = requestId,
callId = args?.get("callId")?.asString ?: return null,
text = args.get("text")?.asString ?: return null,
voice = args.get("voice")?.asString
)
"call.hangup" -> CallHangupCommand(
requestId = requestId,
callId = args?.get("callId")?.asString ?: return null
)
else -> null
}
}
}
}
data class ScreenshotCommand( data class ScreenshotCommand(
@SerializedName("cmd") val cmd: String = "screenshot" override val requestId: String? = null
) : NodeCommand() ) : NodeCommand()
data class NotificationActionCommand( data class NotificationActionCommand(
@SerializedName("cmd") val cmd: String = "notification_action", override val requestId: String? = null,
@SerializedName("id") val notificationId: String, val notificationId: String,
@SerializedName("action") val action: String, val action: String,
@SerializedName("text") val text: String? = null // For reply actions val text: String? = null // For reply actions
) : NodeCommand() ) : NodeCommand()
data class NotificationDismissCommand( data class NotificationDismissCommand(
@SerializedName("cmd") val cmd: String = "notification_dismiss", override val requestId: String? = null,
@SerializedName("id") val notificationId: String val notificationId: String
) : NodeCommand() ) : NodeCommand()
data class CallAnswerCommand( data class CallAnswerCommand(
@SerializedName("cmd") val cmd: String = "call_answer", override val requestId: String? = null,
@SerializedName("call_id") val callId: String, val callId: String,
@SerializedName("greeting") val greeting: String? = null // TTS greeting to play val greeting: String? = null // TTS greeting to play
) : NodeCommand() ) : NodeCommand()
data class CallRejectCommand( data class CallRejectCommand(
@SerializedName("cmd") val cmd: String = "call_reject", override val requestId: String? = null,
@SerializedName("call_id") val callId: String, val callId: String,
@SerializedName("reason") val reason: String? = null val reason: String? = null
) : NodeCommand() ) : NodeCommand()
data class CallSilenceCommand( data class CallSilenceCommand(
@SerializedName("cmd") val cmd: String = "call_silence", override val requestId: String? = null,
@SerializedName("call_id") val callId: String val callId: String
) : NodeCommand() ) : NodeCommand()
data class CallSpeakCommand( data class CallSpeakCommand(
@SerializedName("cmd") val cmd: String = "call_speak", override val requestId: String? = null,
@SerializedName("call_id") val callId: String, val callId: String,
@SerializedName("text") val text: String, // Text to speak via TTS val text: String, // Text to speak via TTS
@SerializedName("voice") val voice: String? = null // TTS voice preference val voice: String? = null // TTS voice preference
) : NodeCommand() ) : NodeCommand()
data class CallHangupCommand( data class CallHangupCommand(
@SerializedName("cmd") val cmd: String = "call_hangup", override val requestId: String? = null,
@SerializedName("call_id") val callId: String val callId: String
) : NodeCommand() ) : NodeCommand()

View File

@ -0,0 +1,134 @@
package com.inou.clawdnode.security
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.*
import java.security.spec.ECGenParameterSpec
/**
* Manages device identity using Android Keystore.
*
* Generates and stores an EC P-256 keypair for signing Gateway challenge nonces.
* The private key never leaves the secure hardware (TEE/SE) on supported devices.
*/
class DeviceIdentity(context: Context) {
private val tag = "DeviceIdentity"
private val keyAlias = "clawdnode_device_key"
private val keyStore: KeyStore by lazy {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
}
/**
* Get or generate the device's public key
*/
val publicKey: String by lazy {
ensureKeyPair()
val publicKey = keyStore.getCertificate(keyAlias)?.publicKey
?: throw IllegalStateException("No public key available")
Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP)
}
/**
* Get device ID (fingerprint of the public key)
*/
val deviceId: String by lazy {
ensureKeyPair()
val publicKey = keyStore.getCertificate(keyAlias)?.publicKey
?: throw IllegalStateException("No public key available")
// Create fingerprint using SHA-256 of the public key
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(publicKey.encoded)
// Return as hex string (first 16 bytes = 32 chars)
hash.take(16).joinToString("") { "%02x".format(it) }
}
/**
* Sign a challenge nonce for gateway authentication
*
* @param nonce The challenge nonce from the gateway
* @return Signature and timestamp
*/
fun signChallenge(nonce: String): SignedChallenge {
ensureKeyPair()
val signedAt = System.currentTimeMillis()
val dataToSign = "$nonce:$signedAt"
val privateKey = keyStore.getKey(keyAlias, null) as? PrivateKey
?: throw IllegalStateException("No private key available")
val signature = Signature.getInstance("SHA256withECDSA").apply {
initSign(privateKey)
update(dataToSign.toByteArray(Charsets.UTF_8))
}
val signatureBytes = signature.sign()
val signatureBase64 = Base64.encodeToString(signatureBytes, Base64.NO_WRAP)
return SignedChallenge(
signature = signatureBase64,
signedAt = signedAt,
nonce = nonce
)
}
/**
* Ensure the keypair exists, generating if necessary
*/
private fun ensureKeyPair() {
if (!keyStore.containsAlias(keyAlias)) {
generateKeyPair()
}
}
/**
* Generate a new EC P-256 keypair in the Android Keystore
*/
private fun generateKeyPair() {
Log.i(tag, "Generating new device keypair")
val keyPairGenerator = KeyPairGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_EC,
"AndroidKeyStore"
)
val parameterSpec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
).apply {
setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
setDigests(KeyProperties.DIGEST_SHA256)
// Require user auth for extra security (optional, can be removed if too restrictive)
// setUserAuthenticationRequired(true)
// setUserAuthenticationValidityDurationSeconds(300)
}.build()
keyPairGenerator.initialize(parameterSpec)
keyPairGenerator.generateKeyPair()
Log.i(tag, "Device keypair generated successfully")
}
/**
* Delete the keypair (for testing/reset)
*/
fun deleteKeyPair() {
if (keyStore.containsAlias(keyAlias)) {
keyStore.deleteEntry(keyAlias)
Log.i(tag, "Device keypair deleted")
}
}
data class SignedChallenge(
val signature: String,
val signedAt: Long,
val nonce: String
)
}

View File

@ -1,22 +1,35 @@
package com.inou.clawdnode.service package com.inou.clawdnode.service
import android.util.Log import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.inou.clawdnode.BuildConfig
import com.inou.clawdnode.ClawdNodeApp import com.inou.clawdnode.ClawdNodeApp
import com.inou.clawdnode.protocol.NodeCommand import com.inou.clawdnode.protocol.*
import com.inou.clawdnode.protocol.NodeEvent import com.inou.clawdnode.security.DeviceIdentity
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.* import okhttp3.*
import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/** /**
* WebSocket client for Clawdbot Gateway connection. * WebSocket client for Clawdbot Gateway connection.
* Handles reconnection, authentication, and message routing. *
* Implements Gateway Protocol v3:
* 1. Connect to /ws endpoint
* 2. Receive connect.challenge event with nonce
* 3. Send connect request with role, caps, commands, auth
* 4. Handle hello-ok response
*
* After handshake, handles message routing for node commands.
*/ */
class GatewayClient( class GatewayClient(
private val onCommand: (NodeCommand) -> Unit, private val onCommand: (NodeCommand) -> Unit,
private val onConnectionChange: (Boolean) -> Unit private val onConnectionChange: (Boolean) -> Unit
) { ) {
private val tag = "GatewayClient" private val tag = "GatewayClient"
private val gson = Gson()
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
@ -27,26 +40,43 @@ class GatewayClient(
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private var isConnected = false private var isConnected = false
private var isHandshakeComplete = false
private var shouldReconnect = true private var shouldReconnect = true
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val requestIdCounter = AtomicInteger(0)
private val auditLog get() = ClawdNodeApp.instance.auditLog private val auditLog get() = ClawdNodeApp.instance.auditLog
private val tokenStore get() = ClawdNodeApp.instance.tokenStore private val tokenStore get() = ClawdNodeApp.instance.tokenStore
private val deviceIdentity by lazy { DeviceIdentity(ClawdNodeApp.instance) }
// Node capabilities
private val caps = listOf("notifications", "calls", "voice")
private val commands = listOf(
"notification.action",
"notification.dismiss",
"call.answer",
"call.reject",
"call.speak",
"call.hangup"
)
fun connect() { fun connect() {
val url = tokenStore.gatewayUrl ?: run { val url = tokenStore.gatewayUrl ?: run {
Log.w(tag, "No gateway URL configured") Log.w(tag, "No gateway URL configured")
return return
} }
val token = tokenStore.gatewayToken ?: run {
if (tokenStore.gatewayToken == null) {
Log.w(tag, "No gateway token configured") Log.w(tag, "No gateway token configured")
return return
} }
shouldReconnect = true shouldReconnect = true
isHandshakeComplete = false
// Build WebSocket URL with auth // Build WebSocket URL - connect to /ws (not /ws/node)
val wsUrl = buildWsUrl(url, token) val wsUrl = buildWsUrl(url)
Log.d(tag, "Connecting to $wsUrl") Log.d(tag, "Connecting to $wsUrl")
val request = Request.Builder() val request = Request.Builder()
@ -55,10 +85,8 @@ class GatewayClient(
webSocket = client.newWebSocket(request, object : WebSocketListener() { webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(tag, "Connected to Gateway") Log.i(tag, "WebSocket connected, waiting for challenge...")
isConnected = true // Don't set isConnected yet - wait for handshake
onConnectionChange(true)
auditLog.log("GATEWAY_CONNECTED", "WebSocket connection established")
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
@ -89,43 +117,222 @@ class GatewayClient(
webSocket?.close(1000, "Client disconnect") webSocket?.close(1000, "Client disconnect")
webSocket = null webSocket = null
isConnected = false isConnected = false
isHandshakeComplete = false
onConnectionChange(false) onConnectionChange(false)
} }
fun send(event: NodeEvent) { fun send(event: NodeEvent) {
val json = event.toJson() val requestId = generateRequestId()
val json = event.toProtocolFrame(requestId)
Log.d(tag, "Sending: $json") Log.d(tag, "Sending: $json")
if (isConnected) { if (isConnected && isHandshakeComplete) {
webSocket?.send(json) webSocket?.send(json)
} else { } else {
Log.w(tag, "Not connected, queuing event") Log.w(tag, "Not connected or handshake incomplete, cannot send event")
// TODO: Queue for retry // TODO: Queue for retry
} }
} }
private fun buildWsUrl(baseUrl: String, token: String): String { /**
* Send a response to a node.invoke request
*/
fun sendResponse(requestId: String, success: Boolean, payload: Any? = null, error: String? = null) {
val frame = if (success) {
ResponseFrame(id = requestId, ok = true, payload = payload)
} else {
ResponseFrame(id = requestId, ok = false, error = ErrorPayload("ERROR", error ?: "Unknown error"))
}
val json = frame.toJson()
Log.d(tag, "Sending response: $json")
if (isConnected && isHandshakeComplete) {
webSocket?.send(json)
}
}
private fun buildWsUrl(baseUrl: String): String {
// Convert http(s) to ws(s) // Convert http(s) to ws(s)
val wsBase = baseUrl val wsBase = baseUrl
.replace("http://", "ws://") .replace("http://", "ws://")
.replace("https://", "wss://") .replace("https://", "wss://")
.trimEnd('/') .trimEnd('/')
return "$wsBase/ws/node?token=$token" // Connect to /ws endpoint (no token in URL - sent in handshake)
return "$wsBase/ws"
} }
private fun handleMessage(json: String) { private fun handleMessage(json: String) {
val command = NodeCommand.fromJson(json) val frame = ProtocolFrame.fromJson(json)
if (command != null) { if (frame == null) {
auditLog.logCommand(command::class.simpleName ?: "unknown", "gateway", true) Log.w(tag, "Failed to parse frame: $json")
onCommand(command) return
} else {
Log.w(tag, "Unknown command: $json")
} }
when (frame.type) {
"event" -> handleEvent(frame)
"req" -> handleRequest(frame)
"res" -> handleResponse(frame)
else -> Log.w(tag, "Unknown frame type: ${frame.type}")
}
}
private fun handleEvent(frame: ProtocolFrame) {
when (frame.event) {
"connect.challenge" -> handleConnectChallenge(frame)
else -> Log.d(tag, "Received event: ${frame.event}")
}
}
private fun handleConnectChallenge(frame: ProtocolFrame) {
Log.i(tag, "Received connect.challenge, sending handshake...")
val payload = frame.payload
val nonce = payload?.get("nonce")?.asString
if (nonce == null) {
Log.e(tag, "No nonce in connect.challenge")
return
}
val token = tokenStore.gatewayToken ?: run {
Log.e(tag, "No token available for handshake")
return
}
// Sign the challenge nonce
val signedChallenge = try {
deviceIdentity.signChallenge(nonce)
} catch (e: Exception) {
Log.e(tag, "Failed to sign challenge", e)
null
}
val connectRequest = ConnectRequest(
id = generateRequestId(),
params = ConnectParams(
client = ClientInfo(
id = Protocol.CLIENT_ID,
version = getAppVersion(),
platform = Protocol.PLATFORM,
mode = Protocol.MODE
),
role = Protocol.ROLE,
caps = caps,
commands = commands,
permissions = mapOf(
"notifications.read" to true,
"notifications.action" to true,
"calls.answer" to true,
"calls.reject" to true,
"calls.speak" to true
),
auth = AuthInfo(token = token),
userAgent = "clawdnode-android/${getAppVersion()}",
device = DeviceInfo(
id = deviceIdentity.deviceId,
publicKey = signedChallenge?.let { deviceIdentity.publicKey },
signature = signedChallenge?.signature,
signedAt = signedChallenge?.signedAt,
nonce = signedChallenge?.nonce
)
)
)
val requestJson = connectRequest.toJson()
Log.d(tag, "Sending connect request: $requestJson")
webSocket?.send(requestJson)
}
private fun handleRequest(frame: ProtocolFrame) {
when (frame.method) {
"node.invoke" -> handleNodeInvoke(frame)
else -> {
Log.w(tag, "Unknown request method: ${frame.method}")
// Send error response
frame.id?.let { id ->
sendResponse(id, false, error = "Unknown method: ${frame.method}")
}
}
}
}
private fun handleNodeInvoke(frame: ProtocolFrame) {
val requestId = frame.id ?: run {
Log.e(tag, "node.invoke missing request id")
return
}
val params = frame.params ?: run {
Log.e(tag, "node.invoke missing params")
sendResponse(requestId, false, error = "Missing params")
return
}
val command = params.get("command")?.asString
val args = params.get("args")?.asJsonObject
if (command == null) {
Log.e(tag, "node.invoke missing command")
sendResponse(requestId, false, error = "Missing command")
return
}
Log.i(tag, "Received node.invoke: $command")
auditLog.logCommand(command, "gateway", true)
val invokeParams = NodeInvokeParams(command = command, args = args)
val nodeCommand = NodeCommand.fromInvoke(requestId, invokeParams)
if (nodeCommand != null) {
onCommand(nodeCommand)
} else {
Log.w(tag, "Unknown command: $command")
sendResponse(requestId, false, error = "Unknown command: $command")
}
}
private fun handleResponse(frame: ProtocolFrame) {
val ok = frame.ok ?: false
val payload = frame.payload
// Check if this is hello-ok response
val payloadType = payload?.get("type")?.asString
if (payloadType == "hello-ok") {
handleHelloOk(frame)
return
}
if (!ok) {
val error = frame.error
Log.e(tag, "Request failed: $error")
}
}
private fun handleHelloOk(frame: ProtocolFrame) {
Log.i(tag, "Received hello-ok, handshake complete!")
val payload = frame.payload
val protocol = payload?.get("protocol")?.asInt ?: Protocol.VERSION
// Check for device token
val auth = payload?.get("auth")?.asJsonObject
val deviceToken = auth?.get("deviceToken")?.asString
if (deviceToken != null) {
Log.i(tag, "Received device token, storing for future connects")
// Could store this for future use
}
isConnected = true
isHandshakeComplete = true
onConnectionChange(true)
auditLog.log("GATEWAY_CONNECTED", "Protocol v$protocol handshake complete")
} }
private fun handleDisconnect() { private fun handleDisconnect() {
isConnected = false isConnected = false
isHandshakeComplete = false
onConnectionChange(false) onConnectionChange(false)
if (shouldReconnect) { if (shouldReconnect) {
@ -139,5 +346,17 @@ class GatewayClient(
} }
} }
fun isConnected() = isConnected private fun generateRequestId(): String {
return "req_${requestIdCounter.incrementAndGet()}_${UUID.randomUUID().toString().take(8)}"
}
private fun getAppVersion(): String {
return try {
BuildConfig.VERSION_NAME
} catch (e: Exception) {
"0.1.0"
}
}
fun isConnected() = isConnected && isHandshakeComplete
} }

View File

@ -97,8 +97,9 @@ class NodeService : Service() {
private fun handleCommand(command: NodeCommand) { private fun handleCommand(command: NodeCommand) {
Log.d(tag, "Handling command: ${command::class.simpleName}") Log.d(tag, "Handling command: ${command::class.simpleName}")
try {
when (command) { when (command) {
is ScreenshotCommand -> handleScreenshot() is ScreenshotCommand -> handleScreenshot(command)
is NotificationActionCommand -> handleNotificationAction(command) is NotificationActionCommand -> handleNotificationAction(command)
is NotificationDismissCommand -> handleNotificationDismiss(command) is NotificationDismissCommand -> handleNotificationDismiss(command)
is CallAnswerCommand -> handleCallAnswer(command) is CallAnswerCommand -> handleCallAnswer(command)
@ -107,40 +108,70 @@ class NodeService : Service() {
is CallSpeakCommand -> handleCallSpeak(command) is CallSpeakCommand -> handleCallSpeak(command)
is CallHangupCommand -> handleCallHangup(command) is CallHangupCommand -> handleCallHangup(command)
} }
} catch (e: Exception) {
Log.e(tag, "Error handling command", e)
command.requestId?.let {
gatewayClient.sendResponse(it, false, error = e.message)
}
}
} }
private fun handleScreenshot() { private fun handleScreenshot(cmd: ScreenshotCommand) {
// TODO: Implement screenshot capture via MediaProjection // TODO: Implement screenshot capture via MediaProjection
Log.d(tag, "Screenshot requested - not yet implemented") Log.d(tag, "Screenshot requested - not yet implemented")
cmd.requestId?.let {
gatewayClient.sendResponse(it, false, error = "Screenshot not yet implemented")
}
} }
private fun handleNotificationAction(cmd: NotificationActionCommand) { private fun handleNotificationAction(cmd: NotificationActionCommand) {
// Delegate to NotificationListener // Delegate to NotificationListener
NotificationManager.triggerAction(cmd.notificationId, cmd.action, cmd.text) NotificationManager.triggerAction(cmd.notificationId, cmd.action, cmd.text)
cmd.requestId?.let {
gatewayClient.sendResponse(it, true, payload = mapOf("triggered" to true))
}
} }
private fun handleNotificationDismiss(cmd: NotificationDismissCommand) { private fun handleNotificationDismiss(cmd: NotificationDismissCommand) {
NotificationManager.dismiss(cmd.notificationId) NotificationManager.dismiss(cmd.notificationId)
cmd.requestId?.let {
gatewayClient.sendResponse(it, true, payload = mapOf("dismissed" to true))
}
} }
private fun handleCallAnswer(cmd: CallAnswerCommand) { private fun handleCallAnswer(cmd: CallAnswerCommand) {
CallManager.answer(cmd.callId, cmd.greeting) CallManager.answer(cmd.callId, cmd.greeting)
cmd.requestId?.let {
gatewayClient.sendResponse(it, true, payload = mapOf("answered" to true))
}
} }
private fun handleCallReject(cmd: CallRejectCommand) { private fun handleCallReject(cmd: CallRejectCommand) {
CallManager.reject(cmd.callId, cmd.reason) CallManager.reject(cmd.callId, cmd.reason)
cmd.requestId?.let {
gatewayClient.sendResponse(it, true, payload = mapOf("rejected" to true))
}
} }
private fun handleCallSilence(cmd: CallSilenceCommand) { private fun handleCallSilence(cmd: CallSilenceCommand) {
CallManager.silence(cmd.callId) CallManager.silence(cmd.callId)
cmd.requestId?.let {
gatewayClient.sendResponse(it, true, payload = mapOf("silenced" to true))
}
} }
private fun handleCallSpeak(cmd: CallSpeakCommand) { private fun handleCallSpeak(cmd: CallSpeakCommand) {
CallManager.speak(cmd.callId, cmd.text, cmd.voice) CallManager.speak(cmd.callId, cmd.text, cmd.voice)
cmd.requestId?.let {
gatewayClient.sendResponse(it, true, payload = mapOf("speaking" to true))
}
} }
private fun handleCallHangup(cmd: CallHangupCommand) { private fun handleCallHangup(cmd: CallHangupCommand) {
CallManager.hangup(cmd.callId) CallManager.hangup(cmd.callId)
cmd.requestId?.let {
gatewayClient.sendResponse(it, true, payload = mapOf("hungup" to true))
}
} }
// ======================================== // ========================================