feat(android): add ReliableWebSocket, CommandRouter, ConnectionService

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sanju Sivalingam
2026-02-17 17:51:19 +05:30
parent ac7fc85891
commit 516b83bd0f
4 changed files with 522 additions and 7 deletions

View File

@@ -0,0 +1,129 @@
package com.thisux.droidclaw.connection
import android.util.Base64
import android.util.Log
import com.thisux.droidclaw.accessibility.DroidClawAccessibilityService
import com.thisux.droidclaw.accessibility.GestureExecutor
import com.thisux.droidclaw.capture.ScreenCaptureManager
import com.thisux.droidclaw.model.AgentStep
import com.thisux.droidclaw.model.GoalStatus
import com.thisux.droidclaw.model.PongMessage
import com.thisux.droidclaw.model.ResultResponse
import com.thisux.droidclaw.model.ScreenResponse
import com.thisux.droidclaw.model.ServerMessage
import kotlinx.coroutines.flow.MutableStateFlow
class CommandRouter(
private val webSocket: ReliableWebSocket,
private val captureManager: ScreenCaptureManager?
) {
companion object {
private const val TAG = "CommandRouter"
}
val currentGoalStatus = MutableStateFlow(GoalStatus.Idle)
val currentSteps = MutableStateFlow<List<AgentStep>>(emptyList())
val currentGoal = MutableStateFlow("")
val currentSessionId = MutableStateFlow<String?>(null)
private var gestureExecutor: GestureExecutor? = null
fun updateGestureExecutor() {
val svc = DroidClawAccessibilityService.instance
gestureExecutor = if (svc != null) GestureExecutor(svc) else null
}
suspend fun handleMessage(msg: ServerMessage) {
Log.d(TAG, "Handling: ${msg.type}")
when (msg.type) {
"get_screen" -> handleGetScreen(msg.requestId!!)
"ping" -> webSocket.sendTyped(PongMessage())
"tap", "type", "enter", "back", "home", "notifications",
"longpress", "swipe", "launch", "clear", "clipboard_set",
"clipboard_get", "paste", "open_url", "switch_app",
"keyevent", "open_settings", "wait" -> handleAction(msg)
"goal_started" -> {
currentSessionId.value = msg.sessionId
currentGoal.value = msg.goal ?: ""
currentGoalStatus.value = GoalStatus.Running
currentSteps.value = emptyList()
Log.i(TAG, "Goal started: ${msg.goal}")
}
"step" -> {
val step = AgentStep(
step = msg.step ?: 0,
action = msg.action?.toString() ?: "",
reasoning = msg.reasoning ?: ""
)
currentSteps.value = currentSteps.value + step
Log.d(TAG, "Step ${step.step}: ${step.reasoning}")
}
"goal_completed" -> {
currentGoalStatus.value = if (msg.success == true) GoalStatus.Completed else GoalStatus.Failed
Log.i(TAG, "Goal completed: success=${msg.success}, steps=${msg.stepsUsed}")
}
else -> Log.w(TAG, "Unknown message type: ${msg.type}")
}
}
private fun handleGetScreen(requestId: String) {
updateGestureExecutor()
val svc = DroidClawAccessibilityService.instance
val elements = svc?.getScreenTree() ?: emptyList()
val packageName = try {
svc?.rootInActiveWindow?.packageName?.toString()
} catch (_: Exception) { null }
var screenshot: String? = null
if (elements.isEmpty()) {
val bytes = captureManager?.capture()
if (bytes != null) {
screenshot = Base64.encodeToString(bytes, Base64.NO_WRAP)
}
}
val response = ScreenResponse(
requestId = requestId,
elements = elements,
screenshot = screenshot,
packageName = packageName
)
webSocket.sendTyped(response)
}
private suspend fun handleAction(msg: ServerMessage) {
updateGestureExecutor()
val executor = gestureExecutor
if (executor == null) {
webSocket.sendTyped(
ResultResponse(
requestId = msg.requestId!!,
success = false,
error = "Accessibility service not running"
)
)
return
}
val result = executor.execute(msg)
webSocket.sendTyped(
ResultResponse(
requestId = msg.requestId!!,
success = result.success,
error = result.error,
data = result.data
)
)
}
fun reset() {
currentGoalStatus.value = GoalStatus.Idle
currentSteps.value = emptyList()
currentGoal.value = ""
currentSessionId.value = null
}
}

View File

