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:
@@ -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"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user