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:
parent
16c0fce034
commit
97a095e073
|
|
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue