feat: agent overlay, stop-goal support, and state persistence across app kill

- Add draggable agent overlay pill (status dot + step text + stop button)
  that shows over other apps while connected. Fix ComposeView rendering
  in service context by providing a SavedStateRegistryOwner.
- Add stop_goal protocol message so the overlay/client can abort a
  running agent session; server aborts via AbortController.
- Persist screen-capture consent to SharedPreferences so it survives
  process death; restore on ConnectionService connect and Settings resume.
- Query AccessibilityManager for real service state instead of relying
  on in-process MutableStateFlow that resets on restart.
- Add overlay permission checklist item and SYSTEM_ALERT_WINDOW manifest
  entry.
- Filter DroidClaw's own overlay nodes from the accessibility tree so the
  agent never interacts with them.
This commit is contained in:
Somasundaram Mahesh
2026-02-18 18:49:13 +05:30
parent 88af77ddc7
commit 011e2be291
11 changed files with 389 additions and 9 deletions

View File

@@ -11,6 +11,7 @@
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" /> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application <application
android:name=".DroidClawApp" android:name=".DroidClawApp"

View File

@@ -1,8 +1,11 @@
package com.thisux.droidclaw.accessibility package com.thisux.droidclaw.accessibility
import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityService
import android.content.ComponentName
import android.content.Context
import android.util.Log import android.util.Log
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityManager
import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityNodeInfo
import com.thisux.droidclaw.model.UIElement import com.thisux.droidclaw.model.UIElement
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -16,6 +19,15 @@ class DroidClawAccessibilityService : AccessibilityService() {
val isRunning = MutableStateFlow(false) val isRunning = MutableStateFlow(false)
val lastScreenTree = MutableStateFlow<List<UIElement>>(emptyList()) val lastScreenTree = MutableStateFlow<List<UIElement>>(emptyList())
var instance: DroidClawAccessibilityService? = null var instance: DroidClawAccessibilityService? = null
fun isEnabledOnDevice(context: Context): Boolean {
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val ourComponent = ComponentName(context, DroidClawAccessibilityService::class.java)
return am.getEnabledAccessibilityServiceList(AccessibilityEvent.TYPES_ALL_MASK)
.any { it.resolveInfo.serviceInfo.let { si ->
ComponentName(si.packageName, si.name) == ourComponent
}}
}
} }
override fun onServiceConnected() { override fun onServiceConnected() {

View File

@@ -21,6 +21,9 @@ object ScreenTreeBuilder {
parentDesc: String parentDesc: String
) { ) {
try { try {
// Skip DroidClaw's own overlay nodes so the agent never sees them
if (node.packageName?.toString() == "com.thisux.droidclaw") return
val rect = Rect() val rect = Rect()
node.getBoundsInScreen(rect) node.getBoundsInScreen(rect)

View File

@@ -22,6 +22,9 @@ class ScreenCaptureManager(private val context: Context) {
companion object { companion object {
private const val TAG = "ScreenCapture" private const val TAG = "ScreenCapture"
private const val PREFS_NAME = "screen_capture"
private const val KEY_RESULT_CODE = "consent_result_code"
private const val KEY_CONSENT_URI = "consent_data_uri"
val isAvailable = MutableStateFlow(false) val isAvailable = MutableStateFlow(false)
// Stores MediaProjection consent for use by ConnectionService // Stores MediaProjection consent for use by ConnectionService
@@ -37,6 +40,37 @@ class ScreenCaptureManager(private val context: Context) {
hasConsentState.value = (resultCode == Activity.RESULT_OK && data != null) hasConsentState.value = (resultCode == Activity.RESULT_OK && data != null)
} }
fun storeConsent(context: Context, resultCode: Int, data: Intent?) {
storeConsent(resultCode, data)
if (resultCode == Activity.RESULT_OK && data != null) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
.putInt(KEY_RESULT_CODE, resultCode)
.putString(KEY_CONSENT_URI, data.toUri(0))
.apply()
}
}
fun restoreConsent(context: Context) {
if (consentResultCode != null && consentData != null) return
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val code = prefs.getInt(KEY_RESULT_CODE, 0)
val uri = prefs.getString(KEY_CONSENT_URI, null)
if (code == Activity.RESULT_OK && uri != null) {
consentResultCode = code
consentData = Intent.parseUri(uri, 0)
hasConsentState.value = true
}
}
fun clearConsent(context: Context) {
consentResultCode = null
consentData = null
hasConsentState.value = false
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
.clear()
.apply()
}
fun hasConsent(): Boolean = consentResultCode != null && consentData != null fun hasConsent(): Boolean = consentResultCode != null && consentData != null
} }

View File

@@ -27,6 +27,9 @@ import com.thisux.droidclaw.model.InstalledAppInfo
import com.thisux.droidclaw.util.DeviceInfoHelper import com.thisux.droidclaw.util.DeviceInfoHelper
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.provider.Settings
import com.thisux.droidclaw.model.StopGoalMessage
import com.thisux.droidclaw.overlay.AgentOverlay
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -56,11 +59,13 @@ class ConnectionService : LifecycleService() {
private var commandRouter: CommandRouter? = null private var commandRouter: CommandRouter? = null
private var captureManager: ScreenCaptureManager? = null private var captureManager: ScreenCaptureManager? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var overlay: AgentOverlay? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
createNotificationChannel() createNotificationChannel()
overlay = AgentOverlay(this)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -97,6 +102,7 @@ class ConnectionService : LifecycleService() {
return@launch return@launch
} }
ScreenCaptureManager.restoreConsent(this@ConnectionService)
captureManager = ScreenCaptureManager(this@ConnectionService).also { mgr -> captureManager = ScreenCaptureManager(this@ConnectionService).also { mgr ->
if (ScreenCaptureManager.hasConsent()) { if (ScreenCaptureManager.hasConsent()) {
try { try {
@@ -105,7 +111,8 @@ class ConnectionService : LifecycleService() {
ScreenCaptureManager.consentData!! ScreenCaptureManager.consentData!!
) )
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.w(TAG, "Screen capture unavailable (needs mediaProjection service type): ${e.message}") Log.w(TAG, "Screen capture unavailable: ${e.message}")
ScreenCaptureManager.clearConsent(this@ConnectionService)
} }
} }
} }
@@ -131,6 +138,9 @@ class ConnectionService : LifecycleService() {
) )
// Send installed apps list once connected // Send installed apps list once connected
if (state == ConnectionState.Connected) { if (state == ConnectionState.Connected) {
if (Settings.canDrawOverlays(this@ConnectionService)) {
overlay?.show()
}
val apps = getInstalledApps() val apps = getInstalledApps()
webSocket?.sendTyped(AppsMessage(apps = apps)) webSocket?.sendTyped(AppsMessage(apps = apps))
Log.i(TAG, "Sent ${apps.size} installed apps to server") Log.i(TAG, "Sent ${apps.size} installed apps to server")
@@ -167,7 +177,12 @@ class ConnectionService : LifecycleService() {
webSocket?.sendTyped(GoalMessage(text = text)) webSocket?.sendTyped(GoalMessage(text = text))
} }
fun stopGoal() {
webSocket?.sendTyped(StopGoalMessage())
}
private fun disconnect() { private fun disconnect() {
overlay?.hide()
webSocket?.disconnect() webSocket?.disconnect()
webSocket = null webSocket = null
commandRouter?.reset() commandRouter?.reset()
@@ -179,6 +194,8 @@ class ConnectionService : LifecycleService() {
} }
override fun onDestroy() { override fun onDestroy() {
overlay?.destroy()
overlay = null
disconnect() disconnect()
instance = null instance = null
super.onDestroy() super.onDestroy()

View File

@@ -71,6 +71,11 @@ data class AppsMessage(
val apps: List<InstalledAppInfo> val apps: List<InstalledAppInfo>
) )
@Serializable
data class StopGoalMessage(
val type: String = "stop_goal"
)
@Serializable @Serializable
data class ServerMessage( data class ServerMessage(
val type: String, val type: String,

View File

@@ -0,0 +1,92 @@
package com.thisux.droidclaw.overlay
import android.graphics.PixelFormat
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
class AgentOverlay(private val service: LifecycleService) {
private val windowManager = service.getSystemService(WindowManager::class.java)
private var composeView: ComposeView? = null
private val savedStateOwner = object : SavedStateRegistryOwner {
private val controller = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle get() = service.lifecycle
override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry
init { controller.performRestore(null) }
}
private val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
x = 0
y = 200
}
fun show() {
if (composeView != null) return
val view = ComposeView(service).apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
setViewTreeLifecycleOwner(service)
setViewTreeSavedStateRegistryOwner(savedStateOwner)
setContent { OverlayContent() }
setupDrag(this)
}
composeView = view
windowManager.addView(view, layoutParams)
}
fun hide() {
composeView?.let {
windowManager.removeView(it)
}
composeView = null
}
fun destroy() {
hide()
}
private fun setupDrag(view: View) {
var initialX = 0
var initialY = 0
var initialTouchX = 0f
var initialTouchY = 0f
view.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = layoutParams.x
initialY = layoutParams.y
initialTouchX = event.rawX
initialTouchY = event.rawY
true
}
MotionEvent.ACTION_MOVE -> {
layoutParams.x = initialX + (event.rawX - initialTouchX).toInt()
layoutParams.y = initialY + (event.rawY - initialTouchY).toInt()
windowManager.updateViewLayout(view, layoutParams)
true
}
else -> false
}
}
}
}

View File

@@ -0,0 +1,165 @@
package com.thisux.droidclaw.overlay
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.thisux.droidclaw.connection.ConnectionService
import com.thisux.droidclaw.model.ConnectionState
import com.thisux.droidclaw.model.GoalStatus
import com.thisux.droidclaw.ui.theme.DroidClawTheme
import kotlinx.coroutines.delay
private val Green = Color(0xFF4CAF50)
private val Blue = Color(0xFF2196F3)
private val Red = Color(0xFFF44336)
private val Gray = Color(0xFF9E9E9E)
private val PillBackground = Color(0xE6212121)
@Composable
fun OverlayContent() {
DroidClawTheme {
val connectionState by ConnectionService.connectionState.collectAsState()
val goalStatus by ConnectionService.currentGoalStatus.collectAsState()
val steps by ConnectionService.currentSteps.collectAsState()
// Auto-reset Completed/Failed back to Idle after 3s
var displayStatus by remember { mutableStateOf(goalStatus) }
LaunchedEffect(goalStatus) {
displayStatus = goalStatus
if (goalStatus == GoalStatus.Completed || goalStatus == GoalStatus.Failed) {
delay(3000)
displayStatus = GoalStatus.Idle
}
}
val isConnected = connectionState == ConnectionState.Connected
val dotColor by animateColorAsState(
targetValue = when {
!isConnected -> Gray
displayStatus == GoalStatus.Running -> Blue
displayStatus == GoalStatus.Failed -> Red
else -> Green
},
label = "dotColor"
)
val statusText = when {
!isConnected -> "Offline"
displayStatus == GoalStatus.Running -> {
val last = steps.lastOrNull()
if (last != null) "Step ${last.step}: ${last.action}" else "Running..."
}
displayStatus == GoalStatus.Completed -> "Done"
displayStatus == GoalStatus.Failed -> "Stopped"
else -> "Ready"
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(PillBackground)
.height(48.dp)
.widthIn(min = 100.dp, max = 220.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
StatusDot(
color = dotColor,
pulse = isConnected && displayStatus == GoalStatus.Running
)
Text(
text = statusText,
color = Color.White,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
if (isConnected && displayStatus == GoalStatus.Running) {
IconButton(
onClick = { ConnectionService.instance?.stopGoal() },
modifier = Modifier.size(28.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = Color.White.copy(alpha = 0.8f)
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Stop goal",
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
@Composable
private fun StatusDot(color: Color, pulse: Boolean) {
if (pulse) {
val transition = rememberInfiniteTransition(label = "pulse")
val alpha by transition.animateFloat(
initialValue = 1f,
targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulseAlpha"
)
Box(
modifier = Modifier
.size(10.dp)
.alpha(alpha)
.clip(CircleShape)
.background(color)
)
} else {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(color)
)
}
}

View File

@@ -2,7 +2,10 @@ package com.thisux.droidclaw.ui.screens
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -62,18 +65,27 @@ fun SettingsScreen() {
var editingServerUrl by remember { mutableStateOf<String?>(null) } var editingServerUrl by remember { mutableStateOf<String?>(null) }
val displayServerUrl = editingServerUrl ?: serverUrl val displayServerUrl = editingServerUrl ?: serverUrl
val isAccessibilityEnabled by DroidClawAccessibilityService.isRunning.collectAsState()
val isCaptureAvailable by ScreenCaptureManager.isAvailable.collectAsState() val isCaptureAvailable by ScreenCaptureManager.isAvailable.collectAsState()
val hasConsent by ScreenCaptureManager.hasConsentState.collectAsState()
val hasCaptureConsent = isCaptureAvailable || hasConsent
var isAccessibilityEnabled by remember {
mutableStateOf(DroidClawAccessibilityService.isEnabledOnDevice(context))
}
var hasCaptureConsent by remember {
ScreenCaptureManager.restoreConsent(context)
mutableStateOf(isCaptureAvailable || ScreenCaptureManager.hasConsent())
}
var isBatteryExempt by remember { mutableStateOf(BatteryOptimization.isIgnoringBatteryOptimizations(context)) } var isBatteryExempt by remember { mutableStateOf(BatteryOptimization.isIgnoringBatteryOptimizations(context)) }
var hasOverlayPermission by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
isAccessibilityEnabled = DroidClawAccessibilityService.isEnabledOnDevice(context)
ScreenCaptureManager.restoreConsent(context)
hasCaptureConsent = isCaptureAvailable || ScreenCaptureManager.hasConsent()
isBatteryExempt = BatteryOptimization.isIgnoringBatteryOptimizations(context) isBatteryExempt = BatteryOptimization.isIgnoringBatteryOptimizations(context)
hasOverlayPermission = Settings.canDrawOverlays(context)
} }
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
@@ -84,7 +96,8 @@ fun SettingsScreen() {
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { result -> ) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) { if (result.resultCode == Activity.RESULT_OK && result.data != null) {
ScreenCaptureManager.storeConsent(result.resultCode, result.data) ScreenCaptureManager.storeConsent(context, result.resultCode, result.data)
hasCaptureConsent = true
} }
} }
@@ -172,6 +185,20 @@ fun SettingsScreen() {
actionLabel = "Disable", actionLabel = "Disable",
onAction = { BatteryOptimization.requestExemption(context) } onAction = { BatteryOptimization.requestExemption(context) }
) )
ChecklistItem(
label = "Overlay permission",
isOk = hasOverlayPermission,
actionLabel = "Grant",
onAction = {
context.startActivity(
Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
)
)
}
)
} }
} }