@@ -1,18 +1,215 @@
package com.thisux.droidclaw.connection package com.thisux.droidclaw.connection
import android.app.Service import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.thisux.droidclaw.DroidClawApp
import com.thisux.droidclaw.MainActivity
import com.thisux.droidclaw.R
import com.thisux.droidclaw.capture.ScreenCaptureManager
import com.thisux.droidclaw.model.ConnectionState
import com.thisux.droidclaw.model.GoalMessage
import com.thisux.droidclaw.model.GoalStatus
import com.thisux.droidclaw.model.AgentStep
import com.thisux.droidclaw.util.DeviceInfoHelper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/** class ConnectionService : LifecycleService() {
* Foreground service for maintaining the WebSocket connection to the DroidClaw server.
* Full implementation will be added in Task 9.
*/
class ConnectionService : Service() {
override fun onBind(intent: Intent?): IBinder? = null companion object {
private const val TAG = "ConnectionSvc"
private const val CHANNEL_ID = "droidclaw_connection"
private const val NOTIFICATION_ID = 1
val connectionState = MutableStateFlow(ConnectionState.Disconnected)
val currentSteps = MutableStateFlow<List<AgentStep>>(emptyList())
val currentGoalStatus = MutableStateFlow(GoalStatus.Idle)
val currentGoal = MutableStateFlow("")
val errorMessage = MutableStateFlow<String?>(null)
var instance: ConnectionService? = null
const val ACTION_CONNECT = "com.thisux.droidclaw.CONNECT"
const val ACTION_DISCONNECT = "com.thisux.droidclaw.DISCONNECT"
const val ACTION_SEND_GOAL = "com.thisux.droidclaw.SEND_GOAL"
const val EXTRA_GOAL = "goal_text"
}
private var webSocket: ReliableWebSocket? = null
private var commandRouter: CommandRouter? = null
private var captureManager: ScreenCaptureManager? = null
private var wakeLock: PowerManager.WakeLock? = null
override fun onCreate() {
super.onCreate()
instance = this
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
ACTION_CONNECT -> {
startForeground(NOTIFICATION_ID, buildNotification("Connecting..."))
connect()
}
ACTION_DISCONNECT -> {
disconnect()
stopSelf()
}
ACTION_SEND_GOAL -> {
val goal = intent.getStringExtra(EXTRA_GOAL) ?: return START_NOT_STICKY
sendGoal(goal)
}
}
return START_NOT_STICKY return START_NOT_STICKY
} }
private fun connect() {
lifecycleScope.launch {
val app = application as DroidClawApp
val apiKey = app.settingsStore.apiKey.first()
val serverUrl = app.settingsStore.serverUrl.first()
if (apiKey.isBlank() || serverUrl.isBlank()) {
connectionState.value = ConnectionState.Error
errorMessage.value = "API key or server URL not configured"
stopSelf()
return@launch
}
captureManager = ScreenCaptureManager(this@ConnectionService)
val ws = ReliableWebSocket(lifecycleScope) { msg ->
commandRouter?.handleMessage(msg)
}
webSocket = ws
val router = CommandRouter(ws, captureManager)
commandRouter = router
launch {
ws.state.collect { state ->
connectionState.value = state
updateNotification(
when (state) {
ConnectionState.Connected -> "Connected to server"
ConnectionState.Connecting -> "Connecting..."
ConnectionState.Error -> "Connection error"
ConnectionState.Disconnected -> "Disconnected"
}
)
}
}
launch { ws.errorMessage.collect { errorMessage.value = it } }
launch { router.currentSteps.collect { currentSteps.value = it } }
launch { router.currentGoalStatus.collect { currentGoalStatus.value = it } }
launch { router.currentGoal.collect { currentGoal.value = it } }
acquireWakeLock()
val deviceInfo = DeviceInfoHelper.get(this@ConnectionService)
ws.connect(serverUrl, apiKey, deviceInfo)
}
}
private fun sendGoal(text: String) {
webSocket?.sendTyped(GoalMessage(text = text))
}
private fun disconnect() {
webSocket?.disconnect()
webSocket = null
commandRouter?.reset()
commandRouter = null
captureManager?.release()
captureManager = null
releaseWakeLock()
connectionState.value = ConnectionState.Disconnected
}
override fun onDestroy() {
disconnect()
instance = null
super.onDestroy()
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return null
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"DroidClaw Connection",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows when DroidClaw is connected to the server"
}
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}
}
private fun buildNotification(text: String): Notification {
val openIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
val disconnectIntent = PendingIntent.getService(
this, 1,
Intent(this, ConnectionService::class.java).apply {
action = ACTION_DISCONNECT
},
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("DroidClaw")
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setOngoing(true)
.setContentIntent(openIntent)
.addAction(0, "Disconnect", disconnectIntent)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIFICATION_ID, buildNotification(text))
}
private fun acquireWakeLock() {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"DroidClaw::ConnectionWakeLock"
).apply {
acquire(10 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) it.release()
}
wakeLock = null
}
} }

