diff --git a/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt b/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt index ea753a8..39f7af7 100644 --- a/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt +++ b/app/src/main/java/com/inou/clawdnode/ClawdNodeApp.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build import com.inou.clawdnode.security.AuditLog +import com.inou.clawdnode.security.DeviceIdentity import com.inou.clawdnode.security.TokenStore /** diff --git a/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt b/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt index f3ab888..7a4afba 100644 --- a/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt +++ b/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt @@ -1,93 +1,65 @@ package com.inou.clawdnode.protocol import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser import com.google.gson.annotations.SerializedName /** * 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.) * Gateway → Phone: Commands (answer call, screenshot, etc.) */ // ============================================ -// EVENTS (Phone → Gateway) +// PROTOCOL CONSTANTS // ============================================ -sealed class NodeEvent { - abstract val type: String - - fun toJson(): String = Gson().toJson(this) +object Protocol { + const val VERSION = 3 + const val CLIENT_ID = "android-node" + 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 = 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 -) : 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 { - fun fromJson(json: String): NodeCommand? { + fun fromJson(json: String): ProtocolFrame? { return try { - val base = Gson().fromJson(json, BaseCommand::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 - } + Gson().fromJson(json, ProtocolFrame::class.java) } catch (e: Exception) { null } @@ -95,52 +67,353 @@ sealed class NodeCommand { } } -data class BaseCommand( - @SerializedName("cmd") val cmd: String, - @SerializedName("id") val id: String? = null +/** + * Connect challenge event payload + * 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 = emptyList(), + @SerializedName("caps") val caps: List, + @SerializedName("commands") val commands: List, + @SerializedName("permissions") val permissions: Map = 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? = 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 = 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 +) : 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( - @SerializedName("cmd") val cmd: String = "screenshot" + override val requestId: String? = null ) : NodeCommand() data class NotificationActionCommand( - @SerializedName("cmd") val cmd: String = "notification_action", - @SerializedName("id") val notificationId: String, - @SerializedName("action") val action: String, - @SerializedName("text") val text: String? = null // For reply actions + override val requestId: String? = null, + val notificationId: String, + val action: String, + val text: String? = null // For reply actions ) : NodeCommand() data class NotificationDismissCommand( - @SerializedName("cmd") val cmd: String = "notification_dismiss", - @SerializedName("id") val notificationId: String + override val requestId: String? = null, + val notificationId: String ) : NodeCommand() data class CallAnswerCommand( - @SerializedName("cmd") val cmd: String = "call_answer", - @SerializedName("call_id") val callId: String, - @SerializedName("greeting") val greeting: String? = null // TTS greeting to play + override val requestId: String? = null, + val callId: String, + val greeting: String? = null // TTS greeting to play ) : NodeCommand() data class CallRejectCommand( - @SerializedName("cmd") val cmd: String = "call_reject", - @SerializedName("call_id") val callId: String, - @SerializedName("reason") val reason: String? = null + override val requestId: String? = null, + val callId: String, + val reason: String? = null ) : NodeCommand() data class CallSilenceCommand( - @SerializedName("cmd") val cmd: String = "call_silence", - @SerializedName("call_id") val callId: String + override val requestId: String? = null, + val callId: String ) : NodeCommand() data class CallSpeakCommand( - @SerializedName("cmd") val cmd: String = "call_speak", - @SerializedName("call_id") val callId: String, - @SerializedName("text") val text: String, // Text to speak via TTS - @SerializedName("voice") val voice: String? = null // TTS voice preference + override val requestId: String? = null, + val callId: String, + val text: String, // Text to speak via TTS + val voice: String? = null // TTS voice preference ) : NodeCommand() data class CallHangupCommand( - @SerializedName("cmd") val cmd: String = "call_hangup", - @SerializedName("call_id") val callId: String + override val requestId: String? = null, + val callId: String ) : NodeCommand() diff --git a/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt b/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt new file mode 100644 index 0000000..c0a3e90 --- /dev/null +++ b/app/src/main/java/com/inou/clawdnode/security/DeviceIdentity.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt b/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt index 9332194..e2e4fa8 100644 --- a/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt +++ b/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt @@ -1,22 +1,35 @@ package com.inou.clawdnode.service 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.protocol.NodeCommand -import com.inou.clawdnode.protocol.NodeEvent +import com.inou.clawdnode.protocol.* +import com.inou.clawdnode.security.DeviceIdentity import kotlinx.coroutines.* import okhttp3.* +import java.util.UUID import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger /** * 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( private val onCommand: (NodeCommand) -> Unit, private val onConnectionChange: (Boolean) -> Unit ) { private val tag = "GatewayClient" + private val gson = Gson() private val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) @@ -27,26 +40,43 @@ class GatewayClient( private var webSocket: WebSocket? = null private var isConnected = false + private var isHandshakeComplete = false private var shouldReconnect = true private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val requestIdCounter = AtomicInteger(0) private val auditLog get() = ClawdNodeApp.instance.auditLog 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() { val url = tokenStore.gatewayUrl ?: run { Log.w(tag, "No gateway URL configured") return } - val token = tokenStore.gatewayToken ?: run { + + if (tokenStore.gatewayToken == null) { Log.w(tag, "No gateway token configured") return } shouldReconnect = true + isHandshakeComplete = false - // Build WebSocket URL with auth - val wsUrl = buildWsUrl(url, token) + // Build WebSocket URL - connect to /ws (not /ws/node) + val wsUrl = buildWsUrl(url) Log.d(tag, "Connecting to $wsUrl") val request = Request.Builder() @@ -55,10 +85,8 @@ class GatewayClient( webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - Log.i(tag, "Connected to Gateway") - isConnected = true - onConnectionChange(true) - auditLog.log("GATEWAY_CONNECTED", "WebSocket connection established") + Log.i(tag, "WebSocket connected, waiting for challenge...") + // Don't set isConnected yet - wait for handshake } override fun onMessage(webSocket: WebSocket, text: String) { @@ -89,43 +117,222 @@ class GatewayClient( webSocket?.close(1000, "Client disconnect") webSocket = null isConnected = false + isHandshakeComplete = false onConnectionChange(false) } fun send(event: NodeEvent) { - val json = event.toJson() + val requestId = generateRequestId() + val json = event.toProtocolFrame(requestId) Log.d(tag, "Sending: $json") - if (isConnected) { + if (isConnected && isHandshakeComplete) { webSocket?.send(json) } else { - Log.w(tag, "Not connected, queuing event") + Log.w(tag, "Not connected or handshake incomplete, cannot send event") // 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) val wsBase = baseUrl .replace("http://", "ws://") .replace("https://", "wss://") .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) { - val command = NodeCommand.fromJson(json) - if (command != null) { - auditLog.logCommand(command::class.simpleName ?: "unknown", "gateway", true) - onCommand(command) - } else { - Log.w(tag, "Unknown command: $json") + val frame = ProtocolFrame.fromJson(json) + if (frame == null) { + Log.w(tag, "Failed to parse frame: $json") + return } + + 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() { isConnected = false + isHandshakeComplete = false onConnectionChange(false) 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 } diff --git a/app/src/main/java/com/inou/clawdnode/service/NodeService.kt b/app/src/main/java/com/inou/clawdnode/service/NodeService.kt index 1b6bc3c..abbf309 100644 --- a/app/src/main/java/com/inou/clawdnode/service/NodeService.kt +++ b/app/src/main/java/com/inou/clawdnode/service/NodeService.kt @@ -97,50 +97,81 @@ class NodeService : Service() { private fun handleCommand(command: NodeCommand) { Log.d(tag, "Handling command: ${command::class.simpleName}") - when (command) { - is ScreenshotCommand -> handleScreenshot() - is NotificationActionCommand -> handleNotificationAction(command) - is NotificationDismissCommand -> handleNotificationDismiss(command) - is CallAnswerCommand -> handleCallAnswer(command) - is CallRejectCommand -> handleCallReject(command) - is CallSilenceCommand -> handleCallSilence(command) - is CallSpeakCommand -> handleCallSpeak(command) - is CallHangupCommand -> handleCallHangup(command) + try { + when (command) { + is ScreenshotCommand -> handleScreenshot(command) + is NotificationActionCommand -> handleNotificationAction(command) + is NotificationDismissCommand -> handleNotificationDismiss(command) + is CallAnswerCommand -> handleCallAnswer(command) + is CallRejectCommand -> handleCallReject(command) + is CallSilenceCommand -> handleCallSilence(command) + is CallSpeakCommand -> handleCallSpeak(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 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) { // Delegate to NotificationListener 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) { NotificationManager.dismiss(cmd.notificationId) + cmd.requestId?.let { + gatewayClient.sendResponse(it, true, payload = mapOf("dismissed" to true)) + } } private fun handleCallAnswer(cmd: CallAnswerCommand) { CallManager.answer(cmd.callId, cmd.greeting) + cmd.requestId?.let { + gatewayClient.sendResponse(it, true, payload = mapOf("answered" to true)) + } } private fun handleCallReject(cmd: CallRejectCommand) { CallManager.reject(cmd.callId, cmd.reason) + cmd.requestId?.let { + gatewayClient.sendResponse(it, true, payload = mapOf("rejected" to true)) + } } private fun handleCallSilence(cmd: CallSilenceCommand) { CallManager.silence(cmd.callId) + cmd.requestId?.let { + gatewayClient.sendResponse(it, true, payload = mapOf("silenced" to true)) + } } private fun handleCallSpeak(cmd: CallSpeakCommand) { 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) { CallManager.hangup(cmd.callId) + cmd.requestId?.let { + gatewayClient.sendResponse(it, true, payload = mapOf("hungup" to true)) + } } // ========================================