View File

@@ -7,7 +7,8 @@ export type DeviceMessage =
| { type: "goal"; text: string } | { type: "goal"; text: string }
| { type: "pong" } | { type: "pong" }
| { type: "heartbeat"; batteryLevel: number; isCharging: boolean } | { type: "heartbeat"; batteryLevel: number; isCharging: boolean }
| { type: "apps"; apps: InstalledApp[] }; | { type: "apps"; apps: InstalledApp[] }
| { type: "stop_goal" };
export type ServerToDeviceMessage = export type ServerToDeviceMessage =
| { type: "auth_ok"; deviceId: string } | { type: "auth_ok"; deviceId: string }

View File

@@ -22,7 +22,7 @@ async function hashApiKey(key: string): Promise<string> {
} }
/** Track running agent sessions to prevent duplicates per device */ /** Track running agent sessions to prevent duplicates per device */
const activeSessions = new Map<string, string>(); const activeSessions = new Map<string, { goal: string; abort: AbortController }>();
/** /**
* Send a JSON message to a device WebSocket (safe — catches send errors). * Send a JSON message to a device WebSocket (safe — catches send errors).
@@ -252,7 +252,8 @@ export async function handleDeviceMessage(
} }
console.log(`[Pipeline] Starting goal for device ${deviceId}: ${goal}`); console.log(`[Pipeline] Starting goal for device ${deviceId}: ${goal}`);
activeSessions.set(deviceId, goal); const abortController = new AbortController();
activeSessions.set(deviceId, { goal, abort: abortController });
sendToDevice(ws, { type: "goal_started", sessionId: deviceId, goal }); sendToDevice(ws, { type: "goal_started", sessionId: deviceId, goal });
@@ -262,6 +263,7 @@ export async function handleDeviceMessage(
userId, userId,
goal, goal,
llmConfig: userLlmConfig, llmConfig: userLlmConfig,
signal: abortController.signal,
onStep(step) { onStep(step) {
sendToDevice(ws, { sendToDevice(ws, {
type: "step", type: "step",
@@ -294,6 +296,23 @@ export async function handleDeviceMessage(
break; break;
} }
case "stop_goal": {
const deviceId = ws.data.deviceId!;
const active = activeSessions.get(deviceId);
if (active) {
console.log(`[Pipeline] Stop requested for device ${deviceId}`);
active.abort.abort();
activeSessions.delete(deviceId);
sendToDevice(ws, {
type: "goal_completed",
sessionId: deviceId,
success: false,
stepsUsed: 0,
});
}
break;
}
case "apps": { case "apps": {
const persistentDeviceId = ws.data.persistentDeviceId; const persistentDeviceId = ws.data.persistentDeviceId;
if (persistentDeviceId) { if (persistentDeviceId) {
@@ -360,7 +379,11 @@ export function handleDeviceClose(
const { deviceId, userId, persistentDeviceId } = ws.data; const { deviceId, userId, persistentDeviceId } = ws.data;
if (!deviceId) return; if (!deviceId) return;
const active = activeSessions.get(deviceId);
if (active) {
active.abort.abort();
activeSessions.delete(deviceId); activeSessions.delete(deviceId);
}
sessions.removeDevice(deviceId); sessions.removeDevice(deviceId);
// Update device status in DB // Update device status in DB