feat: installed apps, stop goal, auth fixes, remote commands
- Android: fetch installed apps via PackageManager, send to server on connect - Android: add QUERY_ALL_PACKAGES permission for full app visibility - Android: fix duplicate Intent import, increase accessibility retry window - Android: default server URL to ws:// instead of wss:// - Server: store installed apps in device metadata JSONB - Server: inject installed apps context into LLM prompt - Server: preprocessor resolves app names from device's actual installed apps - Server: add POST /goals/stop endpoint with AbortController cancellation - Server: rewrite session middleware to direct DB token lookup - Server: goals route fetches user's saved LLM config from DB - Web: show installed apps in device detail Overview tab with search - Web: add Stop button for running goals - Web: replace API routes with remote commands (submitGoal, stopGoal) - Web: add error display for goal submission failures - Shared: add InstalledApp type and apps message to protocol
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
|
||||
<application
|
||||
android:name=".DroidClawApp"
|
||||
|
||||
@@ -41,12 +41,21 @@ class DroidClawAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
fun getScreenTree(): List<UIElement> {
|
||||
val delays = longArrayOf(50, 100, 200)
|
||||
// Retry with increasing delays — apps like Contacts on Vivo
|
||||
// can take 500ms+ to render after a cold launch
|
||||
val delays = longArrayOf(50, 100, 200, 300, 500)
|
||||
for (delayMs in delays) {
|
||||
val root = rootInActiveWindow
|
||||
if (root != null) {
|
||||
try {
|
||||
val elements = ScreenTreeBuilder.capture(root)
|
||||
// If we got a root but zero elements, the app may still be loading.
|
||||
// Retry unless this is the last attempt.
|
||||
if (elements.isEmpty() && delayMs < delays.last()) {
|
||||
root.recycle()
|
||||
runBlocking { delay(delayMs) }
|
||||
continue
|
||||
}
|
||||
lastScreenTree.value = elements
|
||||
return elements
|
||||
} finally {
|
||||
@@ -55,7 +64,7 @@ class DroidClawAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
runBlocking { delay(delayMs) }
|
||||
}
|
||||
Log.w(TAG, "rootInActiveWindow null after retries")
|
||||
Log.w(TAG, "rootInActiveWindow null or empty after retries")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import com.thisux.droidclaw.model.GoalMessage
|
||||
import com.thisux.droidclaw.model.GoalStatus
|
||||
import com.thisux.droidclaw.model.AgentStep
|
||||
import com.thisux.droidclaw.model.HeartbeatMessage
|
||||
import com.thisux.droidclaw.model.AppsMessage
|
||||
import com.thisux.droidclaw.model.InstalledAppInfo
|
||||
import com.thisux.droidclaw.util.DeviceInfoHelper
|
||||
import android.content.pm.PackageManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -125,6 +128,12 @@ class ConnectionService : LifecycleService() {
|
||||
ConnectionState.Disconnected -> "Disconnected"
|
||||
}
|
||||
)
|
||||
// Send installed apps list once connected
|
||||
if (state == ConnectionState.Connected) {
|
||||
val apps = getInstalledApps()
|
||||
webSocket?.sendTyped(AppsMessage(apps = apps))
|
||||
Log.i(TAG, "Sent ${apps.size} installed apps to server")
|
||||
}
|
||||
}
|
||||
}
|
||||
launch { ws.errorMessage.collect { errorMessage.value = it } }
|
||||
@@ -179,6 +188,17 @@ class ConnectionService : LifecycleService() {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getInstalledApps(): List<InstalledAppInfo> {
|
||||
val pm = packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val activities = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL)
|
||||
return activities.mapNotNull { resolveInfo ->
|
||||
val pkg = resolveInfo.activityInfo.packageName
|
||||
val label = resolveInfo.loadLabel(pm).toString()
|
||||
InstalledAppInfo(packageName = pkg, label = label)
|
||||
}.distinctBy { it.packageName }.sortedBy { it.label.lowercase() }
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
|
||||
@@ -26,7 +26,7 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
|
||||
val serverUrl: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
prefs[SettingsKeys.SERVER_URL] ?: "wss://localhost:8080"
|
||||
prefs[SettingsKeys.SERVER_URL] ?: "ws://localhost:8080"
|
||||
}
|
||||
|
||||
val deviceName: Flow<String> = context.dataStore.data.map { prefs ->
|
||||
|
||||
@@ -58,6 +58,18 @@ data class HeartbeatMessage(
|
||||
val isCharging: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class InstalledAppInfo(
|
||||
val packageName: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AppsMessage(
|
||||
val type: String = "apps",
|
||||
val apps: List<InstalledAppInfo>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ServerMessage(
|
||||
val type: String,
|
||||
|
||||
@@ -51,7 +51,7 @@ fun SettingsScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val apiKey by app.settingsStore.apiKey.collectAsState(initial = "")
|
||||
val serverUrl by app.settingsStore.serverUrl.collectAsState(initial = "wss://localhost:8080")
|
||||
val serverUrl by app.settingsStore.serverUrl.collectAsState(initial = "ws://localhost:8080")
|
||||
|
||||
var editingApiKey by remember(apiKey) { mutableStateOf(apiKey) }
|
||||
var editingServerUrl by remember(serverUrl) { mutableStateOf(serverUrl) }
|
||||
|
||||
Reference in New Issue
Block a user