clawdnode-android/app/src/main/java/com/inou/clawdnode/service/GatewayClient.kt

390 lines
13 KiB
Kotlin

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.Protocol
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.
*
* 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,
var onLog: ((String) -> Unit)? = null
) {
private fun log(message: String) {
Log.d(tag, message)
onLog?.invoke(message)
}
private fun logError(message: String, t: Throwable? = null) {
if (t != null) {
Log.e(tag, message, t)
onLog?.invoke("ERROR: $message - ${t.message}")
} else {
Log.e(tag, message)
onLog?.invoke("ERROR: $message")
}
}
private val tag = "GatewayClient"
private val gson = Gson()
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // No timeout for WebSocket
.writeTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.build()
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("No gateway URL configured")
return
}
if (tokenStore.gatewayToken == null) {
log("No gateway token configured")
return
}
shouldReconnect = true
isHandshakeComplete = false
// Build WebSocket URL - connect to /ws (not /ws/node)
val wsUrl = buildWsUrl(url)
log("Connecting to $wsUrl")
val request = Request.Builder()
.url(wsUrl)
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
log("WebSocket connected, waiting for challenge...")
// Don't set isConnected yet - wait for handshake
}
override fun onMessage(webSocket: WebSocket, text: String) {
log("Received: $text")
handleMessage(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
log("Connection closing: $code $reason")
webSocket.close(1000, null)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
log("Connection closed: $code $reason")
handleDisconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
logError("Connection failed", t)
auditLog.log("GATEWAY_ERROR", "Connection failed: ${t.message}")
handleDisconnect()
}
})
}
fun disconnect() {
shouldReconnect = false
webSocket?.close(1000, "Client disconnect")
webSocket = null
isConnected = false
isHandshakeComplete = false
onConnectionChange(false)
}
fun send(event: NodeEvent) {
val requestId = generateRequestId()
val json = event.toProtocolFrame(requestId)
log("Sending: $json")
if (isConnected && isHandshakeComplete) {
webSocket?.send(json)
} else {
log("Not connected or handshake incomplete, cannot send event")
// TODO: Queue for retry
}
}
/**
* 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("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('/')
// Connect to /ws endpoint (no token in URL - sent in handshake)
return "$wsBase/ws"
}
private fun handleMessage(json: String) {
val frame = ProtocolFrame.fromJson(json)
if (frame == null) {
log("Failed to parse frame: $json")
return
}
when (frame.type) {
"event" -> handleEvent(frame)
"req" -> handleRequest(frame)
"res" -> handleResponse(frame)
else -> log("Unknown frame type: ${frame.type}")
}
}
private fun handleEvent(frame: ProtocolFrame) {
when (frame.event) {
"connect.challenge" -> handleConnectChallenge(frame)
else -> log("Received event: ${frame.event}")
}
}
private fun handleConnectChallenge(frame: ProtocolFrame) {
log("Received connect.challenge, sending handshake...")
val payload = frame.payload
val nonce = payload?.get("nonce")?.asString
if (nonce == null) {
logError("No nonce in connect.challenge")
return
}
val token = tokenStore.gatewayToken ?: run {
logError("No token available for handshake")
return
}
// Sign the challenge nonce and get device identity
// Wrap in try/catch - keystore operations can fail on first run or if hardware unavailable
val signedChallenge: DeviceIdentity.SignedChallenge?
val deviceId: String
val publicKey: String?
try {
deviceId = deviceIdentity.deviceId
signedChallenge = deviceIdentity.signChallenge(nonce)
publicKey = deviceIdentity.publicKey
log("Device identity ready: id=${deviceId.take(8)}..., signed challenge")
} catch (e: Exception) {
logError("Failed to initialize device identity or sign challenge", e)
// Cannot proceed without device identity for non-local connections
webSocket?.close(1000, "Device identity initialization failed: ${e.message}")
return
}
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 = deviceId,
publicKey = publicKey,
signature = signedChallenge.signature,
signedAt = signedChallenge.signedAt,
nonce = signedChallenge.nonce
)
)
)
val requestJson = connectRequest.toJson()
log("Sending connect request: $requestJson")
webSocket?.send(requestJson)
}
private fun handleRequest(frame: ProtocolFrame) {
when (frame.method) {
"node.invoke" -> handleNodeInvoke(frame)
else -> {
log("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 {
logError("node.invoke missing request id")
return
}
val params = frame.params ?: run {
logError("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) {
logError("node.invoke missing command")
sendResponse(requestId, false, error = "Missing command")
return
}
log("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("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
logError("Request failed: $error")
}
}
private fun handleHelloOk(frame: ProtocolFrame) {
log("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("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) {
auditLog.log("GATEWAY_RECONNECT", "Scheduling reconnect in 5s")
scope.launch {
delay(5000)
if (shouldReconnect) {
connect()
}
}
}
}
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
}