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:
Sanju Sivalingam
2026-02-17 22:50:18 +05:30
parent fae5fd3534
commit e300f04e13
17 changed files with 410 additions and 88 deletions

View File

@@ -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"

View File

@@ -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()
}

View File

@@ -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(

View File

@@ -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 ->

View File

@@ -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,

View File

@@ -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) }