View File

@@ -0,0 +1,168 @@
package com.thisux.droidclaw.connection
import android.util.Log
import com.thisux.droidclaw.model.AuthMessage
import com.thisux.droidclaw.model.ConnectionState
import com.thisux.droidclaw.model.DeviceInfoMsg
import com.thisux.droidclaw.model.ServerMessage
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class ReliableWebSocket(
private val scope: CoroutineScope,
private val onMessage: suspend (ServerMessage) -> Unit
) {
companion object {
private const val TAG = "ReliableWS"
private const val MAX_BACKOFF_MS = 30_000L
}
@PublishedApi
internal val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val _state = MutableStateFlow(ConnectionState.Disconnected)
val state: StateFlow<ConnectionState> = _state
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage
private val outbound = Channel<String>(Channel.BUFFERED)
private var connectionJob: Job? = null
private var client: HttpClient? = null
private var backoffMs = 1000L
private var shouldReconnect = true
var deviceId: String? = null
private set
fun connect(serverUrl: String, apiKey: String, deviceInfo: DeviceInfoMsg) {
shouldReconnect = true
connectionJob?.cancel()
connectionJob = scope.launch {
while (shouldReconnect && isActive) {
try {
_state.value = ConnectionState.Connecting
_errorMessage.value = null
connectOnce(serverUrl, apiKey, deviceInfo)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Connection failed: ${e.message}")
_state.value = ConnectionState.Error
_errorMessage.value = e.message
}
if (shouldReconnect && isActive) {
Log.i(TAG, "Reconnecting in ${backoffMs}ms")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
}
}
}
}
private suspend fun connectOnce(serverUrl: String, apiKey: String, deviceInfo: DeviceInfoMsg) {
val httpClient = HttpClient(CIO) {
install(WebSockets) {
pingIntervalMillis = 30_000
}
}
client = httpClient
val wsUrl = serverUrl.trimEnd('/') + "/ws/device"
httpClient.webSocket(wsUrl) {
// Auth handshake
val authMsg = AuthMessage(apiKey = apiKey, deviceInfo = deviceInfo)
send(Frame.Text(json.encodeToString(authMsg)))
Log.i(TAG, "Sent auth message")
// Wait for auth response
val authFrame = incoming.receive() as? Frame.Text
?: throw Exception("Expected text frame for auth response")
val authResponse = json.decodeFromString<ServerMessage>(authFrame.readText())
when (authResponse.type) {
"auth_ok" -> {
deviceId = authResponse.deviceId
_state.value = ConnectionState.Connected
_errorMessage.value = null
backoffMs = 1000L
Log.i(TAG, "Authenticated, deviceId=$deviceId")
}
"auth_error" -> {
shouldReconnect = false
_state.value = ConnectionState.Error
_errorMessage.value = authResponse.message ?: "Authentication failed"
close()
return@webSocket
}
else -> {
throw Exception("Unexpected auth response: ${authResponse.type}")
}
}
// Launch outbound sender
val senderJob = launch {
for (msg in outbound) {
send(Frame.Text(msg))
}
}
// Read incoming messages
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val msg = json.decodeFromString<ServerMessage>(text)
onMessage(msg)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message: ${e.message}")
}
}
}
} finally {
senderJob.cancel()
}
}
httpClient.close()
client = null
_state.value = ConnectionState.Disconnected
}
fun send(message: String) {
outbound.trySend(message)
}
inline fun <reified T> sendTyped(message: T) {
send(json.encodeToString(message))
}
fun disconnect() {
shouldReconnect = false
connectionJob?.cancel()
connectionJob = null
client?.close()
client = null
_state.value = ConnectionState.Disconnected
_errorMessage.value = null
deviceId = null
}
}

View File

@@ -0,0 +1,21 @@
package com.thisux.droidclaw.util
import android.content.Context
import android.util.DisplayMetrics
import android.view.WindowManager
import com.thisux.droidclaw.model.DeviceInfoMsg
object DeviceInfoHelper {
fun get(context: Context): DeviceInfoMsg {
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(metrics)
return DeviceInfoMsg(
model = android.os.Build.MODEL,
androidVersion = android.os.Build.VERSION.RELEASE,
screenWidth = metrics.widthPixels,
screenHeight = metrics.heightPixels
)
}
}