Compare commits

...
35 Commits
Author SHA1 Message Date
Kurt Himebauch c9c542f148 fix(agent): run the agent's Lab builds in the app's own process and continue the conversation that asked after the restart 2026-11-09 21:45:19 -05:00
Kurt Himebauch 3014d0369b fix(agent): the notification names a prepare and shows its percent without repeating the title 2026-11-09 21:45:19 -05:00
Kurt Himebauch 99b72f9095 feat(agent): a setup step that prepares fast Lab builds for the installed version, kept alive while it runs 2026-11-09 21:45:19 -05:00
Kurt Himebauch 9530d72164 feat(agent): rebuild a kept Lab build from Updates, and tidy the chat's tool rows, costs, processes and times 2026-11-09 21:45:19 -05:00
Kurt Himebauch a7dc7256e1 fix(agent): an old Lab build's progress is not shown as a failure, and a restart counts as success 2026-11-09 21:45:19 -05:00
Kurt Himebauch 2594b0c9c3 fix(agent): check the Lab build less often while nothing is happening 2026-11-09 21:45:19 -05:00
Kurt Himebauch 1afd634d9f feat(agent): conversation history, model and plan mode, live replies, full tool input and output, processes, attachments, queued messages and a Stop that ends the turn 2026-11-09 21:45:19 -05:00
Kurt Himebauch 305a53c20c feat(agent): DroidDeck opens on the Agent tab after restarting on a Lab build 2026-11-09 21:45:19 -05:00
Kurt Himebauch aea3672bd3 fix(agent): the build tools take about 5 GB 2026-11-09 21:45:19 -05:00
Kurt Himebauch cc08d0dca5 feat(agent): show and remove what the agent stores in the Linux userland 2026-11-09 21:45:18 -05:00
Kurt Himebauch f247e42329 feat(agent): continue the conversation after a Lab build restarts the app 2026-11-09 21:45:18 -05:00
Kurt Himebauch af764059c8 fix(agent): leave room under the chat composer 2026-11-09 21:45:18 -05:00
Kurt Himebauch 6bdcd669f8 feat(agent): a header, a composer with send and stop, and Markdown replies in the agent chat 2026-11-09 21:45:18 -05:00
Kurt Himebauch 77bfbd732f fix(agent): switching harness clears the previous harness's setup results 2026-11-09 21:45:18 -05:00
Kurt Himebauch 2d86dbf1ed fix(agent): start the adb server before connecting and retry the connection check 2026-11-09 21:45:18 -05:00
Kurt Himebauch 56ee9aa81a fix(agent): each harness has its own setup state and conversation 2026-11-09 21:45:18 -05:00
Kurt Himebauch d7a6fc8fbc fix(agent): show why a setup check failed 2026-11-09 21:45:18 -05:00
Kurt Himebauch 5d19335233 fix(agent): connect adb only for commands that use it and run setup checks one at a time 2026-11-09 21:45:18 -05:00
Kurt Himebauch 2cd572c0e0 fix(agent): install helpers off the main thread and offer sign-in only after the install 2026-11-09 21:45:18 -05:00
Kurt Himebauch 65a52da90a feat(agent): first-run setup in the Agent tab, a harness choice with Codex, and no permission prompts 2026-11-09 21:45:18 -05:00
Kurt Himebauch 5a7fda603c fix(agent): connect loopback adb, show unread replies, and fit the drawer page to the screen 2026-11-09 21:45:18 -05:00
Kurt Himebauch 78b89e9ad3 feat(agent): the agent in the session drawer and as a bubble over the game 2026-11-09 21:45:18 -05:00
Kurt Himebauch 65c04be072 feat(agent): an Agent tab in the launcher that talks to Claude Code outside any session 2026-11-09 21:45:18 -05:00
Kurt Himebauch 2213362336 fix(lab): the app's agent hands its builds to the app, so they outlive the agent's turn 2026-11-09 21:45:17 -05:00
Kurt Himebauch 5683be3b9b feat(lab): record which installed commit the build caches are warm for 2026-11-09 21:18:19 -05:00
Kurt Himebauch d7dd802429 feat(lab): a kept build for another version can be rebuilt 2026-11-09 21:17:02 -05:00
Kurt Himebauch 0cf83ce71d fix(lab): report a failed command as a failed build and update progress every five seconds 2026-11-09 21:11:33 -05:00
Kurt Himebauch acecbcd79a feat(lab): estimate the time left and compile the installed commit ahead of the first build 2026-11-09 20:57:57 -05:00
Kurt Himebauch b6d70b177d feat(lab): notify when DroidDeck comes back on a Lab build or turns one off 2026-11-09 20:57:57 -05:00
Kurt Himebauch 5f0519dfbf feat(lab): keep earlier Lab builds and list them in Updates 2026-11-09 20:57:57 -05:00
Kurt Himebauch 914c659f4a fix(lab): size the build heap from the device's memory 2026-11-09 19:05:31 -05:00
Kurt Himebauch a178553254 feat(lab): tell the app's own agent to continue after a Lab build restart 2026-11-09 18:25:14 -05:00
Kurt Himebauch 774abb738d fix(lab): dd-agent connects loopback adb before starting Claude Code 2026-11-09 17:26:10 -05:00
Kurt Himebauch 5d63cc3b9e feat(lab): build Lab builds on the device and report the APK commit to agents 2026-11-09 17:08:29 -05:00
Kurt Himebauch f3f3f9ce08 feat(lab): load an on-device Lab build over the installed app 2026-11-09 16:25:39 -05:00
45 changed files with 4009 additions and 9 deletions

No files matched your search

+1
View File
@@ -10,3 +10,4 @@ tools/mangoapp/mangoapp text eol=lf
app/src/main/assets/desktop-removal/*.json text eol=lf
app/src/main/assets/desktop-removal/*.txt text eol=lf
*.pdf binary
app/src/main/assets/agent/* text eol=lf
+1
View File
@@ -302,6 +302,7 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
<service android:name=".agent.AgentHostService" android:exported="false" />
<service
android:name=".session.SessionService"
android:exported="false"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Opens a URL in Android's browser on the display the agent is being used on ($DD_BROWSER_DISPLAY).
url=$1
[ -n "$url" ] || exit 1
serial=${ADB_SERIAL:-127.0.0.1:5555}
adb connect "$serial" >/dev/null 2>&1
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
adb -s "$serial" shell "am start --display ${DD_BROWSER_DISPLAY:-0} -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
echo "Open this on any device: $url" >&2
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# GitHub sign-in through Android's browser, with the one-time code typed in for the person.
serial=${ADB_SERIAL:-127.0.0.1:5555}
display=${DD_BROWSER_DISPLAY:-0}
adb connect "$serial" >/dev/null 2>&1
coproc GH { BROWSER=true gh auth login --hostname github.com --git-protocol https --web 2>&1; }
typed=
while IFS= read -r line <&"${GH[0]}"; do
printf '%s\n' "$line"
code=$(grep -oE '[A-Z0-9]{4}-[A-Z0-9]{4}' <<<"$line" | head -1)
if [ -n "$code" ] && [ -z "$typed" ]; then
typed=1
echo >&"${GH[1]}"
adb -s "$serial" shell "am start --display $display -a android.intent.action.VIEW -d https://github.com/login/device" >/dev/null 2>&1
# Chrome drops characters from `input text` and from fast key events on this page.
keys=
for ((i = 0; i < ${#code}; i++)); do
c=${code:i:1}
[ "$c" = - ] || keys+="input -d $display keyevent KEYCODE_$c; sleep 0.4; "
done
(sleep 7; adb -s "$serial" shell "$keys" >/dev/null 2>&1) &
fi
done
wait "$GH_PID"
@@ -7,8 +7,11 @@ import com.droiddeck.launcher.session.EnhancedSessionLogShare
import com.droiddeck.launcher.session.GpuClockPin
class App : Application() {
override fun attachBaseContext(base: android.content.Context) =
override fun attachBaseContext(base: android.content.Context) {
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName() else packageName
com.droiddeck.launcher.lab.LabOverlay.load(this, process == packageName)
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
@@ -39,6 +42,9 @@ class App : Application() {
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
CrashHandler.install(this)
com.droiddeck.launcher.agent.AgentHost.refresh(this)
if (com.droiddeck.launcher.agent.AgentHost.available) com.droiddeck.launcher.lab.LabNotice.openOnInstall = "agent"
com.droiddeck.launcher.lab.LabNotice.started(this)
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
@@ -1104,6 +1104,8 @@ class FrontEndActivity : ComponentActivity() {
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
if (savedInstanceState == null) readIntegrationIntent(intent)
readGameIntent(intent)
// A Lab build is usually the agent's work, so the app comes back where the agent is.
if (savedInstanceState == null && navRequest == null && com.droiddeck.launcher.lab.LabOverlay.promoted && com.droiddeck.launcher.agent.AgentHost.available) navRequest = "agent"
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
@@ -1148,6 +1148,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
if (!drawerOpen && !pcKeyboardOpen) com.droiddeck.launcher.ui.AgentBubble {
drawerPage = SessionDrawerPage.AGENT
drawerOpen = true
}
if (!drawerDocked) SessionMenu(docked = false)
LaunchedEffect(achievementsOpen) {
if (achievementsOpen) {
@@ -0,0 +1,44 @@
package com.droiddeck.launcher.agent
import android.content.Context
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* Loopback adb for the agent, so droiddeckctl in the Linux userland reaches the app. It reuses
* the Wireless debugging pairing DroidDeck already made (for the phantom process fix): the paired
* key goes to the guest as an adb vendor key, and the serial is the paired TLS connect port.
* Without a pairing it falls back to adbd on 127.0.0.1:5555, which some devices and `adb tcpip`
* leave listening.
*/
object AgentAdb {
data class Link(val serial: String, val vendorKeys: String, val paired: Boolean)
private const val LEGACY = "127.0.0.1:5555"
private const val VENDOR_KEY = "/root/.android/droiddeck-paired.adbkey"
/** The display the browser opens on for sign-ins: the one the agent is being used on. */
@Volatile var browserDisplay = 0
/** Blocks while it looks up the connect port when none is saved; call it off the main thread. */
fun prepare(app: Context): Link {
val key = File(app.noBackupFilesDir, "wireless-adb-key.pem")
val address = WirelessAdbFix.savedConnectionAddress(app)
if (address == null || !key.isFile) return Link(LEGACY, VENDOR_KEY, false)
val guestKey = File(LinuxRuntime.rootDir(app), VENDOR_KEY.removePrefix("/"))
runCatching {
if (!guestKey.isFile || !guestKey.readBytes().contentEquals(key.readBytes())) {
guestKey.parentFile?.mkdirs()
guestKey.writeBytes(key.readBytes())
guestKey.setReadable(false, false)
guestKey.setReadable(true, true)
}
}
val host = address.substringBeforeLast(':')
val port = address.substringAfterLast(':').toIntOrNull()
?: WirelessAdbFix.findConnectPort(app, host.trim('[', ']'))
?: return Link(LEGACY, VENDOR_KEY, false)
return Link("$host:$port", VENDOR_KEY, true)
}
}
@@ -0,0 +1,55 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.compose.runtime.mutableStateListOf
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* Files the user attaches to a message, copied into the Linux userland so the agent's own tools
* can read them (Claude Code's Read shows images and PDFs to the model).
*/
object AgentAttachments {
/** Guest paths waiting to go with the next message. */
val pending = mutableStateListOf<String>()
private const val GUEST_DIR = "/root/.cache/droiddeck/attachments"
private val IMAGE = setOf("png", "jpg", "jpeg", "webp", "gif", "bmp")
fun hostDir(context: Context) = File(LinuxRuntime.rootDir(context.applicationContext), GUEST_DIR.removePrefix("/"))
fun host(context: Context, guestPath: String) = File(LinuxRuntime.rootDir(context.applicationContext), guestPath.removePrefix("/"))
fun isImage(path: String) = path.substringAfterLast('.', "").lowercase() in IMAGE
/** Copies [uris] for [chat] (or a new conversation) on the calling thread; returns their guest paths. */
fun copy(context: Context, chat: String?, uris: List<Uri>): List<String> {
val app = context.applicationContext
val folder = chat ?: "new"
val dir = File(hostDir(app), folder).apply { mkdirs() }
return uris.mapNotNull { uri ->
runCatching {
val name = displayName(app, uri).replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "file" }
var target = File(dir, name)
var n = 1
while (target.exists()) target = File(dir, "${name.substringBeforeLast('.')}-${n++}${name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }}")
app.contentResolver.openInputStream(uri)!!.use { input -> target.outputStream().use { input.copyTo(it) } }
"$GUEST_DIR/$folder/${target.name}"
}.getOrNull()
}
}
/** The message the CLI gets: the user's text, then where the attached files are. */
fun prompt(text: String, files: List<String>): String {
if (files.isEmpty()) return text
val list = files.joinToString("\n") { "- $it" }
return "$text\n\nAttached files (read them with your tools):\n$list".trim()
}
private fun displayName(context: Context, uri: Uri): String =
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) else null
} ?: uri.lastPathSegment?.substringAfterLast('/') ?: "file"
}
@@ -284,6 +284,8 @@ class AgentBridgeProvider : ContentProvider() {
return JSONObject()
.put("schema", SCHEMA)
.put("build", BuildConfig.BUILD_LABEL)
.put("apkCommit", com.droiddeck.launcher.lab.LabOverlay.apkCommit)
.put("lab", com.droiddeck.launcher.lab.LabOverlay.active?.label ?: JSONObject.NULL)
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
.put("runtime", JSONObject()
.put("installed", LinuxRuntime.isInstalled(context))
@@ -0,0 +1,134 @@
package com.droiddeck.launcher.agent
import android.content.Context
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.agent.AgentHost.Entry
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/**
* Conversations kept on the device, one JSON Lines file each in `files/agent/chats`: a header
* line with the harness, a line with the CLI's conversation id once it has one, then the entries.
*/
object AgentChats {
data class Chat(val id: String, val harness: String, val title: String, val updated: Long)
class Loaded(val harness: String?, val conversation: String?, val entries: List<Entry>)
private const val MAX_OUTPUT = 24_000
fun dir(context: Context) = File(context.filesDir, "agent/chats")
fun file(context: Context, id: String) = File(dir(context), "$id.jsonl")
fun newId(context: Context): String {
var stamp = System.currentTimeMillis()
while (file(context, stamp.toString()).exists()) stamp++
return stamp.toString()
}
/** Newest first; chats with nothing asked yet are left out. */
fun list(context: Context): List<Chat> = dir(context).listFiles { f -> f.name.endsWith(".jsonl") }.orEmpty()
.mapNotNull { f ->
val loaded = read(f)
val title = loaded.entries.firstNotNullOfOrNull { (it as? Entry.Asked)?.text } ?: return@mapNotNull null
Chat(f.name.removeSuffix(".jsonl"), loaded.harness ?: ClaudeCode.id, title.lineSequence().first().take(120), f.lastModified())
}
.sortedByDescending { it.updated }
fun delete(context: Context, id: String) {
file(context, id).delete()
File(AgentAttachments.hostDir(context), id).deleteRecursively()
}
fun start(file: File, harness: String) {
file.parentFile?.mkdirs()
file.writeText(JSONObject().put("harness", harness).toString() + "\n")
}
fun appendConversation(file: File, conversation: String) =
runCatching { file.appendText(JSONObject().put("conversation", conversation).toString() + "\n") }
fun append(file: File, entry: Entry) = runCatching { file.appendText(encode(entry) + "\n") }
/** The transcript before chats were kept, moved into the first chat. */
fun migrate(context: Context, harness: String, conversation: String?): String? {
val old = File(context.filesDir, "agent/transcript.jsonl")
if (!old.isFile) return null
val id = newId(context)
val target = file(context, id)
start(target, harness)
conversation?.let { appendConversation(target, it) }
target.appendText(old.readText())
old.delete()
return id
}
@VisibleForTesting
internal fun encode(entry: Entry): String = when (entry) {
is Entry.Asked -> JSONObject().put("asked", entry.text).apply { if (entry.files.isNotEmpty()) put("files", JSONArray(entry.files)) }
is Entry.Said -> JSONObject().put("said", entry.text)
is Entry.Thought -> JSONObject().put("thought", entry.text)
is Entry.Result -> JSONObject().put("result", entry.id).put("output", entry.output).put("failed", entry.failed)
is Entry.Used -> JSONObject().put("used", entry.tool).put("detail", entry.detail).put("id", entry.id).put("input", entry.input)
.apply { entry.output?.let { put("output", it).put("failed", entry.failed) } }
is Entry.Finished -> JSONObject().put("finished", true).put("error", entry.error ?: JSONObject.NULL).put("stopped", entry.stopped).apply {
entry.usage?.let { put("ms", it.durationMs).put("cost", it.costUsd).put("in", it.inputTokens).put("out", it.outputTokens) }
}
}.toString()
@VisibleForTesting
internal fun read(file: File): Loaded {
var harness: String? = null
var conversation: String? = null
val entries = mutableListOf<Entry>()
runCatching {
file.forEachLine { line ->
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@forEachLine
when {
o.has("harness") -> harness = o.getString("harness")
o.has("conversation") -> conversation = o.getString("conversation")
else -> decode(o)?.let { merge(entries, it) }
}
}
}
return Loaded(harness, conversation, entries)
}
private fun decode(o: JSONObject): Entry? = when {
o.has("asked") -> Entry.Asked(o.getString("asked"), o.optJSONArray("files")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty())
o.has("said") -> Entry.Said(o.getString("said"))
o.has("thought") -> Entry.Thought(o.getString("thought"))
o.has("used") -> Entry.Used(o.optString("id"), o.getString("used"), o.optString("detail"), o.optString("input"),
if (o.has("output")) o.optString("output") else null, o.optBoolean("failed"))
o.has("result") -> Entry.Result(o.getString("result"), o.optString("output"), o.optBoolean("failed"))
o.has("finished") -> Entry.Finished(
if (o.isNull("error")) null else o.optString("error"), o.optBoolean("stopped"),
if (o.has("ms")) AgentHost.Usage(o.optLong("ms"), o.optDouble("cost", 0.0), o.optLong("in"), o.optLong("out")) else null,
)
else -> null
}
/** A tool's result is shown on the call it answers, and a call seen again (an updated todo list) replaces it. */
fun merge(entries: MutableList<Entry>, entry: Entry): Boolean {
if (entry is Entry.Used && entry.id.isNotEmpty()) {
val same = entries.indexOfLast { it is Entry.Used && it.id == entry.id }
if (same >= 0) {
entries[same] = entry
return true
}
}
if (entry !is Entry.Result) {
entries += entry
return true
}
val index = entries.indexOfLast { it is Entry.Used && it.id == entry.id && entry.id.isNotEmpty() }
if (index < 0) return false
entries[index] = (entries[index] as Entry.Used).copy(output = clip(entry.output), failed = entry.failed)
return true
}
/** Long outputs keep their end, where errors and summaries usually are. */
fun clip(output: String) = if (output.length <= MAX_OUTPUT) output else "…\n" + output.takeLast(MAX_OUTPUT)
}
@@ -0,0 +1,263 @@
package com.droiddeck.launcher.agent
import androidx.annotation.VisibleForTesting
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
/** One coding agent CLI in the Linux userland, run one process per turn. */
interface AgentHarness {
val id: String
val name: String
/** Absolute guest path of the CLI. */
val binary: String
/** A guest shell script that installs the CLI. */
val install: String
/** A guest command whose exit status is 0 when the CLI is signed in. */
val signedIn: List<String>
/** A guest command that signs in through $BROWSER. */
val signIn: List<String>
/** Whether [Options.plan] means anything to this CLI. */
val plans: Boolean
/** Models to offer besides the CLI's default; [rootfs] is where the CLI's own files can be read. */
fun models(rootfs: File): List<Model>
/** The guest command for one turn; [conversation] is null for the first one. */
fun turn(message: String, conversation: String?, options: Options): List<String>
/** One line of the CLI's output, or null when it carries nothing to show. */
fun parse(line: String): AgentEvent?
data class Model(val id: String, val label: String)
data class Options(val model: String? = null, val plan: Boolean = false, val brief: String? = null)
companion object {
val all: List<AgentHarness> = listOf(ClaudeCode, Codex)
fun byId(id: String?): AgentHarness = all.firstOrNull { it.id == id } ?: ClaudeCode
}
}
/** What one line of output carries: whole entries, or text still being written. */
data class AgentEvent(
val conversation: String? = null,
val entries: List<AgentHost.Entry> = emptyList(),
val text: String? = null,
val thinking: String? = null,
/** Tokens the conversation's context holds after this step. */
val context: Long? = null,
val usage: AgentHost.Usage? = null,
)
/** Claude Code: `claude -p` with stream-json output, resumed by session id. */
object ClaudeCode : AgentHarness {
override val id = "claude"
override val name = "Claude Code"
override val binary = "/root/.local/bin/claude"
override val install = "curl -fsSL https://claude.ai/install.sh | bash"
override val signedIn = listOf("sh", "-c", "$binary auth status | grep -q '\"loggedIn\": true'")
override val signIn = listOf(binary, "auth", "login", "--claudeai")
override val plans = true
override fun models(rootfs: File) = listOf(
AgentHarness.Model("opus", "Opus"), AgentHarness.Model("sonnet", "Sonnet"), AgentHarness.Model("haiku", "Haiku"),
)
// DroidDeck runs the agent unattended on the user's own device; permission prompts have no one to answer them.
override fun turn(message: String, conversation: String?, options: AgentHarness.Options) = buildList {
addAll(listOf(binary, "-p", message, "--output-format", "stream-json", "--verbose", "--include-partial-messages"))
addAll(listOf("--permission-mode", if (options.plan) "plan" else "bypassPermissions"))
options.model?.let { addAll(listOf("--model", it)) }
options.brief?.let { addAll(listOf("--append-system-prompt", it)) }
if (conversation != null) addAll(listOf("--resume", conversation))
}
override fun parse(line: String): AgentEvent? {
val o = runCatching { JSONObject(line) }.getOrNull() ?: return null
val conversation = o.optString("session_id").takeIf { it.isNotEmpty() }
return when (o.optString("type")) {
"stream_event" -> {
val delta = o.optJSONObject("event")?.takeIf { it.optString("type") == "content_block_delta" }?.optJSONObject("delta") ?: return null
when (delta.optString("type")) {
"text_delta" -> AgentEvent(conversation, text = delta.optString("text"))
"thinking_delta" -> AgentEvent(conversation, thinking = delta.optString("thinking"))
else -> null
}
}
"assistant" -> {
val message = o.optJSONObject("message")
val content = message?.optJSONArray("content") ?: JSONArray()
val entries = (0 until content.length()).mapNotNull { i ->
val block = content.optJSONObject(i) ?: return@mapNotNull null
when (block.optString("type")) {
"text" -> block.optString("text").trim().takeIf { it.isNotEmpty() }?.let { AgentHost.Entry.Said(it) }
"thinking" -> block.optString("thinking").trim().takeIf { it.isNotEmpty() }?.let { AgentHost.Entry.Thought(it) }
"tool_use" -> {
val input = block.optJSONObject("input")
AgentHost.Entry.Used(block.optString("id"), block.optString("name"), detail(input), fullInput(block.optString("name"), input))
}
else -> null
}
}
val usage = message?.optJSONObject("usage")
val context = usage?.let { it.optLong("input_tokens") + it.optLong("cache_read_input_tokens") + it.optLong("cache_creation_input_tokens") + it.optLong("output_tokens") }
AgentEvent(conversation, entries, context = context?.takeIf { it > 0 })
}
"user" -> {
val content = o.optJSONObject("message")?.optJSONArray("content") ?: return conversation?.let { AgentEvent(it) }
val results = (0 until content.length()).mapNotNull { i ->
val block = content.optJSONObject(i)?.takeIf { it.optString("type") == "tool_result" } ?: return@mapNotNull null
AgentHost.Entry.Result(block.optString("tool_use_id"), resultText(block.opt("content")), block.optBoolean("is_error"))
}
AgentEvent(conversation, results)
}
"result" -> AgentEvent(
conversation,
listOf(AgentHost.Entry.Finished(if (o.optBoolean("is_error")) o.optString("result").ifEmpty { o.optString("subtype") } else null)),
usage = AgentHost.Usage(
o.optLong("duration_ms"), o.optDouble("total_cost_usd", 0.0),
o.optJSONObject("usage")?.let { it.optLong("input_tokens") + it.optLong("cache_read_input_tokens") + it.optLong("cache_creation_input_tokens") } ?: 0,
o.optJSONObject("usage")?.optLong("output_tokens") ?: 0,
),
)
else -> conversation?.let { AgentEvent(it) }
}
}
private fun resultText(content: Any?): String = when (content) {
is String -> content
is JSONArray -> (0 until content.length()).mapNotNull { i ->
val part = content.optJSONObject(i) ?: return@mapNotNull null
when (part.optString("type")) {
"text" -> part.optString("text")
"image" -> "[image]"
else -> null
}
}.joinToString("\n")
null, JSONObject.NULL -> ""
else -> content.toString()
}
private fun detail(input: JSONObject?): String {
input ?: return ""
for (key in listOf("command", "file_path", "pattern", "url", "query", "description", "prompt")) {
input.optString(key).takeIf { it.isNotEmpty() }?.let { return firstLine(it) }
}
return ""
}
@VisibleForTesting
internal fun fullInput(tool: String, input: JSONObject?): String {
input ?: return ""
return when {
tool == "Bash" -> input.optString("command")
tool == "Edit" -> "${input.optString("file_path")}\n\n- ${input.optString("old_string").replace("\n", "\n- ")}\n\n+ ${input.optString("new_string").replace("\n", "\n+ ")}"
tool == "Write" -> "${input.optString("file_path")}\n\n${input.optString("content")}"
tool == "TodoWrite" -> todos(input.optJSONArray("todos"))
else -> input.toString(2)
}
}
private fun todos(list: JSONArray?) = (0 until (list?.length() ?: 0)).mapNotNull { i ->
val todo = list?.optJSONObject(i) ?: return@mapNotNull null
val mark = when (todo.optString("status")) { "completed" -> "[x]"; "in_progress" -> "[~]"; else -> "[ ]" }
"$mark ${todo.optString("content")}"
}.joinToString("\n")
}
/** OpenAI Codex: `codex exec --json`, resumed by thread id. */
object Codex : AgentHarness {
override val id = "codex"
override val name = "Codex"
override val binary = "/root/.local/bin/codex"
override val install = """
set -e
url=${'$'}(curl -fsSL https://api.github.com/repos/openai/codex/releases/latest | grep -o 'https://[^"]*codex-aarch64-unknown-linux-musl.tar.gz' | head -1)
mkdir -p /root/.local/bin
curl -fsSL "${'$'}url" | tar -xz -C /tmp
install -m755 /tmp/codex-aarch64-unknown-linux-musl $binary
""".trimIndent()
override val signedIn = listOf(binary, "login", "status")
override val signIn = listOf(binary, "login")
override val plans = false
/** Codex keeps the models its account can use in `~/.codex/models_cache.json` once it has run. */
override fun models(rootfs: File): List<AgentHarness.Model> = runCatching {
val o = JSONObject(File(rootfs, "root/.codex/models_cache.json").readText())
val list = o.optJSONArray("models") ?: JSONArray()
(0 until list.length()).mapNotNull { i ->
val m = list.optJSONObject(i) ?: return@mapNotNull null
val slug = m.optString("slug").ifEmpty { m.optString("id") }.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
if (m.has("visibility") && m.optString("visibility") != "list") return@mapNotNull null
AgentHarness.Model(slug, m.optString("display_name").ifEmpty { slug })
}
}.getOrDefault(emptyList())
// Codex's own sandbox needs Landlock and seccomp, which proot does not provide.
override fun turn(message: String, conversation: String?, options: AgentHarness.Options) = buildList {
addAll(listOf(binary, "exec"))
if (conversation != null) addAll(listOf("resume", conversation))
addAll(listOf("--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check"))
options.model?.let { addAll(listOf("-m", it)) }
// codex exec has no system prompt option, so a new conversation starts with the brief.
add(if (conversation == null && options.brief != null) "${options.brief}\n\n$message" else message)
}
override fun parse(line: String): AgentEvent? {
val o = runCatching { JSONObject(line) }.getOrNull() ?: return null
val item = o.optJSONObject("item")
val id = item?.optString("id").orEmpty()
val entries = when (o.optString("type")) {
"thread.started" -> return AgentEvent(o.optString("thread_id").takeIf { it.isNotEmpty() })
"item.started" -> when (item?.optString("type")) {
"command_execution" -> listOf(AgentHost.Entry.Used(id, "Shell", firstLine(item.optString("command")), item.optString("command")))
"mcp_tool_call" -> listOf(AgentHost.Entry.Used(id, item.optString("tool").ifEmpty { "Tool" }, item.optString("server"), item.optJSONObject("arguments")?.toString(2).orEmpty()))
"web_search" -> listOf(AgentHost.Entry.Used(id, "Search", item.optString("query"), item.optString("query")))
else -> emptyList()
}
"item.updated" -> when (item?.optString("type")) {
"todo_list" -> listOf(todoEntry(id, item))
else -> emptyList()
}
"item.completed" -> when (item?.optString("type")) {
"agent_message" -> item.optString("text").trim().takeIf { it.isNotEmpty() }?.let { listOf(AgentHost.Entry.Said(it)) }.orEmpty()
"reasoning" -> item.optString("text").trim().takeIf { it.isNotEmpty() }?.let { listOf(AgentHost.Entry.Thought(it)) }.orEmpty()
"command_execution" -> listOf(AgentHost.Entry.Result(id, item.optString("aggregated_output"),
item.has("exit_code") && !item.isNull("exit_code") && item.optInt("exit_code") != 0))
"mcp_tool_call" -> listOf(AgentHost.Entry.Result(id, item.optJSONObject("result")?.toString(2) ?: item.optJSONObject("error")?.optString("message").orEmpty(),
item.optString("status") == "failed"))
"web_search" -> listOf(AgentHost.Entry.Result(id, "", false))
"file_change" -> listOf(
AgentHost.Entry.Used(id, "Edit", changedFiles(item), changedFiles(item).replace(", ", "\n")),
AgentHost.Entry.Result(id, item.optString("status"), item.optString("status") == "failed"),
)
"todo_list" -> listOf(todoEntry(id, item))
"error" -> listOf(AgentHost.Entry.Said("⚠ " + item.optString("message")))
else -> emptyList()
}
"turn.completed" -> return AgentEvent(
entries = listOf(AgentHost.Entry.Finished(null)),
usage = o.optJSONObject("usage")?.let { AgentHost.Usage(0, 0.0, it.optLong("input_tokens") + it.optLong("cached_input_tokens"), it.optLong("output_tokens")) },
)
"turn.failed" -> listOf(AgentHost.Entry.Finished(o.optJSONObject("error")?.optString("message") ?: "turn failed"))
"error" -> listOf(AgentHost.Entry.Finished(o.optString("message").ifEmpty { "error" }))
else -> emptyList()
}
return if (entries.isEmpty()) null else AgentEvent(entries = entries)
}
private fun todoEntry(id: String, item: JSONObject): AgentHost.Entry.Used {
val items = item.optJSONArray("items") ?: JSONArray()
val text = (0 until items.length()).mapNotNull { i ->
val todo = items.optJSONObject(i) ?: return@mapNotNull null
"${if (todo.optBoolean("completed")) "[x]" else "[ ]"} ${todo.optString("text")}"
}.joinToString("\n")
return AgentHost.Entry.Used("$id-todo", "TodoWrite", "", text, output = "")
}
private fun changedFiles(item: JSONObject): String {
val changes = item.optJSONArray("changes") ?: return ""
return (0 until changes.length()).mapNotNull { changes.optJSONObject(it)?.optString("path") }.joinToString(", ").take(400)
}
}
@VisibleForTesting
internal fun firstLine(text: String) = text.lineSequence().first().take(160)
@@ -0,0 +1,451 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.nio.file.Files
/**
* The launcher's agent: a coding agent CLI ([AgentHarness]) from the Linux userland, run headless
* one process per turn, outside any game or desktop session. Conversations are kept on disk
* ([AgentChats]), so a restart (a Lab build, a crash) continues the same one.
*/
object AgentHost {
sealed interface Entry {
data class Asked(val text: String, val files: List<String> = emptyList()) : Entry
data class Said(val text: String) : Entry
data class Thought(val text: String) : Entry
/** A tool call; [output] stays null until its result arrives. */
data class Used(val id: String, val tool: String, val detail: String, val input: String = "", val output: String? = null, val failed: Boolean = false) : Entry
data class Result(val id: String, val output: String, val failed: Boolean) : Entry
data class Finished(val error: String?, val stopped: Boolean = false, val usage: Usage? = null) : Entry
}
data class Usage(val durationMs: Long, val costUsd: Double, val inputTokens: Long, val outputTokens: Long)
/** A message typed while the agent was working, sent when the turn ends. */
data class Queued(val text: String, val files: List<String>)
enum class State { MISSING, IDLE, WORKING }
private const val TAG = "AgentHost"
private const val WORKSPACE = "/root/DroidDeck"
private const val TICK_MS = 1_000L
private const val IDLE_TICK_MS = 5_000L
/** Where the agent runs and what the user sees, which no file in the checkout tells it. */
@VisibleForTesting
internal const val BRIEF = "You run inside DroidDeck on the user's handheld, in its Linux userland (Ubuntu under proot, aarch64), " +
"and the user talks to you from DroidDeck's Agent tab, often on a small screen, so keep replies short. " +
"droiddeckctl (in the /root/DroidDeck checkout, tools/droiddeckctl) inspects and drives the app over loopback adb (\$ADB_SERIAL). " +
"dd-lab-build builds the checkout as a Lab build and restarts DroidDeck on it (docs/lab-builds.md). DroidDeck runs the build in a " +
"process of its own and shows the user its progress and time left; dd-lab-build follows that build's log until it is done. " +
"Run it with run_in_background and keep checking its output, so the user can talk to you meanwhile. " +
"The restart ends this process, and this conversation continues on the new build. " +
"Files the user attaches are listed at the end of their message, under /root/.cache/droiddeck/attachments."
val entries = mutableStateListOf<Entry>()
val queue = mutableStateListOf<Queued>()
var state by mutableStateOf(State.IDLE)
private set
var installed by mutableStateOf(false)
private set
/** The Linux runtime is there, so the agent can be set up. */
var available by mutableStateOf(false)
private set
var harness by mutableStateOf<AgentHarness>(ClaudeCode)
private set
/** The chosen model for [harness]; null is the CLI's default. */
var model by mutableStateOf<String?>(null)
private set
var plan by mutableStateOf(false)
private set
var chat by mutableStateOf<String?>(null)
private set
/** A reply finished since the conversation was last on screen. */
var unseen by mutableStateOf(false)
private set
var failed by mutableStateOf(false)
private set
/** Text and thinking of the reply being written. */
var live by mutableStateOf("")
private set
var liveThinking by mutableStateOf("")
private set
/** When the current turn started, on [SystemClock.elapsedRealtime]. */
var turnStarted by mutableLongStateOf(0L)
private set
var now by mutableLongStateOf(0L)
private set
/** Tokens in the conversation's context after its last step. */
var context by mutableLongStateOf(0L)
private set
var lab by mutableStateOf<LabProgress?>(null)
private set
private class Turn(val process: Process, val pid: Int)
private val main = Handler(Looper.getMainLooper())
private var turn: Turn? = null
/** Counts turns, so a process that starts after its turn was stopped is not adopted by the next one. */
private var generation = 0
private var labProcess: Process? = null
/** The running Lab build was asked for by the current turn, so stopping the turn stops it. */
private var labFromTurn = false
private var conversation: String? = null
private var loaded = false
private var appContext: Context? = null
val turnPid: Int get() = turn?.pid ?: 0
fun seen() {
unseen = false
}
fun refresh(context: Context) {
val app = context.applicationContext
appContext = app
harness = AgentHarness.byId(prefs(app).getString(HARNESS_KEY, null))
model = prefs(app).getString(modelKey(harness), null)
plan = prefs(app).getBoolean(PLAN_KEY, false)
available = LinuxRuntime.isInstalled(app)
installed = guestFileExists(app, harness.binary)
if (!installed) state = State.MISSING else if (state == State.MISSING) state = State.IDLE
if (!loaded) {
loaded = true
val migrated = AgentChats.migrate(app, harness.id, prefs(app).getString(LEGACY_CONVERSATION_KEY, null))
if (migrated != null) prefs(app).edit().remove(LEGACY_CONVERSATION_KEY).putString(CHAT_KEY, migrated).apply()
prefs(app).getString(CHAT_KEY, null)?.let { load(app, it) }
resumeAfterRestart(app)
tick()
}
}
private fun load(app: Context, id: String) {
val file = AgentChats.file(app, id)
if (!file.isFile) return
val loaded = AgentChats.read(file)
chat = id
conversation = loaded.conversation
entries.clear()
entries.addAll(loaded.entries)
context = 0
}
/** Opens a kept conversation, switching to the CLI it belongs to. */
fun open(context: Context, id: String) {
val app = context.applicationContext
if (state == State.WORKING || id == chat) return
val harnessId = AgentChats.read(AgentChats.file(app, id)).harness
if (harnessId != null && harnessId != harness.id) {
prefs(app).edit().putString(HARNESS_KEY, harnessId).apply()
AgentSetup.forget()
refresh(app)
}
load(app, id)
prefs(app).edit().putString(CHAT_KEY, id).apply()
}
fun delete(context: Context, id: String) {
if (id == chat) {
if (state == State.WORKING) return
forget(context)
}
AgentChats.delete(context.applicationContext, id)
}
/**
* A Lab build restarts the app, which ends the agent's process mid-turn. dd-lab-build leaves
* [RESUME] when the app's own agent ran it, so the same conversation picks up on the new build.
*/
private fun resumeAfterRestart(app: Context) {
val marker = File(app.filesDir, RESUME)
val claimed = File(app.filesDir, "$RESUME.claimed")
if (!marker.renameTo(claimed)) return
claimed.delete()
val asked = prefs(app).getString(LAB_CHAT_KEY, null)
prefs(app).edit().remove(LAB_CHAT_KEY).apply()
if (!installed) return
// The conversation that asked for the build hears about it, even if its turn had already ended.
if (asked != null && asked != chat && AgentChats.file(app, asked).isFile) {
load(app, asked)
prefs(app).edit().putString(CHAT_KEY, asked).apply()
}
if (asked != null || interrupted(entries)) send(app, app.getString(R.string.agent_resumed))
}
/** True when DroidDeck restarted in the middle of one of the agent's turns. */
fun resuming(context: Context) = File(context.applicationContext.filesDir, RESUME).isFile
@VisibleForTesting
internal fun interrupted(entries: List<Entry>) = entries.isNotEmpty() && entries.last() !is Entry.Finished
/** Switches the agent CLI; a conversation belongs to one CLI, so a new one starts. */
fun choose(context: Context, choice: AgentHarness) {
if (choice == harness || state == State.WORKING) return
forget(context)
AgentSetup.forget()
prefs(context).edit().putString(HARNESS_KEY, choice.id).apply()
refresh(context)
}
fun chooseModel(context: Context, id: String?) {
model = id
prefs(context).edit().putString(modelKey(harness), id).apply()
}
fun choosePlan(context: Context, on: Boolean) {
plan = on
prefs(context).edit().putBoolean(PLAN_KEY, on).apply()
}
fun models(context: Context) = harness.models(LinuxRuntime.rootDir(context.applicationContext))
/** Sends [text] now, or after the current turn when the agent is working. */
fun send(context: Context, text: String, files: List<String> = emptyList()) {
val app = context.applicationContext
val message = text.trim()
if (message.isEmpty() && files.isEmpty()) return
if (state == State.WORKING) {
queue += Queued(message, files)
return
}
if (state != State.IDLE) return
val id = chat ?: AgentChats.newId(app).also { id ->
AgentChats.start(AgentChats.file(app, id), harness.id)
chat = id
conversation = null
prefs(app).edit().putString(CHAT_KEY, id).apply()
}
record(app, Entry.Asked(message, files))
state = State.WORKING
val gen = ++generation
live = ""
liveThinking = ""
turnStarted = SystemClock.elapsedRealtime()
AgentHostService.start(app)
val current = harness
val prompt = AgentAttachments.prompt(message, files)
val command = current.turn(prompt, conversation, AgentHarness.Options(model, plan && current.plans, BRIEF))
val workdir = workspace(app)
Thread({
val child = try {
guest(app, command, workdir).start()
} catch (e: Exception) {
Log.w(TAG, "could not start ${current.name}", e)
main.post { if (generation == gen) finish(app, e.message ?: "could not start") }
return@Thread
}
val started = Turn(child, AgentProcesses.pidOf(child))
main.post { if (generation == gen && state == State.WORKING && turn == null) turn = started else AgentProcesses.killTree(started.pid) }
child.outputStream.close()
var ended = false
runCatching {
child.inputStream.bufferedReader().forEachLine { line ->
val event = current.parse(line) ?: return@forEachLine
if (event.entries.any { it is Entry.Finished }) ended = true
main.post { if (turn === started) apply(app, event) }
}
}
val status = runCatching { child.waitFor() }.getOrDefault(-1)
main.post {
if (turn === started) {
turn = null
if (state == State.WORKING && !ended) finish(app, app.getString(R.string.agent_exited, current.name, status))
}
}
}, "agent-output").start()
}
/** Ends the turn now: its process and everything it started, a build included. */
fun stop(context: Context) {
val app = context.applicationContext
val running = turn
turn = null
queue.clear()
if (labFromTurn && lab?.running == true) cancelLabBuild(app)
if (running != null) {
AgentProcesses.killTree(running.pid)
running.process.destroy()
}
if (state == State.WORKING) finish(app, null, stopped = true)
}
fun unqueue(item: Queued) {
queue.remove(item)
}
/** Starts a new conversation; the current one stays in the history. */
fun forget(context: Context) {
stop(context)
entries.clear()
chat = null
conversation = null
this.context = 0
prefs(context).edit().remove(CHAT_KEY).apply()
state = if (installed) State.IDLE else State.MISSING
}
/**
* A process for [command] in the Linux userland, run the way the store helpers run theirs:
* proot over the installed rootfs, without a session. Loopback adb is connected first so
* droiddeckctl reaches the app.
*/
fun guest(app: Context, command: List<String>, workdir: String = "/root", connectAdb: Boolean = true): ProcessBuilder {
val adb = AgentAdb.prepare(app)
val env = listOf(
"/usr/bin/env", "HOME=/root", "PATH=/root/.local/bin:/usr/local/bin:/usr/bin:/bin", "LANG=C.UTF-8",
"ADB_SERIAL=${adb.serial}", "ADB_VENDOR_KEYS=${adb.vendorKeys}",
"DD_AGENT=1", "BROWSER=/root/.local/bin/android-open", "DD_BROWSER_DISPLAY=${AgentAdb.browserDisplay}",
)
val shell = listOf(
"/bin/sh", "-c",
// proot ends the adb server with the command, so each command that needs adb connects it.
(if (connectAdb) "adb start-server >/dev/null 2>&1; adb connect \"\$ADB_SERIAL\" >/dev/null 2>&1; " else "") +
"cd \"$1\" 2>/dev/null; shift; exec \"$@\"", "sh", workdir,
)
val proot = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), env + shell + command)
return ProcessBuilder(proot).redirectErrorStream(true).apply {
environment().apply {
remove("LD_PRELOAD"); remove("LD_AUDIT"); remove("LD_LIBRARY_PATH")
put("PROOT_LOADER", LinuxRuntime.prootLoader(app).path)
put("PROOT_TMP_DIR", LinuxRuntime.prootTempDir(app).path)
if (SessionPrefs.prootNoSeccomp(app)) put("PROOT_NO_SECCOMP", "1")
LinuxRuntime.prootLibraryPath(app).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
}
}
}
fun guestFileExists(context: Context, path: String): Boolean {
val file = File(LinuxRuntime.rootDir(context), path.removePrefix("/"))
return file.exists() || Files.isSymbolicLink(file.toPath())
}
private fun workspace(app: Context) = if (guestFileExists(app, WORKSPACE)) WORKSPACE else "/root"
private fun apply(app: Context, event: AgentEvent) {
event.conversation?.takeIf { it != conversation }?.let { id ->
conversation = id
chat?.let { AgentChats.appendConversation(AgentChats.file(app, it), id) }
}
event.text?.let { live += it }
event.thinking?.let { liveThinking += it }
event.context?.let { context = it }
for (entry in event.entries) when (entry) {
is Entry.Finished -> finish(app, entry.error, usage = event.usage)
else -> {
if (entry is Entry.Said) live = ""
if (entry is Entry.Thought) liveThinking = ""
record(app, entry)
}
}
AgentHostService.update(app)
}
private fun finish(app: Context, error: String?, stopped: Boolean = false, usage: Usage? = null) {
if (state != State.WORKING) return
turn = null
if (!stopped) labFromTurn = false
live = ""
liveThinking = ""
record(app, Entry.Finished(error, stopped, usage))
state = State.IDLE
unseen = true
failed = error != null
AgentHostService.stop(app)
if (!stopped && queue.isNotEmpty()) {
val next = queue.removeAt(0)
send(app, next.text, next.files)
}
}
private fun record(app: Context, entry: Entry) {
if (!AgentChats.merge(entries, entry)) return
chat?.let { AgentChats.append(AgentChats.file(app, it), entry) }
}
/** The tool call still running in this turn, if any. */
fun running(): Entry.Used? {
if (state != State.WORKING) return null
val start = entries.indexOfLast { it is Entry.Asked }
return entries.withIndex().lastOrNull { (i, e) -> i > start && e is Entry.Used && e.output == null }?.value as? Entry.Used
}
/** The latest todo list the agent wrote in this conversation. */
fun todos(): String? = entries.lastOrNull { it is Entry.Used && it.tool == "TodoWrite" }?.let { (it as Entry.Used).input }
private fun tick() {
now = SystemClock.elapsedRealtime()
appContext?.let { app ->
claimLabRequest(app)
lab = LabProgress.read(LinuxRuntime.rootDir(app))
AgentHostService.update(app)
}
main.postDelayed(::tick, if (state == State.WORKING || lab != null) TICK_MS else IDLE_TICK_MS)
}
/**
* dd-lab-build run by the agent hands its build to the app: the turn's processes end with the
* turn, and a build must outlive a turn that ends early or is stopped.
*/
private fun claimLabRequest(app: Context) {
val request = File(app.filesDir, LAB_REQUEST)
if (!request.isFile) return
val claimed = File(app.filesDir, "$LAB_REQUEST.claimed")
if (!request.renameTo(claimed)) return
val o = runCatching { org.json.JSONObject(claimed.readText()) }.getOrNull()
claimed.delete()
if (o == null || labProcess?.isAlive == true) return
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
val cwd = o.optString("cwd").ifEmpty { WORKSPACE }
labFromTurn = state == State.WORKING
chat?.let { prefs(app).edit().putString(LAB_CHAT_KEY, it).apply() }
AgentHostService.setupStarted(app)
Thread({
runCatching {
val child = guest(app, listOf("sh", "-c",
"mkdir -p /root/.cache; DD_LAB_DETACHED=1 exec dd-lab-build \"\$@\" > /root/.cache/dd-lab-build.log 2>&1", "dd-lab-build") + args, cwd).start()
main.post { labProcess = child }
child.outputStream.close()
child.inputStream.readBytes()
child.waitFor()
}.onFailure { Log.w(TAG, "could not run a Lab build", it) }
main.post {
labProcess = null
labFromTurn = false
AgentHostService.setupEnded(app)
}
}, "agent-lab-build").start()
}
fun cancelLabBuild(context: Context) {
val progress = lab ?: return
LabProgress.cancel(LinuxRuntime.rootDir(context.applicationContext), progress)
lab = LabProgress.read(LinuxRuntime.rootDir(context.applicationContext))
}
private const val LEGACY_CONVERSATION_KEY = "session_id"
private const val HARNESS_KEY = "harness"
private const val CHAT_KEY = "chat"
private const val PLAN_KEY = "plan"
private const val RESUME = "agent/resume"
private const val LAB_REQUEST = "agent/lab-request"
private const val LAB_CHAT_KEY = "lab_chat"
private fun modelKey(harness: AgentHarness) = "model_${harness.id}"
internal fun prefs(context: Context) = context.getSharedPreferences("agent", Context.MODE_PRIVATE)
}
@@ -0,0 +1,110 @@
package com.droiddeck.launcher.agent
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.os.SystemClock
import com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
/** Keeps the app process alive while the agent works, and says what it is doing, with a way to stop it. */
class AgentHostService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
if (AgentHost.lab?.running == true) AgentHost.cancelLabBuild(this)
AgentHost.stop(this)
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, build(this))
return START_NOT_STICKY
}
companion object {
private const val CHANNEL_ID = "agent"
private const val NOTIFICATION_ID = 4107
private const val ACTION_STOP = "com.droiddeck.launcher.agent.STOP"
private var lastText: String? = null
@Volatile private var started = false
/** Setup steps that keep the app alive while they run, besides the agent's turn. */
private var setupSteps = 0
fun start(context: Context) {
lastText = null
started = true
runCatching { context.startForegroundService(Intent(context, AgentHostService::class.java)) }
}
fun stop(context: Context) {
if (setupSteps > 0) return
started = false
context.stopService(Intent(context, AgentHostService::class.java))
}
fun setupStarted(context: Context) {
setupSteps++
start(context)
}
fun setupEnded(context: Context) {
setupSteps = (setupSteps - 1).coerceAtLeast(0)
if (AgentHost.state != AgentHost.State.WORKING) stop(context)
}
/** Refreshes the notification with the current step; unchanged text is not re-posted. */
fun update(context: Context) {
if (!started) return
val text = describe(context)
if (text == lastText) return
lastText = text
context.getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, build(context))
}
private fun describe(context: Context): String {
val lab = AgentHost.lab?.takeIf { it.running }
if (lab != null) return context.getString(R.string.agent_lab_progress, lab.permille / 10, minutes(lab.etaSeconds.toLong().coerceAtLeast(0)))
if (AgentHost.state != AgentHost.State.WORKING) return context.getString(R.string.agent_setting_up)
val tool = AgentHost.running()
val elapsed = minutes((SystemClock.elapsedRealtime() - AgentHost.turnStarted) / 1000)
return if (tool != null) "${tool.tool} · ${tool.detail}".take(120) + " · $elapsed" else context.getString(R.string.agent_thinking_for, elapsed)
}
private fun minutes(seconds: Long) = if (seconds < 60) "${seconds.coerceAtLeast(0)}s" else "${seconds / 60}m ${seconds % 60}s"
private fun build(context: Context): Notification {
val manager = context.getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, context.getString(R.string.agent_channel_name),
NotificationManager.IMPORTANCE_LOW).apply { setShowBadge(false) })
val open = PendingIntent.getActivity(context, 0,
Intent(context, FrontEndActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent"),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val stop = PendingIntent.getService(context, 1, Intent(context, AgentHostService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val lab = AgentHost.lab?.takeIf { it.running }
return Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(context.getString(when {
lab?.prepare == true -> R.string.agent_lab_preparing
lab != null -> R.string.agent_lab_building
else -> R.string.agent_working
}))
.setContentText(describe(context))
.setContentIntent(open)
.addAction(Notification.Action.Builder(null, context.getString(R.string.agent_stop), stop).build())
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.apply { if (lab != null) setProgress(1000, lab.permille, lab.stage != "building") }
.setCategory(Notification.CATEGORY_PROGRESS)
.build()
}
}
}
@@ -0,0 +1,105 @@
package com.droiddeck.launcher.agent
import android.os.Process
import android.os.SystemClock
import androidx.annotation.VisibleForTesting
import java.io.File
/**
* Linux userland processes, read from /proc: proot runs them as real processes under the app's
* own uid, so the app can list and end them without a shell.
*/
object AgentProcesses {
data class Proc(
val pid: Int, val ppid: Int, val name: String, val args: String,
val seconds: Long, val rssKb: Long, val cpuPercent: Int, val agent: Boolean,
)
private const val HZ = 100L
private var lastTicks = emptyMap<Int, Long>()
private var lastAt = 0L
@VisibleForTesting
internal class Stat(val pid: Int, val ppid: Int, val ticks: Long, val startTicks: Long, val rssPages: Long)
/** Guest processes, those under [turnPid] (the agent's current turn) first. */
@Synchronized
fun snapshot(turnPid: Int): List<Proc> {
val me = Process.myPid()
val uid = Process.myUid()
val stats = HashMap<Int, Stat>()
val args = HashMap<Int, String>()
for (dir in File("/proc").listFiles().orEmpty()) {
val pid = dir.name.toIntOrNull() ?: continue
if (pid == me) continue
val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
if (!owned(dir, uid)) continue
stats[pid] = stat
args[pid] = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).trimEnd('\u0000').replace('\u0000', ' ') }.getOrDefault("")
}
val now = SystemClock.elapsedRealtime()
val uptimeTicks = now * HZ / 1000
val elapsed = (now - lastAt).coerceAtLeast(1)
fun isProot(pid: Int) = args[pid].orEmpty().substringBefore(' ').endsWith("proot.so") || args[pid].orEmpty().substringBefore(' ').endsWith("/proot")
fun ancestors(pid: Int) = generateSequence(stats[pid]?.ppid) { stats[it]?.ppid }.takeWhile { it > 1 }.take(64)
val out = stats.values.filter { s ->
!isProot(s.pid) && args[s.pid].orEmpty().isNotEmpty() && ancestors(s.pid).any { isProot(it) }
}.map { s ->
val full = args[s.pid].orEmpty()
val cpu = lastTicks[s.pid]?.let { ((s.ticks - it) * 1000 * 100 / HZ / elapsed).toInt().coerceIn(0, 800) } ?: 0
Proc(
s.pid, s.ppid, full.substringBefore(' ').substringAfterLast('/'), full,
((uptimeTicks - s.startTicks) / HZ).coerceAtLeast(0), s.rssPages * 4, cpu,
turnPid > 0 && ancestors(s.pid).any { it == turnPid },
)
}
lastTicks = stats.mapValues { it.value.ticks }
lastAt = now
return out.sortedWith(compareByDescending<Proc> { it.agent }.thenBy { it.seconds })
}
/** Ends [pid] and everything it started, children first so none is left to be re-parented. */
fun killTree(pid: Int) {
if (pid <= 0) return
val children = HashMap<Int, MutableList<Int>>()
for (dir in File("/proc").listFiles().orEmpty()) {
val child = dir.name.toIntOrNull() ?: continue
val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
children.getOrPut(stat.ppid) { mutableListOf() } += child
}
val order = mutableListOf<Int>()
fun walk(p: Int) {
children[p].orEmpty().forEach(::walk)
order += p
}
walk(pid)
order.forEach { runCatching { Process.sendSignal(it, Process.SIGNAL_KILL) } }
}
fun alive(pid: Int) = pid > 0 && File("/proc/$pid").exists()
/** Processes whose command line contains [text], for ending a build the app did not start. */
fun find(text: String): List<Int> = File("/proc").listFiles().orEmpty().mapNotNull { dir ->
val pid = dir.name.toIntOrNull() ?: return@mapNotNull null
if (pid == Process.myPid()) return@mapNotNull null
val args = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).replace('\u0000', ' ') }.getOrDefault("")
pid.takeIf { args.contains(text) && owned(dir, Process.myUid()) }
}
private fun owned(dir: File, uid: Int) = runCatching {
File(dir, "status").useLines { lines -> lines.first { it.startsWith("Uid:") }.split(Regex("\\s+"))[1].toInt() == uid }
}.getOrDefault(false)
/** `/proc/<pid>/stat`: the name in parentheses may hold spaces, so fields count from its end. */
@VisibleForTesting
internal fun parseStat(text: String): Stat {
val pid = text.substringBefore(' ').toInt()
val f = text.substring(text.lastIndexOf(')') + 2).split(' ')
return Stat(pid, f[1].toInt(), f[11].toLong() + f[12].toLong(), f[19].toLong(), f[21].toLong())
}
/** The pid of a [java.lang.Process] the app started. */
fun pidOf(process: java.lang.Process): Int =
runCatching { process.javaClass.getDeclaredField("pid").apply { isAccessible = true }.getInt(process) }.getOrNull()
?: Regex("pid=(\\d+)").find(process.toString())?.groupValues?.get(1)?.toInt() ?: 0
}
@@ -0,0 +1,149 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.util.concurrent.TimeUnit
/**
* First-run setup for the agent, as steps with a check and an action each. The first three are
* needed to talk to the agent; GitHub and the build tools are only needed to open pull requests
* and to make Lab builds on the device.
*/
object AgentSetup {
enum class Step(val required: Boolean) { CONNECTION(true), INSTALL(true), SIGN_IN(true), GITHUB(false), BUILD_TOOLS(false), PREPARE(false) }
enum class Status { UNKNOWN, DONE, TODO, WORKING, FAILED }
val status = mutableStateMapOf<Step, Status>()
/** The last line of output from the step that is running or just failed. */
val detail = mutableStateMapOf<Step, String>()
var runtimeInstalled by mutableStateOf(true)
private set
val ready: Boolean get() = Step.entries.filter { it.required }.all { status[it] == Status.DONE }
/** Set once the required steps have all passed, so the chat opens without waiting for the checks. */
fun completed(context: Context) = AgentHost.prefs(context).getBoolean(completedKey(), false)
private fun completedKey() = "setup_completed_${AgentHost.harness.id}"
private val main = Handler(Looper.getMainLooper())
private const val REPO = "https://github.com/Droid-Deck/DroidDeck.git"
private const val CHECKOUT = "/root/DroidDeck"
private val HELPERS = listOf("android-open", "dd-gh-login")
fun check(context: Context) {
val app = context.applicationContext
runtimeInstalled = LinuxRuntime.isInstalled(app)
if (!runtimeInstalled) return
AgentHost.refresh(app)
val harness = AgentHost.harness
val probes = mutableListOf<Pair<Step, List<String>>>()
for (step in Step.entries) if (status[step] != Status.WORKING) {
when (step) {
Step.INSTALL -> set(step, if (AgentHost.guestFileExists(app, harness.binary)) Status.DONE else Status.TODO)
Step.BUILD_TOOLS -> set(step, if (buildToolsInstalled(app)) Status.DONE else Status.TODO)
Step.PREPARE -> set(step, if (prepared(app)) Status.DONE else Status.TODO)
Step.CONNECTION -> probes += step to listOf("sh", "-c",
"for i in 1 2 3; do adb connect \"\$ADB_SERIAL\" >/dev/null 2>&1; " +
"adb -s \"\$ADB_SERIAL\" get-state 2>&1 | tee /dev/stderr | grep -q device && exit 0; sleep 1; done; exit 1")
Step.SIGN_IN -> if (AgentHost.guestFileExists(app, harness.binary)) probes += step to harness.signedIn else set(step, Status.TODO)
Step.GITHUB -> probes += step to listOf("gh", "auth", "status")
}
}
Thread({ for ((step, command) in probes) probe(app, step, command) }, "agent-checks").start()
}
/** Runs [step]'s action. [CONNECTION] is the app's own pairing flow, so the caller opens it. */
fun run(context: Context, step: Step) {
val app = context.applicationContext
val harness = AgentHost.harness
val command = when (step) {
Step.CONNECTION -> return
Step.INSTALL -> {
listOf("sh", "-c", "set -e; export DEBIAN_FRONTEND=noninteractive; " +
"apt-get install -y --no-install-recommends git gh adb curl ca-certificates python3; ${harness.install}")
}
Step.SIGN_IN -> harness.signIn
Step.GITHUB -> listOf("dd-gh-login")
Step.BUILD_TOOLS -> listOf("sh", "-c", "set -e; [ -d $CHECKOUT/.git ] || git clone --filter=blob:none $REPO $CHECKOUT; " +
"cd $CHECKOUT; [ -x tools/agent/setup ] || { echo 'This DroidDeck version has no tools/agent/setup yet'; exit 1; }; tools/agent/setup")
Step.PREPARE -> listOf("sh", "-c", "cd $CHECKOUT && dd-lab-build --prepare")
}
set(step, Status.WORKING)
detail.remove(step)
AgentHostService.setupStarted(app)
Thread({
val result = runCatching {
if (step == Step.INSTALL) installHelpers(app)
val child = AgentHost.guest(app, command).start()
child.outputStream.close()
child.inputStream.bufferedReader().forEachLine { line ->
val text = line.trim()
if (text.isNotEmpty()) main.post { detail[step] = text.take(160) }
}
child.waitFor()
}
main.post {
AgentHostService.setupEnded(app)
if (result.getOrNull() == 0) {
detail.remove(step)
status[step] = Status.UNKNOWN
check(app)
} else {
status[step] = Status.FAILED
if (result.isFailure) detail[step] = result.exceptionOrNull()?.message ?: "failed"
}
}
}, "agent-setup-${step.name.lowercase()}").start()
}
fun buildToolsInstalled(app: Context) = AgentHost.guestFileExists(app, "/root/aapt2/aapt2") && AgentHost.guestFileExists(app, "$CHECKOUT/.git")
/** dd-lab-build compiled this install's commit, so a Lab build compiles only what changed. */
private fun prepared(app: Context) = runCatching {
File(LinuxRuntime.rootDir(app), "root/.cache/dd-lab-build-base").readText().trim()
.startsWith(com.droiddeck.launcher.lab.LabOverlay.apkCommit.take(7))
}.getOrDefault(false)
/** Drops what the checks found, which belonged to the previous harness. */
fun forget() {
status.clear()
detail.clear()
}
private fun probe(app: Context, step: Step, command: List<String>) {
var output = ""
val ok = runCatching {
val child = AgentHost.guest(app, command, connectAdb = step == Step.CONNECTION).start()
child.outputStream.close()
output = child.inputStream.bufferedReader().readText()
child.waitFor(60, TimeUnit.SECONDS) && child.exitValue() == 0
}.onFailure { output = it.message.orEmpty() }.getOrDefault(false)
main.post {
if (status[step] != Status.WORKING) status[step] = if (ok) Status.DONE else Status.TODO
val last = output.lines().lastOrNull { it.isNotBlank() }?.trim()?.take(160)
if (!ok && step.required && last != null) detail[step] = last else if (ok) detail.remove(step)
if (ready) AgentHost.prefs(app).edit().putBoolean(completedKey(), true).apply()
}
}
private fun set(step: Step, value: Status) {
if (Looper.myLooper() == Looper.getMainLooper()) status[step] = value else main.post { status[step] = value }
}
private fun installHelpers(app: Context) {
val bin = File(LinuxRuntime.rootDir(app), "root/.local/bin").apply { mkdirs() }
for (name in HELPERS) {
val target = File(bin, name)
app.assets.open("agent/$name").use { input -> target.outputStream().use { input.copyTo(it) } }
target.setExecutable(true, false)
}
}
}
@@ -0,0 +1,95 @@
package com.droiddeck.launcher.agent
import android.content.Context
import android.os.Handler
import android.os.Looper
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.mutableStateMapOf
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.io.IOException
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
/**
* What the agent adds to the Linux userland, measured and removable piece by piece. Sign-ins
* (~/.claude, ~/.codex, gh's config) are left alone, so removing a CLI and installing it again
* does not sign out.
*/
object AgentStorage {
enum class Item(val paths: List<String>) {
CLAUDE_CODE(listOf("/root/.local/share/claude", "/root/.local/bin/claude", "/root/cc")),
CODEX(listOf("/root/.local/bin/codex")),
BUILD_TOOLS(listOf("/root/android-sdk", "/root/aapt2", "/root/x86root", "/root/.gradle")),
SOURCE(listOf("/root/DroidDeck")),
CACHES(listOf("/root/.cache", "/root/.npm")),
}
/** Bytes per item; missing while it is being measured. */
val sizes = mutableStateMapOf<Item, Long>()
val removing = mutableStateMapOf<Item, Boolean>()
private val main = Handler(Looper.getMainLooper())
fun measure(context: Context) {
val root = LinuxRuntime.rootDir(context.applicationContext)
Thread({
for (item in Item.entries) {
val bytes = item.paths.sumOf { size(File(root, it.removePrefix("/")).toPath()) }
main.post { sizes[item] = bytes }
}
}, "agent-storage").start()
}
fun remove(context: Context, item: Item) {
val app = context.applicationContext
val root = LinuxRuntime.rootDir(app)
removing[item] = true
Thread({
for (path in item.paths) runCatching { delete(File(root, path.removePrefix("/")).toPath()) }
main.post {
removing.remove(item)
AgentHost.refresh(app)
AgentSetup.check(app)
measure(app)
}
}, "agent-storage-remove").start()
}
/** Walks without following links, so a link out of the rootfs (into /sdcard, say) counts and removes only itself. */
@VisibleForTesting
internal fun size(path: Path): Long {
if (!Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return 0
var total = 0L
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
total += attrs.size()
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException) = FileVisitResult.CONTINUE
})
return total
}
@VisibleForTesting
internal fun delete(path: Path) {
if (!Files.exists(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return
Files.walkFileTree(path, object : SimpleFileVisitor<Path>() {
override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
Files.deleteIfExists(file)
return FileVisitResult.CONTINUE
}
override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult {
Files.deleteIfExists(dir)
return FileVisitResult.CONTINUE
}
override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult {
runCatching { Files.deleteIfExists(file) }
return FileVisitResult.CONTINUE
}
})
}
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.agent
import androidx.annotation.VisibleForTesting
import org.json.JSONObject
import java.io.File
/** A Lab build in progress, as dd-lab-build reports it in `~/.cache/dd-lab-build.json`. */
data class LabProgress(
val stage: String, val prepare: Boolean, val permille: Int, val task: String,
val done: Int, val total: Int, val etaSeconds: Int, val elapsedSeconds: Int, val pid: Int,
) {
val running get() = stage in RUNNING
val failed get() = stage == "failed"
companion object {
private val RUNNING = setOf("checking", "fetching", "sdk", "planning", "building", "packaging", "restarting")
private const val PATH = "root/.cache/dd-lab-build.json"
/** A finished build stays on screen this long. */
private const val SHOW_DONE_MS = 60_000L
/** The build, while it runs and shortly after; null when there is none to show. */
fun read(rootfs: File): LabProgress? {
val file = File(rootfs, PATH)
if (!file.isFile) return null
val parsed = parse(runCatching { file.readText() }.getOrNull() ?: return null) ?: return null
val progress = when {
!parsed.running || AgentProcesses.alive(parsed.pid) -> parsed
// The restart ends the app and the script with it, so a restart that got that far succeeded.
parsed.stage == "restarting" -> parsed.copy(stage = "ready")
else -> parsed.copy(stage = "failed", task = "stopped")
}
return progress.takeIf { it.running || System.currentTimeMillis() - file.lastModified() < SHOW_DONE_MS }
}
/** Ends the build and Gradle's daemon, which keeps compiling after its client is gone. */
fun cancel(rootfs: File, progress: LabProgress) {
AgentProcesses.killTree(progress.pid)
AgentProcesses.find("GradleDaemon").forEach(AgentProcesses::killTree)
runCatching {
File(rootfs, PATH).writeText(JSONObject().put("stage", "failed").put("task", "stopped").put("pid", 0).toString())
}
}
@VisibleForTesting
internal fun parse(json: String): LabProgress? = runCatching {
val o = JSONObject(json)
LabProgress(
o.getString("stage"), o.optString("mode") == "prepare", o.optInt("permille"), o.optString("task"),
o.optInt("done"), o.optInt("total"), o.optInt("eta", -1), o.optInt("elapsed"), o.optInt("pid"),
)
}.getOrNull()
}
}
@@ -0,0 +1,60 @@
package com.droiddeck.launcher.lab
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 com.droiddeck.launcher.FrontEndActivity
import com.droiddeck.launcher.R
import com.droiddeck.launcher.stores.download.StoreDownloadService
/**
* Says what happened to a Lab build when the app starts, so a restart onto a new build does not
* look like a crash, and a build Android or the safety checks turned off is not lost silently.
*/
object LabNotice {
private const val CHANNEL_ID = "lab"
private const val NOTIFICATION_ID = 4120
/** Where a start that just moved a Lab build into use opens; the agent that built it sets it. */
@Volatile var openOnInstall: String = "updates"
fun started(context: Context) {
val app = context.applicationContext
val active = LabOverlay.active
val skipped = LabOverlay.skipped
val current = LabOverlay.current(app)
when {
LabOverlay.promoted && active != null ->
post(app, app.getString(R.string.lab_installed_title), app.getString(R.string.lab_installed_text, active.label), openOnInstall)
skipped == LabOverlay.SKIPPED_APP_IMAGE && current != null ->
post(app, app.getString(R.string.lab_paused_title), app.getString(R.string.lab_paused_text, current.label), "updates")
skipped == LabOverlay.Reason.OTHER_INSTALL.name || skipped == LabOverlay.Reason.DID_NOT_START.name -> {
val reason = if (skipped == LabOverlay.Reason.OTHER_INSTALL.name) R.string.lab_off_other_install else R.string.lab_off_did_not_start
post(app, app.getString(R.string.lab_off_title), app.getString(reason), "updates")
}
}
}
private fun post(app: Context, title: String, text: String, nav: String) {
val manager = app.getSystemService(NotificationManager::class.java) ?: return
manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, app.getString(R.string.upd_lab), NotificationManager.IMPORTANCE_DEFAULT))
val open = PendingIntent.getActivity(
app, NOTIFICATION_ID,
Intent(app, FrontEndActivity::class.java).putExtra(StoreDownloadService.EXTRA_NAV, nav)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification = Notification.Builder(app, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle(title)
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setContentIntent(open)
.setAutoCancel(true)
.build()
runCatching { manager.notify(NOTIFICATION_ID, notification) }
}
}
@@ -0,0 +1,267 @@
package com.droiddeck.launcher.lab
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.content.res.loader.ResourcesLoader
import android.content.res.loader.ResourcesProvider
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import com.droiddeck.launcher.BuildConfig
import org.json.JSONObject
import java.io.File
/**
* A Lab build: app code and resources an on-device agent built from this install's own commit,
* loaded over the installed APK at startup so it can be tried without reinstalling or re-signing.
*
* `files/lab/next` holds a build waiting for the next start and `files/lab/active` the one in use,
* each with `overlay.apk` (the build's dex files and linked resources) and `overlay.json`. A build
* that is replaced, turned off or refused moves to `files/lab/builds`, so it can be used again.
*/
object LabOverlay {
data class Info(val label: String, val baseCommit: String, val baseApk: String, val built: Long = 0)
enum class Reason { REPLACED, TURNED_OFF, OTHER_INSTALL, DID_NOT_START, UNREADABLE }
/** A build kept in `files/lab/builds`; [usable] when it was made for this install. */
data class Saved(val dir: File, val info: Info?, val reason: Reason?, val usable: Boolean)
private const val TAG = "LabOverlay"
private const val MAX_FAILED_STARTS = 2
private const val STABLE_AFTER_MS = 10_000L
private const val KEEP_BUILDS = 12
const val SKIPPED_APP_IMAGE = "app image present"
/** The installed APK's commit; this class always comes from the APK, never from a Lab build. */
val apkCommit: String = BuildConfig.SOURCE_COMMIT
@Volatile var active: Info? = null
private set
@Volatile var skipped: String? = null
private set
/** This start moved a newly staged build into use. */
@Volatile var promoted = false
private set
private var loader: Any? = null
@VisibleForTesting
internal var injectDex: (ClassLoader, File) -> Unit = ::prependDex
@VisibleForTesting
internal var openResources: (File) -> Any? = { if (Build.VERSION.SDK_INT >= 30) createLoader(it) else null }
@VisibleForTesting
internal var appImage: (Context) -> Boolean = ::hasAppImage
fun root(context: Context) = File(context.filesDir, "lab")
/** Called from [Application.attachBaseContext], before any class the build may change is loaded. */
fun load(app: Application, mainProcess: Boolean) {
active = null
skipped = null
promoted = false
try {
val root = root(app)
if (mainProcess) promoted = promote(root)
val dir = File(root, "active")
val apk = File(dir, "overlay.apk")
val meta = File(dir, "overlay.json")
if (!apk.isFile || !meta.isFile) return
val info = parse(meta.readText()) ?: return refuse(root, mainProcess, Reason.UNREADABLE)
if (!madeFor(app, info)) return refuse(root, mainProcess, Reason.OTHER_INSTALL)
if (Build.VERSION.SDK_INT < 30) return skip("needs Android 11")
// Classes in an app image are loaded before attachBaseContext and would ignore the overlay;
// dd-lab-build recompiles the app without one (cmd package compile -m verify).
if (appImage(app)) return skip(SKIPPED_APP_IMAGE)
if (mainProcess) {
val starts = failedStarts(root) + 1
if (starts > MAX_FAILED_STARTS) return refuse(root, true, Reason.DID_NOT_START)
File(root, "starts").writeText(starts.toString())
}
apk.setReadOnly()
loader = openResources(apk)
injectDex(app.classLoader, apk)
active = info
addLoader(app.resources)
app.registerActivityLifecycleCallbacks(Callbacks(root, mainProcess))
} catch (e: Throwable) {
Log.w(TAG, "Lab build not loaded", e)
skipped = e.message ?: e.javaClass.simpleName
}
}
/** The APK's path changes with every install and update of the app, so it pins a build to one install. */
fun madeFor(context: Context, info: Info) = info.baseCommit == apkCommit && info.baseApk == context.applicationInfo.sourceDir
/** The build in `active`, whether or not this start loaded it. */
fun current(context: Context): Info? = read(File(root(context), "active"))
/** A build staged for the next start. */
fun pending(context: Context): Info? = read(File(root(context), "next"))
/** Kept builds, newest first. */
fun saved(context: Context): List<Saved> = File(root(context), "builds").listFiles().orEmpty()
.filter { File(it, "overlay.apk").isFile }
.sortedByDescending { it.name.toLongOrNull() ?: 0L }
.map { dir ->
val info = read(dir)
val reason = File(dir, "reason").takeIf { it.isFile }?.readText()?.trim()?.let { r -> Reason.entries.firstOrNull { it.name == r } }
Saved(dir, info, reason, info != null && madeFor(context, info))
}
/** Leaves the Lab build and restarts the app on the installed APK. */
fun turnOff(context: Context) {
val root = root(context)
archive(root, File(root, "active"), Reason.TURNED_OFF)
File(root, "next").deleteRecursively()
File(root, "starts").delete()
restart(context)
}
/** Stages [build] for the next start; the caller restarts the app. */
fun stage(context: Context, build: Saved) {
val root = root(context)
File(root, "next").deleteRecursively()
File(build.dir, "reason").delete()
if (!build.dir.renameTo(File(root, "next"))) error("could not stage ${build.dir.name}")
}
fun delete(build: Saved) {
build.dir.deleteRecursively()
}
/**
* Android's background optimization can give the APK an app image again, which keeps a Lab
* build from loading. Recompiling without one needs a shell, which the caller provides.
*/
fun needsRecompile(context: Context) = appImage(context)
fun recompileCommand(context: Context) = "cmd package compile -m verify -f ${context.packageName}"
fun restart(context: Context) {
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (launch != null) context.startActivity(launch)
Runtime.getRuntime().exit(0)
}
@VisibleForTesting
internal fun parse(json: String): Info? = runCatching {
val o = JSONObject(json)
Info(o.getString("label"), o.getString("baseCommit"), o.getString("baseApk"), o.optLong("built"))
}.getOrNull()
private fun read(dir: File): Info? = File(dir, "overlay.json").takeIf { it.isFile && File(dir, "overlay.apk").isFile }?.let { parse(it.readText()) }
/** Moves `next` into use and keeps the build it replaces. Returns whether there was one to move. */
@VisibleForTesting
internal fun promote(root: File): Boolean {
val next = File(root, "next")
if (!File(next, "overlay.apk").isFile || !File(next, "overlay.json").isFile) return false
val active = File(root, "active")
archive(root, active, Reason.REPLACED)
active.deleteRecursively()
if (!next.renameTo(active)) return false
File(root, "starts").delete()
return true
}
@VisibleForTesting
internal fun archive(root: File, dir: File, reason: Reason) {
if (!File(dir, "overlay.apk").isFile) return
val builds = File(root, "builds").apply { mkdirs() }
var stamp = System.currentTimeMillis()
while (File(builds, stamp.toString()).exists()) stamp++
val target = File(builds, stamp.toString())
if (!dir.renameTo(target)) return
File(target, "reason").writeText(reason.name)
builds.listFiles().orEmpty().sortedByDescending { it.name.toLongOrNull() ?: 0L }.drop(KEEP_BUILDS).forEach { it.deleteRecursively() }
}
@VisibleForTesting
internal fun failedStarts(root: File) = File(root, "starts").takeIf { it.isFile }?.readText()?.trim()?.toIntOrNull() ?: 0
private fun refuse(root: File, mainProcess: Boolean, reason: Reason) {
skipped = reason.name
Log.w(TAG, "Lab build turned off: $reason")
if (!mainProcess) return
archive(root, File(root, "active"), reason)
File(root, "starts").delete()
File(root, "last-off-reason").writeText(reason.name)
}
private fun skip(reason: String) {
skipped = reason
Log.w(TAG, "Lab build not loaded: $reason")
}
private fun hasAppImage(context: Context): Boolean {
val apk = File(context.applicationInfo.sourceDir)
val oat = File(apk.parentFile, "oat")
return oat.listFiles().orEmpty().any { File(it, "${apk.nameWithoutExtension}.art").isFile }
}
@RequiresApi(30)
private fun createLoader(apk: File): ResourcesLoader =
ParcelFileDescriptor.open(apk, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
ResourcesLoader().apply { addProvider(ResourcesProvider.loadFromApk(fd)) }
}
/** Gives [resources] the Lab build's resources; contexts made from them inherit it. */
fun addLoader(resources: Resources) {
if (Build.VERSION.SDK_INT < 30) return
val current = loader as? ResourcesLoader ?: return
resources.addLoaders(current)
}
private fun prependDex(classLoader: ClassLoader, apk: File) {
val pathList = field(Class.forName("dalvik.system.BaseDexClassLoader"), "pathList").get(classLoader)!!
val elementsField = field(pathList.javaClass, "dexElements")
@Suppress("UNCHECKED_CAST") val installed = elementsField.get(pathList) as Array<Any?>
val errors = ArrayList<java.io.IOException>()
val made = try {
pathList.javaClass.getDeclaredMethod("makeDexElements", List::class.java, File::class.java, List::class.java, ClassLoader::class.java)
.apply { isAccessible = true }.invoke(null, listOf(apk), null, errors, classLoader)
} catch (_: NoSuchMethodException) {
pathList.javaClass.getDeclaredMethod(
"makeDexElements", List::class.java, File::class.java, List::class.java, ClassLoader::class.java, Boolean::class.javaPrimitiveType,
).apply { isAccessible = true }.invoke(null, listOf(apk), null, errors, classLoader, false)
}
errors.firstOrNull()?.let { throw it }
@Suppress("UNCHECKED_CAST") val added = made as Array<Any?>
if (added.isEmpty()) error("no dex in ${apk.name}")
val merged = java.lang.reflect.Array.newInstance(installed.javaClass.componentType!!, added.size + installed.size)
System.arraycopy(added, 0, merged, 0, added.size)
System.arraycopy(installed, 0, merged, added.size, installed.size)
elementsField.set(pathList, merged)
}
private fun field(type: Class<*>, name: String) = type.getDeclaredField(name).apply { isAccessible = true }
private class Callbacks(private val root: File, private val mainProcess: Boolean) : Application.ActivityLifecycleCallbacks {
private var settled = false
override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) = addLoader(activity.resources)
override fun onActivityResumed(activity: Activity) {
if (settled || !mainProcess) return
settled = true
Handler(Looper.getMainLooper()).postDelayed({ File(root, "starts").delete() }, STABLE_AFTER_MS)
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}
}
@@ -0,0 +1,99 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.core.animateFloat
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.outlined.AutoAwesome
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentHost
private object BubblePlace {
var x by mutableStateOf(Float.NaN)
var y by mutableStateOf(Float.NaN)
var hidden by mutableStateOf(false)
}
/**
* The agent's bubble over a session: there once the agent has been used, pulsing while it works,
* marked when a reply or failure has not been seen. Drag it to either edge; a long press hides it
* until the next session.
*/
@Composable
internal fun AgentBubble(onOpen: () -> Unit) {
if (!AgentHost.installed || BubblePlace.hidden || AgentHost.entries.isEmpty()) return
val colors = MaterialTheme.colorScheme
val density = androidx.compose.ui.platform.LocalDensity.current
val size = 56.dp
val sizePx = with(density) { size.toPx() }
val margin = with(density) { 12.dp.toPx() }
val working = AgentHost.state == AgentHost.State.WORKING
val pulse = androidx.compose.animation.core.rememberInfiniteTransition(label = "agentPulse")
val ring by pulse.animateFloat(
0.35f, 1f,
androidx.compose.animation.core.infiniteRepeatable(androidx.compose.animation.core.tween(900), androidx.compose.animation.core.RepeatMode.Reverse),
label = "agentRing",
)
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
val width = constraints.maxWidth.toFloat()
val height = constraints.maxHeight.toFloat()
if (BubblePlace.x.isNaN()) {
BubblePlace.x = width - sizePx - margin
BubblePlace.y = height * 0.3f
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.offset { androidx.compose.ui.unit.IntOffset(BubblePlace.x.toInt(), BubblePlace.y.toInt()) }
.size(size)
.graphicsLayer { alpha = if (working || AgentHost.unseen) 1f else 0.7f }
.clip(androidx.compose.foundation.shape.CircleShape)
.background(colors.surface)
.border(
2.dp,
if (working) colors.primary.copy(alpha = ring) else if (AgentHost.failed && AgentHost.unseen) colors.error else colors.outlineVariant,
androidx.compose.foundation.shape.CircleShape,
)
.pointerInput(width, height) {
detectDragGestures(onDragEnd = {
BubblePlace.x = if (BubblePlace.x + sizePx / 2 < width / 2) margin else width - sizePx - margin
}) { change, drag ->
change.consume()
BubblePlace.x = (BubblePlace.x + drag.x).coerceIn(0f, width - sizePx)
BubblePlace.y = (BubblePlace.y + drag.y).coerceIn(0f, height - sizePx)
}
}
.pointerInput(Unit) {
detectTapGestures(onTap = { onOpen() }, onLongPress = { BubblePlace.hidden = true })
}
.testTag("agent-bubble"),
) {
Icon(androidx.compose.material.icons.Icons.Outlined.AutoAwesome, stringResource(R.string.rail_agent), tint = colors.primary)
if (AgentHost.unseen && !working) Box(
Modifier.align(Alignment.TopEnd).padding(6.dp).size(10.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(if (AgentHost.failed) colors.error else colors.primary),
)
}
}
}
@@ -0,0 +1,297 @@
package com.droiddeck.launcher.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Psychology
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentAttachments
import com.droiddeck.launcher.agent.AgentHost.Entry
@Composable
internal fun AgentEntry(entry: Entry, running: Boolean) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
when (entry) {
is Entry.Asked -> Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (entry.files.isNotEmpty()) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { entry.files.forEach { AttachmentChip(it, null) } }
if (entry.text.isNotEmpty()) SelectionContainer {
Text(entry.text, fontSize = 15.sp, color = pal.onPrimary,
modifier = Modifier.widthIn(max = 560.dp).clip(Shape16).background(pal.primary).padding(horizontal = 14.dp, vertical = 10.dp))
}
}
is Entry.Said -> SelectionContainer { AgentMarkdown(entry.text, Modifier.widthIn(max = 760.dp).padding(horizontal = 4.dp)) }
is Entry.Thought -> AgentThought(entry.text, live = false)
is Entry.Used -> if (entry.tool == "TodoWrite") AgentTodos(entry.input) else AgentToolCall(entry, running)
is Entry.Result -> Unit
is Entry.Finished -> AgentTurnEnd(entry)
}
}
@Composable
private fun AgentTurnEnd(entry: Entry.Finished) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val text = when {
entry.stopped -> stringResource(R.string.agent_stopped)
entry.error != null -> stringResource(R.string.agent_failed, entry.error)
else -> entry.usage?.let { u ->
buildList {
if (u.durationMs > 0) add(clock(u.durationMs / 1000))
if (u.costUsd >= 0.005) add("$" + String.format(java.util.Locale.US, "%.2f", u.costUsd))
if (u.inputTokens > 0 || u.outputTokens > 0) add(stringResource(R.string.agent_tokens, tokens(u.inputTokens), tokens(u.outputTokens)))
}.joinToString(" · ")
}
} ?: return
if (text.isEmpty()) return
Text(text, fontSize = 12.sp, color = if (entry.error != null && !entry.stopped) colors.error else pal.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp))
}
internal fun tokens(n: Long) = if (n >= 1000) "${n / 1000}k" else n.toString()
@Composable
internal fun AgentThought(text: String, live: Boolean) {
val pal = LocalPalette.current
var open by rememberSaveable(text.take(40)) { mutableStateOf(false) }
Column(Modifier.padding(horizontal = 4.dp).animateContentSize()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clip(Shape12).clickable { open = !open }.padding(vertical = 4.dp)) {
Icon(Icons.Outlined.Psychology, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
Text(stringResource(if (live) R.string.agent_thinking else R.string.agent_thought), fontSize = 13.sp, color = pal.onSurfaceVariant,
modifier = Modifier.padding(start = 6.dp))
Icon(if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
if (open || live) Text(
if (open) text else text.takeLast(240), fontSize = 13.sp, fontStyle = FontStyle.Italic, color = pal.onSurfaceVariant, lineHeight = 19.sp,
maxLines = if (open) Int.MAX_VALUE else 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 760.dp).padding(start = 22.dp),
)
}
}
@Composable
private fun AgentTodos(list: String) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
Column(
Modifier.widthIn(max = 560.dp).padding(horizontal = 4.dp).clip(Shape12).background(pal.surfaceVariant).padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (line in list.lines().filter { it.isNotBlank() }) {
val done = line.startsWith("[x]")
val doing = line.startsWith("[~]")
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(14.dp).clip(Shape12).background(when {
done -> pal.good
doing -> pal.primary
else -> pal.line
}))
Text(line.drop(4), fontSize = 13.sp, color = if (done) pal.onSurfaceVariant else colors.onSurface,
fontWeight = if (doing) FontWeight.SemiBold else null, modifier = Modifier.padding(start = 8.dp))
}
}
}
}
/** A tool call: one line with its status, opening to the full input and output. */
@Composable
private fun AgentToolCall(entry: Entry.Used, running: Boolean) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var open by rememberSaveable(entry.id.ifEmpty { entry.detail }) { mutableStateOf(false) }
Column(
Modifier.fillMaxWidth().padding(horizontal = 4.dp).clip(Shape12).background(pal.surfaceVariant).animateContentSize(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { open = !open }.testTag("agent-tool").padding(horizontal = 10.dp, vertical = 8.dp),
) {
Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) {
when {
running -> CircularProgressIndicator(Modifier.size(13.dp), strokeWidth = 2.dp, color = pal.primary)
entry.failed -> Icon(Icons.Outlined.ErrorOutline, null, tint = colors.error, modifier = Modifier.size(16.dp))
entry.output != null -> Icon(Icons.Outlined.CheckCircle, null, tint = pal.good, modifier = Modifier.size(16.dp))
else -> Icon(Icons.Outlined.Close, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
Text(entry.tool, fontSize = 12.5.sp, fontWeight = FontWeight.SemiBold, color = pal.primary, modifier = Modifier.padding(start = 8.dp))
Text(entry.detail, fontSize = 12.5.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(start = 8.dp))
Icon(if (open) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
if (open) Column(Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (entry.input.isNotBlank()) CodeBox(stringResource(R.string.agent_tool_input), entry.input, colors.onSurface)
when {
entry.output == null && running -> Text(stringResource(R.string.agent_tool_running), fontSize = 12.sp, color = pal.onSurfaceVariant)
entry.output == null -> Text(stringResource(R.string.agent_tool_no_result), fontSize = 12.sp, color = pal.onSurfaceVariant)
entry.output.isBlank() -> Text(stringResource(R.string.agent_tool_empty), fontSize = 12.sp, color = pal.onSurfaceVariant)
else -> CodeBox(stringResource(R.string.agent_tool_output), entry.output, if (entry.failed) colors.error else colors.onSurface)
}
}
}
}
@Composable
private fun CodeBox(label: String, text: String, color: Color) {
val pal = LocalPalette.current
Column {
Text(label, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = pal.onSurfaceVariant, modifier = Modifier.padding(bottom = 3.dp))
SelectionContainer {
Text(
text, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = color, lineHeight = 17.sp,
modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).clip(Shape12).background(MaterialTheme.colorScheme.surface)
.verticalScroll(rememberScrollState()).padding(10.dp),
)
}
}
}
/** An attached file: a thumbnail for images, the name otherwise; [onRemove] adds a remove mark. */
@Composable
internal fun AttachmentChip(path: String, onRemove: (() -> Unit)?) {
val context = LocalContext.current
val pal = LocalPalette.current
Box {
if (AgentAttachments.isImage(path)) AsyncImage(
model = AgentAttachments.host(context, path), contentDescription = path.substringAfterLast('/'), contentScale = ContentScale.Crop,
modifier = Modifier.size(72.dp).clip(Shape12).background(pal.surfaceVariant),
) else Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.heightIn(min = 40.dp).clip(Shape12).background(pal.surfaceVariant).padding(horizontal = 10.dp, vertical = 8.dp),
) {
Icon(Icons.Outlined.Description, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
Text(path.substringAfterLast('/'), fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 200.dp).padding(start = 6.dp, end = if (onRemove != null) 18.dp else 0.dp))
}
if (onRemove != null) Box(
contentAlignment = Alignment.Center,
modifier = Modifier.align(Alignment.TopEnd).padding(3.dp).size(20.dp).clip(Shape12).background(MaterialTheme.colorScheme.surface).clickable(onClick = onRemove),
) {
Icon(Icons.Outlined.Close, stringResource(R.string.agent_remove_attachment), tint = pal.onSurfaceVariant, modifier = Modifier.size(14.dp))
}
}
}
/** The Markdown agents write: headings, lists, fenced code, `code` and **bold**. */
@Composable
internal fun AgentMarkdown(text: String, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
for (block in markdownBlocks(text)) when (block) {
is MdBlock.Code -> Text(block.text, fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = colors.onSurface,
modifier = Modifier.fillMaxWidth().clip(Shape12).background(pal.surfaceVariant).padding(12.dp))
is MdBlock.Line -> Text(
inlineMarkdown(block.text, pal.primary2, pal.surfaceVariant),
fontSize = if (block.heading) 17.sp else 15.sp,
fontWeight = if (block.heading) FontWeight.Bold else null,
lineHeight = 22.sp, color = colors.onSurface,
modifier = if (block.bullet) Modifier.padding(start = 12.dp) else Modifier,
)
}
}
}
internal sealed interface MdBlock {
data class Line(val text: String, val heading: Boolean = false, val bullet: Boolean = false) : MdBlock
data class Code(val text: String) : MdBlock
}
private val HEADING = Regex("^#{1,6}\\s+")
private val BULLET = Regex("^\\s*[-*+]\\s+")
internal fun markdownBlocks(text: String): List<MdBlock> {
val out = mutableListOf<MdBlock>()
val code = StringBuilder()
var inCode = false
for (raw in text.lines()) {
if (raw.trimStart().startsWith("```")) {
if (inCode) {
out += MdBlock.Code(code.toString().trimEnd('\n'))
code.clear()
}
inCode = !inCode
continue
}
if (inCode) {
code.append(raw).append('\n')
continue
}
val line = raw.trimEnd()
if (line.isBlank()) continue
val heading = HEADING.find(line)
val bullet = BULLET.find(line)
out += when {
heading != null -> MdBlock.Line(line.substring(heading.range.last + 1), heading = true)
bullet != null -> MdBlock.Line("• " + line.substring(bullet.range.last + 1), bullet = true)
else -> MdBlock.Line(line)
}
}
if (inCode && code.isNotEmpty()) out += MdBlock.Code(code.toString().trimEnd('\n'))
return out
}
internal fun inlineMarkdown(text: String, codeColor: Color, codeBackground: Color): AnnotatedString = buildAnnotatedString {
var i = 0
while (i < text.length) {
val boldEnd = if (text.startsWith("**", i)) text.indexOf("**", i + 2) else -1
val tickEnd = if (text[i] == '`') text.indexOf('`', i + 1) else -1
when {
boldEnd > i + 2 -> {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text, i + 2, boldEnd) }
i = boldEnd + 2
}
tickEnd > i + 1 -> {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBackground)) { append(text, i + 1, tickEnd) }
i = tickEnd + 1
}
else -> append(text[i++])
}
}
}
@@ -0,0 +1,389 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Stop
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentAttachments
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentHost.Entry
import com.droiddeck.launcher.agent.AgentSetup
internal enum class AgentPanel { NONE, MODEL, HISTORY, PROCESSES }
/** Slash commands the app handles itself; others go to the CLI, which has its own. */
private val LOCAL_COMMANDS = listOf("/new", "/model", "/plan", "/history", "/processes", "/stop")
private val CLI_COMMANDS = listOf("/compact", "/cost", "/review", "/init")
@Composable
internal fun AgentPage(modifier: Modifier, inputModifier: Modifier = Modifier, visible: Boolean = true) {
val context = LocalContext.current
remember { AgentHost.refresh(context) }
LaunchedEffect(AgentHost.unseen, visible) { if (visible) AgentHost.seen() }
remember { AgentSetup.check(context) }
var setup by rememberSaveable { mutableStateOf(false) }
if (setup || AgentHost.state == AgentHost.State.MISSING || !(AgentSetup.ready || AgentSetup.completed(context))) {
AgentSetupView(modifier, onDone = { setup = false })
return
}
var panel by rememberSaveable { mutableStateOf(AgentPanel.NONE) }
Column(modifier.fillMaxSize()) {
AgentHeader(panel, { panel = if (panel == it) AgentPanel.NONE else it }, onSetup = { setup = true })
when (panel) {
AgentPanel.HISTORY -> AgentHistory(Modifier.weight(1f)) { panel = AgentPanel.NONE }
AgentPanel.PROCESSES -> AgentProcessList(Modifier.weight(1f))
else -> {
if (panel == AgentPanel.MODEL) AgentModelPicker()
AgentConversation(Modifier.weight(1f))
AgentStatus()
AgentComposer(inputModifier) { panel = it }
}
}
}
}
@Composable
private fun AgentHeader(panel: AgentPanel, onPanel: (AgentPanel) -> Unit, onSetup: () -> Unit) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val working = AgentHost.state == AgentHost.State.WORKING
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.rail_agent), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
val model = AgentHost.models(context).firstOrNull { it.id == AgentHost.model }?.label ?: AgentHost.model ?: stringResource(R.string.agent_model_default)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clip(Shape12).clickable { onPanel(AgentPanel.MODEL) }.padding(vertical = 2.dp).testTag("agent-model"),
) {
val parts = listOfNotNull(AgentHost.harness.name, model, stringResource(R.string.agent_plan_short).takeIf { AgentHost.plan && AgentHost.harness.plans })
Text(parts.joinToString(" · "), fontSize = 13.sp, color = if (panel == AgentPanel.MODEL) pal.primary else pal.onSurfaceVariant)
Icon(Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
}
}
HeaderAction(Icons.Outlined.History, stringResource(R.string.agent_history), selected = panel == AgentPanel.HISTORY, tag = "agent-history") { onPanel(AgentPanel.HISTORY) }
HeaderAction(Icons.Outlined.Memory, stringResource(R.string.agent_processes), selected = panel == AgentPanel.PROCESSES, tag = "agent-processes") { onPanel(AgentPanel.PROCESSES) }
HeaderAction(Icons.Outlined.Add, stringResource(R.string.agent_new), enabled = AgentHost.entries.isNotEmpty(), tag = "agent-new") {
AgentHost.forget(context)
if (panel != AgentPanel.NONE) onPanel(panel)
}
HeaderAction(Icons.Outlined.Settings, stringResource(R.string.agent_setup_title), enabled = !working, tag = "agent-settings", onClick = onSetup)
}
}
@Composable
internal fun HeaderAction(icon: ImageVector, label: String, enabled: Boolean = true, selected: Boolean = false, tag: String? = null, onClick: () -> Unit) {
val pal = LocalPalette.current
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(start = 8.dp).size(44.dp).clip(Shape12)
.background(if (selected) pal.primary.copy(alpha = 0.2f) else pal.surfaceVariant)
.clickable(enabled = enabled, onClick = onClick)
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
) {
Icon(icon, label, tint = when {
selected -> pal.primary
enabled -> pal.onBackground
else -> pal.onSurfaceVariant.copy(alpha = 0.5f)
}, modifier = Modifier.size(22.dp))
}
}
@Composable
private fun AgentConversation(modifier: Modifier) {
val pal = LocalPalette.current
val list = rememberLazyListState()
val working = AgentHost.state == AgentHost.State.WORKING
val atBottom by remember { derivedStateOf { !list.canScrollForward } }
val items = AgentHost.entries.size + (if (working) 1 else 0)
LaunchedEffect(items) { if (items > 0) list.animateScrollToItem(items - 1) }
LaunchedEffect(AgentHost.live.length / 80, AgentHost.liveThinking.length / 80) {
if (atBottom && items > 0) list.scrollToItem(items - 1, Int.MAX_VALUE / 2)
}
if (AgentHost.entries.isEmpty() && !working) {
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Outlined.AutoAwesome, null, tint = pal.primary, modifier = Modifier.size(40.dp))
Text(stringResource(R.string.agent_empty), fontSize = 15.sp, color = pal.onSurfaceVariant,
textAlign = TextAlign.Center, modifier = Modifier.widthIn(max = 460.dp).padding(top = 12.dp))
}
return
}
val start = AgentHost.entries.indexOfLast { it is Entry.Asked }
LazyColumn(modifier.fillMaxWidth(), state = list, verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(AgentHost.entries.size) { i ->
val entry = AgentHost.entries[i]
AgentEntry(entry, running = working && i > start && entry is Entry.Used && entry.output == null)
}
if (working) item { AgentLive() }
}
}
/** The reply being written, and what the agent is doing right now. */
@Composable
private fun AgentLive() {
val pal = LocalPalette.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (AgentHost.liveThinking.isNotBlank()) AgentThought(AgentHost.liveThinking, live = true)
if (AgentHost.live.isNotBlank()) AgentMarkdown(AgentHost.live, Modifier.widthIn(max = 760.dp).padding(horizontal = 4.dp))
val running = AgentHost.running()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(4.dp)) {
CircularProgressIndicator(Modifier.size(14.dp), strokeWidth = 2.dp, color = pal.primary)
val elapsed = clock((AgentHost.now - AgentHost.turnStarted) / 1000)
Text(
if (running != null) stringResource(R.string.agent_running, running.tool, elapsed) else stringResource(R.string.agent_thinking_for, elapsed),
fontSize = 13.sp, color = pal.onSurfaceVariant,
)
}
}
}
internal fun clock(seconds: Long): String {
val s = seconds.coerceAtLeast(0)
return when {
s < 60 -> "${s}s"
s < 3600 -> "${s / 60}m ${s % 60}s"
else -> "${s / 3600}h ${s % 3600 / 60}m"
}
}
/** Above the composer: a Lab build's progress, the todo item in progress, and queued messages. */
@Composable
private fun AgentStatus() {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val lab = AgentHost.lab
if (lab != null) {
var armed by remember(lab.pid) { mutableStateOf(false) }
Column(
Modifier.fillMaxWidth().padding(top = 10.dp).clip(Shape14).background(colors.surface).border(1.dp, if (lab.failed) AttentionAmber else pal.line, Shape14)
.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-lab"),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
stringResource(if (lab.prepare) R.string.agent_lab_preparing else R.string.agent_lab_building) + " · " + labStage(lab),
fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = if (lab.failed) AttentionAmber else colors.onBackground,
)
val facts = buildList {
if (lab.running && lab.total > 0) add("${lab.done}/${lab.total}")
add(clock(lab.elapsedSeconds.toLong()))
if (lab.running && lab.etaSeconds >= 0) add(stringResource(R.string.agent_lab_left, clock(lab.etaSeconds.toLong())))
}
Text(facts.joinToString(" · "), fontSize = 12.5.sp, color = pal.onSurfaceVariant)
}
if (lab.running) SecondaryButton(stringResource(if (armed) R.string.agent_lab_cancel_confirm else R.string.agent_lab_cancel), compact = true) {
if (armed) AgentHost.cancelLabBuild(context) else armed = true
}
}
if (lab.running) {
LinearProgressIndicator(
progress = { lab.permille / 1000f }, color = pal.primary, trackColor = pal.surfaceVariant,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).height(6.dp).clip(CircleShape),
)
if (lab.task.isNotEmpty()) Text(lab.task, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
}
}
val todos = AgentHost.todos()?.lines()?.filter { it.isNotBlank() }
if (AgentHost.state == AgentHost.State.WORKING && !todos.isNullOrEmpty()) {
val doing = todos.firstOrNull { it.startsWith("[~]") } ?: todos.firstOrNull { it.startsWith("[ ]") }
val done = todos.count { it.startsWith("[x]") }
if (doing != null) Text(
"${doing.drop(4)} · $done/${todos.size}", fontSize = 13.sp, color = pal.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
)
}
for (queued in AgentHost.queue) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp).fillMaxWidth().clip(Shape12).background(pal.surfaceVariant).padding(start = 12.dp),
) {
Text(stringResource(R.string.agent_queued, queued.text), fontSize = 13.sp, color = pal.onSurfaceVariant, maxLines = 1,
overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clickable { AgentHost.unqueue(queued) }) {
Icon(Icons.Outlined.Close, stringResource(R.string.agent_unqueue), tint = pal.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
}
}
}
@Composable
private fun labStage(lab: com.droiddeck.launcher.agent.LabProgress) = when (lab.stage) {
"building" -> stringResource(R.string.agent_lab_stage_building, lab.permille / 10)
"packaging" -> stringResource(R.string.agent_lab_stage_packaging)
"restarting" -> stringResource(R.string.agent_lab_stage_restarting)
"ready", "prepared" -> stringResource(R.string.agent_lab_stage_ready)
"failed" -> stringResource(R.string.agent_lab_stage_failed, lab.task)
else -> stringResource(R.string.agent_lab_stage_starting)
}
@Composable
private fun AgentComposer(inputModifier: Modifier, onPanel: (AgentPanel) -> Unit) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var draft by rememberSaveable { mutableStateOf("") }
val working = AgentHost.state == AgentHost.State.WORKING
val send = send@{
val text = draft.trim()
when (text.substringBefore(' ')) {
"/new" -> AgentHost.forget(context)
"/model" -> onPanel(AgentPanel.MODEL)
"/plan" -> AgentHost.choosePlan(context, !AgentHost.plan)
"/history" -> onPanel(AgentPanel.HISTORY)
"/processes" -> onPanel(AgentPanel.PROCESSES)
"/stop" -> AgentHost.stop(context)
else -> {
if (text.isEmpty() && AgentAttachments.pending.isEmpty()) return@send
AgentHost.send(context, text, AgentAttachments.pending.toList())
AgentAttachments.pending.clear()
}
}
draft = ""
}
if (draft.startsWith("/") && !draft.contains(' ')) {
val matches = (LOCAL_COMMANDS + CLI_COMMANDS).filter { it.startsWith(draft) && it != draft }
if (matches.isNotEmpty()) Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 10.dp).fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
for (command in matches) Text(
command, fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = pal.primary,
modifier = Modifier.clip(Shape12).background(pal.surfaceVariant).clickable { draft = "$command " }.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
if (AgentAttachments.pending.isNotEmpty()) Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 10.dp).fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
for (path in AgentAttachments.pending.toList()) AttachmentChip(path) { AgentAttachments.pending.remove(path) }
}
var focused by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 16.dp).clip(Shape16).background(colors.surface)
.border(1.dp, if (focused) pal.primary else pal.line, Shape16).padding(start = 6.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
) {
if (LocalActivityResultRegistryOwner.current != null) AttachButton()
Box(Modifier.weight(1f).padding(start = 8.dp, top = 11.dp, bottom = 11.dp)) {
if (draft.isEmpty()) Text(
stringResource(if (working) R.string.agent_hint_working else R.string.agent_hint), fontSize = 16.sp,
color = pal.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
BasicTextField(
draft, { draft = it },
Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused }.testTag("agent-input").then(inputModifier),
textStyle = TextStyle(fontSize = 16.sp, color = colors.onSurface),
cursorBrush = SolidColor(pal.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { send() }),
maxLines = 6,
)
}
if (working) RoundButton(Icons.Outlined.Stop, stringResource(R.string.agent_stop), pal.error.copy(alpha = 0.22f), colors.error, "agent-stop") {
AgentHost.stop(context)
}
val canSend = draft.isNotBlank() || AgentAttachments.pending.isNotEmpty()
RoundButton(
Icons.AutoMirrored.Outlined.Send, stringResource(if (working) R.string.agent_queue else R.string.agent_send),
if (canSend) pal.primary else pal.surfaceVariant, if (canSend) pal.onPrimary else pal.onSurfaceVariant, "agent-send", enabled = canSend,
) { send() }
}
}
@Composable
private fun AttachButton() {
val context = LocalContext.current
val pal = LocalPalette.current
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris.isEmpty()) return@rememberLauncherForActivityResult
val chat = AgentHost.chat
Thread({
val paths = AgentAttachments.copy(context, chat, uris)
android.os.Handler(android.os.Looper.getMainLooper()).post { AgentAttachments.pending.addAll(paths) }
}, "agent-attach").start()
}
RoundButton(Icons.Outlined.AttachFile, stringResource(R.string.agent_attach), pal.surfaceVariant, pal.onBackground, "agent-attach") {
runCatching { picker.launch(arrayOf("*/*")) }
}
}
@Composable
private fun RoundButton(
icon: ImageVector, label: String, background: androidx.compose.ui.graphics.Color, tint: androidx.compose.ui.graphics.Color,
tag: String, enabled: Boolean = true, onClick: () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(start = 6.dp).size(46.dp).clip(CircleShape).background(background)
.clickable(enabled = enabled, onClick = onClick).testTag(tag),
) {
Icon(icon, label, tint = tint, modifier = Modifier.size(22.dp))
}
}
@@ -0,0 +1,182 @@
package com.droiddeck.launcher.ui
import android.text.format.DateUtils
import android.text.format.Formatter
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
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.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentChats
import com.droiddeck.launcher.agent.AgentHarness
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentProcesses
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/** The model and plan mode for the next turns; a conversation keeps going with whatever is chosen. */
@Composable
internal fun AgentModelPicker() {
val context = LocalContext.current
val pal = LocalPalette.current
val models = remember(AgentHost.harness) { AgentHost.models(context) }
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
val choices = listOf(AgentHarness.Model("", stringResource(R.string.agent_model_default))) + models
for (choice in choices) {
val id = choice.id.ifEmpty { null }
if (id == AgentHost.model) PrimaryButton(choice.label, compact = true) {}
else SecondaryButton(choice.label, compact = true) { AgentHost.chooseModel(context, id) }
}
}
if (AgentHost.harness.plans) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp)) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.agent_plan), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
Text(stringResource(R.string.agent_plan_hint), fontSize = 12.5.sp, color = pal.onSurfaceVariant)
}
ToggleSwitch(AgentHost.plan, label = stringResource(R.string.agent_plan)) { AgentHost.choosePlan(context, it) }
}
}
}
@Composable
internal fun AgentHistory(modifier: Modifier, onOpened: () -> Unit) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var version by remember { mutableIntStateOf(0) }
val chats by produceState(emptyList<AgentChats.Chat>(), version, AgentHost.entries.size) {
value = withContext(Dispatchers.IO) { AgentChats.list(context) }
}
if (chats.isEmpty()) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.agent_history_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
}
return
}
val working = AgentHost.state == AgentHost.State.WORKING
LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
items(chats, key = { it.id }) { chat ->
var armed by remember(chat.id) { mutableStateOf(false) }
val current = chat.id == AgentHost.chat
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 62.dp)
.clickable(enabled = !working || current) {
AgentHost.open(context, chat.id)
onOpened()
}
.background(if (current) pal.primary.copy(alpha = 0.12f) else colors.surface)
.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-chat"),
) {
Column(Modifier.weight(1f)) {
Text(chat.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
if (armed) stringResource(R.string.agent_history_delete_warning)
else AgentHarness.byId(chat.harness).name + " · " + when {
System.currentTimeMillis() - chat.updated < 60_000 -> stringResource(R.string.agent_just_now)
else -> DateUtils.getRelativeTimeSpanString(chat.updated, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
},
fontSize = 12.5.sp, color = if (armed) AttentionAmber else pal.onSurfaceVariant,
)
}
SecondaryButton(
stringResource(if (armed) R.string.agent_storage_confirm else R.string.agent_history_delete), compact = true,
enabled = !(current && working),
) {
if (armed) {
AgentHost.delete(context, chat.id)
version++
} else armed = true
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
}
/** Processes in the Linux userland, the agent's own first, each with a way to end it. */
@Composable
internal fun AgentProcessList(modifier: Modifier) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
var refresh by remember { mutableIntStateOf(0) }
var procs by remember { mutableStateOf<List<AgentProcesses.Proc>?>(null) }
LaunchedEffect(refresh) {
while (true) {
procs = withContext(Dispatchers.IO) { AgentProcesses.snapshot(AgentHost.turnPid) }
delay(2_000)
}
}
val list = procs ?: return
if (list.isEmpty()) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(stringResource(R.string.agent_processes_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
}
return
}
LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
items(list, key = { it.pid }) { proc ->
var open by remember(proc.pid) { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-process")) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(proc.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
if (proc.agent) Text(stringResource(R.string.agent_process_agent), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = pal.primary,
modifier = Modifier.clip(Shape12).background(pal.primary.copy(alpha = 0.15f)).padding(horizontal = 6.dp, vertical = 1.dp))
}
Text(
listOf("pid ${proc.pid}", clock(proc.seconds), Formatter.formatShortFileSize(context, proc.rssKb * 1024), "${proc.cpuPercent}% CPU").joinToString(" · "),
fontSize = 12.5.sp, color = pal.onSurfaceVariant,
)
}
SecondaryButton(stringResource(R.string.agent_process_stop), compact = true) {
AgentProcesses.killTree(proc.pid)
refresh++
}
}
SelectionContainer {
Text(proc.args, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
maxLines = if (open) 40 else 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
}
}
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
}
@@ -0,0 +1,179 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.PairingGuideActivity
import com.droiddeck.launcher.R
import com.droiddeck.launcher.agent.AgentHarness
import com.droiddeck.launcher.agent.AgentHost
import com.droiddeck.launcher.agent.AgentSetup
import com.droiddeck.launcher.agent.AgentStorage
import android.text.format.Formatter
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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 com.droiddeck.launcher.agent.AgentSetup.Status
import com.droiddeck.launcher.agent.AgentSetup.Step
import com.droiddeck.launcher.core.WirelessAdbPairingService
@Composable
internal fun AgentSetupView(modifier: Modifier, onDone: () -> Unit = {}) {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
LaunchedEffect(AgentHost.harness) {
AgentSetup.check(context)
AgentStorage.measure(context)
}
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(stringResource(R.string.agent_setup_title), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
if (!AgentSetup.runtimeInstalled) {
Note(stringResource(R.string.agent_setup_runtime))
return@Column
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
for (harness in AgentHarness.all) {
val chosen = harness == AgentHost.harness
if (chosen) PrimaryButton(harness.name, compact = true) {} else SecondaryButton(harness.name, compact = true) { AgentHost.choose(context, harness) }
}
}
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
val steps = Step.entries
steps.forEachIndexed { index, step ->
val status = AgentSetup.status[step] ?: Status.UNKNOWN
val (title, hint) = labels(step, AgentHost.harness.name)
val lab = AgentHost.lab?.takeIf { it.prepare && it.running }
val detail = if (step == Step.PREPARE && lab != null)
stringResource(R.string.agent_prepare_progress, lab.permille / 10, clock(lab.etaSeconds.toLong().coerceAtLeast(0)))
else AgentSetup.detail[step] ?: hint
CheckRow(
when (status) {
Status.DONE -> CheckState.OK
Status.WORKING, Status.UNKNOWN -> CheckState.BUSY
Status.TODO, Status.FAILED -> CheckState.WARN
},
title, detail, divider = index < steps.lastIndex,
) {
val blocked = (step == Step.SIGN_IN && AgentSetup.status[Step.INSTALL] != Status.DONE) ||
(step == Step.PREPARE && AgentSetup.status[Step.BUILD_TOOLS] != Status.DONE)
if ((status == Status.TODO || status == Status.FAILED) && !blocked) {
val label = stringResource(if (status == Status.FAILED) R.string.agent_setup_retry else action(step))
val run = { if (step == Step.CONNECTION) pair(context) else AgentSetup.run(context, step) }
if (step.required) PrimaryButton(label, compact = true, onClick = run) else SecondaryButton(label, compact = true, onClick = run)
}
}
}
}
if (AgentSetup.ready) PrimaryButton(stringResource(R.string.agent_setup_done), main = true, onClick = onDone)
StorageCard()
}
}
@Composable
private fun StorageCard() {
val context = LocalContext.current
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val items = AgentStorage.Item.entries.filter { (AgentStorage.sizes[it] ?: 0L) > 0L }
if (items.isEmpty()) return
val total = items.sumOf { AgentStorage.sizes[it] ?: 0L }
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxWidth().padding(top = 10.dp)) {
Text(stringResource(R.string.agent_storage_title), fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.weight(1f))
Text(Formatter.formatShortFileSize(context, total), fontSize = 14.sp, color = pal.onSurfaceVariant)
}
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
items.forEachIndexed { index, item ->
var armed by remember(item) { mutableStateOf(false) }
val busy = AgentStorage.removing[item] == true
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp),
) {
Column(Modifier.weight(1f)) {
Text(stringResource(storageTitle(item)), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(
if (armed) stringResource(storageWarning(item)) else Formatter.formatShortFileSize(context, AgentStorage.sizes[item] ?: 0L),
fontSize = 13.sp, color = if (armed) AttentionAmber else pal.onSurfaceVariant,
)
}
SecondaryButton(
stringResource(if (armed) R.string.agent_storage_confirm else R.string.agent_storage_remove),
enabled = !busy && AgentHost.state != AgentHost.State.WORKING, compact = true,
) {
if (armed) {
armed = false
AgentStorage.remove(context, item)
} else armed = true
}
}
if (index < items.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
}
private fun storageTitle(item: AgentStorage.Item) = when (item) {
AgentStorage.Item.CLAUDE_CODE -> R.string.agent_storage_claude
AgentStorage.Item.CODEX -> R.string.agent_storage_codex
AgentStorage.Item.BUILD_TOOLS -> R.string.agent_step_build
AgentStorage.Item.SOURCE -> R.string.agent_storage_source
AgentStorage.Item.CACHES -> R.string.agent_storage_caches
}
private fun storageWarning(item: AgentStorage.Item) = when (item) {
AgentStorage.Item.SOURCE -> R.string.agent_storage_source_warning
AgentStorage.Item.CACHES -> R.string.agent_storage_caches_warning
else -> R.string.agent_storage_reinstall_warning
}
@Composable
private fun labels(step: Step, harness: String): Pair<String, String> = when (step) {
Step.CONNECTION -> stringResource(R.string.agent_step_connection) to stringResource(R.string.agent_step_connection_hint)
Step.INSTALL -> stringResource(R.string.agent_step_install, harness) to stringResource(R.string.agent_step_install_hint)
Step.SIGN_IN -> stringResource(R.string.agent_step_sign_in, harness) to stringResource(R.string.agent_step_sign_in_hint)
Step.GITHUB -> stringResource(R.string.agent_step_github) to stringResource(R.string.agent_step_github_hint)
Step.BUILD_TOOLS -> stringResource(R.string.agent_step_build) to stringResource(R.string.agent_step_build_hint)
Step.PREPARE -> stringResource(R.string.agent_step_prepare) to stringResource(R.string.agent_step_prepare_hint)
}
private fun action(step: Step): Int = when (step) {
Step.CONNECTION -> R.string.agent_action_pair
Step.INSTALL -> R.string.agent_action_install
Step.SIGN_IN, Step.GITHUB -> R.string.agent_action_sign_in
Step.BUILD_TOOLS -> R.string.agent_action_install
Step.PREPARE -> R.string.agent_action_prepare
}
/** The pairing DroidDeck already uses for the phantom process fix; the agent reuses its key. */
private fun pair(context: android.content.Context) {
if (!WirelessAdbPairingService.notificationsEnabled(context)) {
context.startActivity(android.content.Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
return
}
WirelessAdbPairingService.start(context)
PairingGuideActivity.start(context, null, false)
}
@@ -38,7 +38,8 @@ object BuildWatermark {
if (text.length <= TITLE_LIMIT) text else text.take(TITLE_LIMIT - 1).trimEnd() + "…"
fun install(app: Application) {
val lines = lines() ?: return
val lab = com.droiddeck.launcher.lab.LabOverlay.active?.let { "LAB | ${truncate(it.label)}" }
val lines = (listOfNotNull(lab) + lines().orEmpty()).ifEmpty { return }
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity is QuestXrActivity) return
@@ -270,6 +270,10 @@ private fun Content(
DesktopPage(s, a, modifier, onSelect)
return
}
if (selected == "agent") {
AgentPage(modifier.padding(horizontal = padH).padding(top = padV))
return
}
if (selected == "android-apps" && s.isHomeApp) {
AndroidAppsPage(s, modifier, onAndroidAppClick)
return
@@ -47,6 +47,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LocalMall
@@ -123,7 +124,9 @@ internal fun BottomBar(
internal fun navigationKeys(s: FrontEndState) = buildList {
add("steam"); add("games")
if (s.gameStoresEnabled) add("stores")
add("desktop"); add("components")
add("desktop")
if (com.droiddeck.launcher.agent.AgentHost.available) add("agent")
add("components")
if (s.isHomeApp) add("android-apps")
add("setup"); add("updates")
}
@@ -156,7 +159,7 @@ internal fun SideRail(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
val count = navigationKeys(s).size
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
val shown = Hops.railPreview ?: selected
@@ -197,6 +200,7 @@ internal fun railLabelIcon(key: String): Pair<Int, ImageVector> = when (key) {
"stores" -> R.string.rail_stores to Icons.Outlined.LocalMall
"desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows
"components" -> R.string.rail_components to Icons.Outlined.Layers
"agent" -> R.string.rail_agent to Icons.Outlined.AutoAwesome
"android-apps" -> R.string.rail_apps to Icons.Outlined.Apps
"setup" -> R.string.rail_setup to Icons.Outlined.Tune
else -> R.string.rail_updates to Icons.Outlined.SystemUpdate
@@ -84,7 +84,7 @@ import com.droiddeck.launcher.stores.download.StoreDownloadTier
import com.droiddeck.launcher.R
private enum class CheckState { OK, WARN, BUSY }
internal enum class CheckState { OK, WARN, BUSY }
private const val STORAGE_TAB = 3
private const val LAUNCHER_TAB = 2
@@ -97,7 +97,7 @@ private const val GLASSES_TAB = 6
private const val SYNC_TAB = 4
@Composable
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
internal fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val tint = when (state) {
@@ -690,7 +690,7 @@ internal fun SetupPanel(
}
@Composable
private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
internal fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val ready = passing == total
@@ -0,0 +1,161 @@
package com.droiddeck.launcher.ui
import android.text.format.DateUtils
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.lab.LabOverlay
/**
* Lab builds on this device: the one in use, one waiting for a restart, and the kept ones.
* [onRebuild] offers to build a kept one again for this install, when something can.
*/
@Composable
internal fun LabBuilds(onRebuild: ((LabOverlay.Info) -> Unit)? = null) {
val context = LocalContext.current
var version by remember { mutableIntStateOf(0) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val current = remember(version) { LabOverlay.current(context) }
val pending = remember(version) { LabOverlay.pending(context) }
val saved = remember(version) { LabOverlay.saved(context) }
if (current == null && pending == null && saved.isEmpty()) return
val active = LabOverlay.active
val paused = active == null && current != null && LabOverlay.skipped == LabOverlay.SKIPPED_APP_IMAGE
val restartFailed = stringResource(R.string.lab_restart_failed)
/** Android may have given the APK an app image again, which a Lab build cannot load over. */
fun restart() {
busy = true
error = null
Thread({
val result = runCatching {
if (LabOverlay.needsRecompile(context)) WirelessAdbFix.shell(context, LabOverlay.recompileCommand(context))
}
android.os.Handler(android.os.Looper.getMainLooper()).post {
busy = false
if (result.isSuccess) LabOverlay.restart(context)
else error = restartFailed.format(result.exceptionOrNull()?.message.orEmpty())
}
}, "lab-restart").start()
}
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {
Text(
stringResource(R.string.lab_builds).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
)
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
val rows = mutableListOf<@Composable () -> Unit>()
if (current != null) rows += {
BuildRow(
current, true,
when {
active != null -> stringResource(R.string.lab_in_use)
paused -> stringResource(R.string.lab_paused_row)
else -> stringResource(R.string.lab_not_loaded, LabOverlay.skipped.orEmpty())
},
warn = active == null,
) {
if (paused) PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
SecondaryButton(stringResource(R.string.lab_turn_off), compact = true, enabled = !busy) { LabOverlay.turnOff(context) }
}
}
if (pending != null) rows += {
BuildRow(pending, false, stringResource(R.string.lab_waiting), warn = false) {
PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
}
}
for (build in saved) rows += {
var armed by remember(build.dir) { mutableStateOf(false) }
val info = build.info
BuildRow(
info, false,
when {
armed -> stringResource(R.string.lab_delete_warning)
!build.usable -> stringResource(R.string.lab_other_install)
build.reason == LabOverlay.Reason.DID_NOT_START -> stringResource(R.string.lab_off_did_not_start)
else -> stringResource(R.string.lab_kept)
},
warn = armed || !build.usable || build.reason == LabOverlay.Reason.DID_NOT_START,
) {
if (!build.usable && !armed && info != null && onRebuild != null) SecondaryButton(stringResource(R.string.lab_rebuild), compact = true, enabled = !busy) {
onRebuild(info)
}
if (build.usable && !armed) SecondaryButton(stringResource(R.string.lab_use), compact = true, enabled = !busy) {
runCatching { LabOverlay.stage(context, build) }
.onSuccess { restart() }
.onFailure { error = it.message }
}
SecondaryButton(stringResource(if (armed) R.string.lab_delete_confirm else R.string.lab_delete), compact = true, enabled = !busy) {
if (armed) {
LabOverlay.delete(build)
version++
} else armed = true
}
}
}
rows.forEachIndexed { index, row ->
row()
if (index < rows.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
}
}
error?.let { Text(it, fontSize = 12.5.sp, color = AttentionAmber, modifier = Modifier.padding(start = 2.dp, top = 8.dp)) }
}
}
@Composable
private fun BuildRow(info: LabOverlay.Info?, current: Boolean, status: String, warn: Boolean, actions: @Composable () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(34.dp).clip(Shape12)
.background(if (current) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant)) {
Icon(Icons.Outlined.Code, null, tint = if (current) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
}
Column(Modifier.weight(1f)) {
Text(info?.label ?: "?", fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
maxLines = 1, overflow = TextOverflow.Ellipsis)
val built = info?.built?.takeIf { it > 0 }?.let { DateUtils.getRelativeTimeSpanString(it * 1000, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() }
Text(listOfNotNull(status, built).joinToString(" · "), fontSize = 12.5.sp, color = if (warn) AttentionAmber else colors.onSurfaceVariant,
maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { actions() }
}
}
@@ -9,12 +9,16 @@ internal enum class SessionDrawerPage(val firstControl: String) {
EFFECTS("look"),
VR("steamvr"),
GAMES("anisotropy"),
AGENT("agent-input"),
SESSION("suspend"),
}
internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List<SessionDrawerPage> =
internal fun sessionDrawerPages(
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
): List<SessionDrawerPage> =
SessionDrawerPage.entries.filter {
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES)
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) &&
(hasAgent || it != SessionDrawerPage.AGENT)
}
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -43,6 +44,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.ScreenShare
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.PictureInPictureAlt
@@ -172,6 +174,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
SessionDrawerPage.VR -> R.string.vr_title
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
SessionDrawerPage.GAMES -> R.string.game_settings_title
SessionDrawerPage.AGENT -> R.string.rail_agent
SessionDrawerPage.SESSION -> R.string.mode_session
}
@@ -184,6 +187,7 @@ private val SessionDrawerPage.icon get() = when (this) {
SessionDrawerPage.VR -> VrHeadsetIcon
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
SessionDrawerPage.AGENT -> Icons.Outlined.AutoAwesome
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
}
@@ -762,6 +766,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
track = { key -> focus.track(page, key) },
)
}
SessionDrawerPage.AGENT -> AgentPage(
Modifier.fillMaxWidth()
.height((LocalConfiguration.current.screenHeightDp - 190).coerceAtLeast(240).dp)
.padding(horizontal = 14.dp),
inputModifier = focus.track(page, "agent-input"),
visible = open,
)
SessionDrawerPage.SESSION -> {
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
@@ -285,6 +285,13 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
tests.forEach { t -> TestRow(t, u.follow.pr == t.pr) { ua.onFollow(Follow(Channel.TEST, t.pr)) } }
}
}
val context = androidx.compose.ui.platform.LocalContext.current
LabBuilds(onRebuild = if (com.droiddeck.launcher.agent.AgentHost.installed) { info ->
com.droiddeck.launcher.agent.AgentHost.send(context, context.getString(R.string.agent_rebuild_lab, info.label))
context.startActivity(android.content.Intent(context, com.droiddeck.launcher.FrontEndActivity::class.java)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent")
.addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT))
} else null)
}
}
+110
View File
@@ -1118,6 +1118,116 @@
<string name="upd_tests">Test builds</string>
<string name="upd_tests_hint">Try a fix before it\'s released</string>
<string name="upd_tests_none">None right now</string>
<string name="upd_lab">Lab build</string>
<string name="lab_builds">Lab builds</string>
<string name="lab_in_use">In use</string>
<string name="lab_paused_row">Paused after Android re-optimized DroidDeck</string>
<string name="lab_not_loaded">Not loaded: %1$s</string>
<string name="lab_waiting">Waiting for a restart</string>
<string name="lab_kept">Kept</string>
<string name="lab_other_install">For another version</string>
<string name="lab_rebuild">Rebuild</string>
<string name="lab_delete_warning">Deletes this build from the device</string>
<string name="lab_restart">Restart on it</string>
<string name="lab_turn_off">Turn off</string>
<string name="lab_use">Use</string>
<string name="lab_delete">Delete</string>
<string name="lab_delete_confirm">Delete now</string>
<string name="lab_restart_failed">Could not prepare the restart: %1$s. Pair wireless debugging in Setup and try again.</string>
<string name="lab_installed_title">Lab build installed</string>
<string name="lab_installed_text">DroidDeck restarted on %1$s</string>
<string name="lab_paused_title">Lab build paused</string>
<string name="lab_paused_text">Android re-optimized DroidDeck, so %1$s is not loaded. Open Updates to restart on it.</string>
<string name="lab_off_title">Lab build turned off</string>
<string name="lab_off_other_install">It was built for another version of DroidDeck. It is kept in Updates.</string>
<string name="lab_off_did_not_start">DroidDeck did not start on it twice</string>
<string name="rail_agent">Agent</string>
<string name="agent_channel_name">Agent</string>
<string name="agent_working">Agent is working</string>
<string name="agent_missing">Install Claude Code in the Linux userland to use the agent.</string>
<string name="agent_hint">Ask about a game, a crash, or a change to DroidDeck</string>
<string name="agent_send">Send</string>
<string name="agent_new">New conversation</string>
<string name="agent_stop">Stop</string>
<string name="agent_storage_title">Storage</string>
<string name="agent_storage_remove">Remove</string>
<string name="agent_storage_confirm">Remove now</string>
<string name="agent_storage_claude">Claude Code</string>
<string name="agent_storage_codex">Codex</string>
<string name="agent_storage_source">DroidDeck source</string>
<string name="agent_storage_caches">Download caches</string>
<string name="agent_storage_source_warning">Changes that are not pushed are lost</string>
<string name="agent_storage_caches_warning">Builds download these again</string>
<string name="agent_storage_reinstall_warning">Setup can install it again; sign-ins are kept</string>
<string name="agent_resumed">DroidDeck restarted on your Lab build. Continue where you left off.</string>
<string name="agent_empty">Ask it why a game won\'t start, to explain a log, or to change DroidDeck and try the change on this device.</string>
<string name="agent_failed">Failed: %1$s</string>
<string name="agent_exited">%1$s ended (exit %2$d)</string>
<string name="agent_stopped">Stopped</string>
<string name="agent_setting_up">Setting up the agent</string>
<string name="agent_rebuild_lab">Rebuild my Lab build %1$s for the installed version of DroidDeck and restart on it.</string>
<string name="agent_tokens">%1$s in · %2$s out</string>
<string name="agent_thinking">Thinking</string>
<string name="agent_thought">Thought</string>
<string name="agent_thinking_for">Thinking · %1$s</string>
<string name="agent_running">%1$s · %2$s</string>
<string name="agent_tool_input">INPUT</string>
<string name="agent_tool_output">OUTPUT</string>
<string name="agent_tool_running">Running…</string>
<string name="agent_tool_no_result">No result: the turn ended first</string>
<string name="agent_tool_empty">No output</string>
<string name="agent_hint_working">Type to queue the next message</string>
<string name="agent_queue">Queue</string>
<string name="agent_queued">Next: %1$s</string>
<string name="agent_unqueue">Don\'t send</string>
<string name="agent_attach">Attach</string>
<string name="agent_remove_attachment">Remove</string>
<string name="agent_model_default">Default</string>
<string name="agent_plan">Plan first</string>
<string name="agent_plan_short">Plan</string>
<string name="agent_plan_hint">Proposes a plan without changing anything</string>
<string name="agent_history">Conversations</string>
<string name="agent_history_empty">No conversations yet</string>
<string name="agent_just_now">Just now</string>
<string name="agent_history_delete">Delete</string>
<string name="agent_history_delete_warning">Deletes it and its attachments</string>
<string name="agent_processes">Processes</string>
<string name="agent_processes_empty">Nothing is running in the Linux userland</string>
<string name="agent_process_agent">AGENT</string>
<string name="agent_process_stop">Stop</string>
<string name="agent_lab_building">Lab build</string>
<string name="agent_lab_preparing">Preparing Lab builds</string>
<string name="agent_lab_progress">%1$d%% · about %2$s left</string>
<string name="agent_lab_left">about %1$s left</string>
<string name="agent_lab_cancel">Cancel</string>
<string name="agent_lab_cancel_confirm">Cancel build</string>
<string name="agent_lab_stage_starting">Starting</string>
<string name="agent_lab_stage_building">%1$d%%</string>
<string name="agent_lab_stage_packaging">Packaging</string>
<string name="agent_lab_stage_restarting">Restarting DroidDeck on it</string>
<string name="agent_lab_stage_ready">Done</string>
<string name="agent_lab_stage_failed">Failed: %1$s</string>
<string name="agent_setup_title">Set up the agent</string>
<string name="agent_setup_runtime">Install the Linux runtime in Setup first.</string>
<string name="agent_setup_retry">Retry</string>
<string name="agent_setup_done">Start chatting</string>
<string name="agent_step_connection">Device connection</string>
<string name="agent_step_connection_hint">Wireless debugging, so the agent can see and drive DroidDeck</string>
<string name="agent_step_install">Install %1$s</string>
<string name="agent_step_install_hint">Into the Linux userland, with git, gh and adb</string>
<string name="agent_step_sign_in">Sign in to %1$s</string>
<string name="agent_step_sign_in_hint">Opens your browser</string>
<string name="agent_step_github">GitHub</string>
<string name="agent_step_github_hint">Optional, to open pull requests</string>
<string name="agent_step_build">Build tools</string>
<string name="agent_step_build_hint">Optional, for Lab builds on this device · about 5 GB</string>
<string name="agent_step_prepare">Fast Lab builds</string>
<string name="agent_step_prepare_hint">Compiles this version ahead, so Lab builds take minutes · about 20 minutes once</string>
<string name="agent_prepare_progress">Compiling · %1$d%% · about %2$s left</string>
<string name="agent_action_prepare">Prepare</string>
<string name="agent_action_pair">Pair</string>
<string name="agent_action_install">Install</string>
<string name="agent_action_sign_in">Sign in</string>
<string name="upd_pr">PR #%1$d</string>
<string name="upd_not_built">Not built for this copy of DroidDeck</string>
<string name="upd_updated">Updated %1$s</string>
@@ -0,0 +1,148 @@
package com.droiddeck.launcher.agent
import com.droiddeck.launcher.agent.AgentHost.Entry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.file.Files
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class AgentHostTest {
@Test fun claudeInitCarriesOnlyTheConversation() {
val event = ClaudeCode.parse("""{"type":"system","subtype":"init","session_id":"abc","tools":[]}""")!!
assertEquals("abc", event.conversation)
assertEquals(emptyList<Entry>(), event.entries)
}
@Test fun claudeTextThinkingAndToolUseBecomeEntries() {
val event = ClaudeCode.parse(
"""{"type":"assistant","session_id":"abc","message":{"content":[
{"type":"text","text":" Looking at the log. "},
{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"droiddeckctl state\nmore"}},
{"type":"thinking","thinking":"why"}],"usage":{"input_tokens":10,"cache_read_input_tokens":90,"output_tokens":5}}}""",
)!!
assertEquals(listOf(Entry.Said("Looking at the log."), Entry.Used("t1", "Bash", "droiddeckctl state", "droiddeckctl state\nmore"), Entry.Thought("why")), event.entries)
assertEquals(105L, event.context)
}
@Test fun claudeStreamsTextAsItIsWritten() {
val event = ClaudeCode.parse("""{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}}""")!!
assertEquals("Hel", event.text)
assertEquals("hm", ClaudeCode.parse("""{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"hm"}}}""")!!.thinking)
assertNull(ClaudeCode.parse("""{"type":"stream_event","event":{"type":"message_start"}}"""))
}
@Test fun claudeToolResultsAnswerTheirCall() {
val event = ClaudeCode.parse(
"""{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"ok"}],"is_error":true}]}}""",
)!!
assertEquals(listOf(Entry.Result("t1", "ok", true)), event.entries)
val entries = mutableListOf<Entry>(Entry.Used("t1", "Bash", "ls", "ls"))
assertTrue(AgentChats.merge(entries, event.entries.single()))
assertEquals(Entry.Used("t1", "Bash", "ls", "ls", "ok", true), entries.single())
assertFalse(AgentChats.merge(entries, Entry.Result("unknown", "x", false)))
}
@Test fun aClaudeResultEndsTheTurnWithItsUsage() {
val event = ClaudeCode.parse("""{"type":"result","subtype":"success","is_error":false,"result":"done","duration_ms":4200,"total_cost_usd":0.05,"usage":{"input_tokens":3,"output_tokens":7}}""")!!
assertEquals(listOf(Entry.Finished(null)), event.entries)
assertEquals(AgentHost.Usage(4200, 0.05, 3, 7), event.usage)
assertEquals(listOf(Entry.Finished("Invalid API key")),
ClaudeCode.parse("""{"type":"result","subtype":"success","is_error":true,"result":"Invalid API key"}""")!!.entries)
assertNull(ClaudeCode.parse("not json"))
}
@Test fun claudeTurnsBypassPermissionsOrPlanAndTakeAModel() {
val first = ClaudeCode.turn("hi", null, AgentHarness.Options())
assertTrue("bypassPermissions" in first && "--resume" !in first && "--model" !in first && "--include-partial-messages" in first)
val next = ClaudeCode.turn("hi", "abc", AgentHarness.Options(model = "opus", plan = true))
assertEquals("abc", next[next.indexOf("--resume") + 1])
assertEquals("opus", next[next.indexOf("--model") + 1])
assertEquals("plan", next[next.indexOf("--permission-mode") + 1])
}
@Test fun claudeShowsWholeEditsAndTodos() {
assertEquals("a.kt\n\n- old\n\n+ new", ClaudeCode.fullInput("Edit", org.json.JSONObject("""{"file_path":"a.kt","old_string":"old","new_string":"new"}""")))
assertEquals("[x] read\n[~] fix\n[ ] test", ClaudeCode.fullInput("TodoWrite", org.json.JSONObject(
"""{"todos":[{"content":"read","status":"completed"},{"content":"fix","status":"in_progress"},{"content":"test","status":"pending"}]}""")))
}
@Test fun codexEventsBecomeEntries() {
assertEquals("t1", Codex.parse("""{"type":"thread.started","thread_id":"t1"}""")!!.conversation)
assertEquals(listOf(Entry.Used("i1", "Shell", "uname -m", "uname -m")),
Codex.parse("""{"type":"item.started","item":{"id":"i1","type":"command_execution","command":"uname -m","status":"in_progress"}}""")!!.entries)
assertEquals(listOf(Entry.Result("i1", "aarch64\n", false)),
Codex.parse("""{"type":"item.completed","item":{"id":"i1","type":"command_execution","command":"uname -m","aggregated_output":"aarch64\n","exit_code":0}}""")!!.entries)
assertEquals(listOf(Entry.Said("It is aarch64.")),
Codex.parse("""{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"It is aarch64."}}""")!!.entries)
assertEquals(Entry.Used("i3", "Edit", "a.kt, b.kt", "a.kt\nb.kt"),
Codex.parse("""{"type":"item.completed","item":{"id":"i3","type":"file_change","status":"completed","changes":[{"path":"a.kt"},{"path":"b.kt"}]}}""")!!.entries.first())
assertEquals(listOf(Entry.Finished(null)), Codex.parse("""{"type":"turn.completed","usage":{}}""")!!.entries)
assertEquals(listOf(Entry.Finished("quota")), Codex.parse("""{"type":"turn.failed","error":{"message":"quota"}}""")!!.entries)
assertNull(Codex.parse("""{"type":"item.started","item":{"type":"reasoning"}}"""))
}
@Test fun codexTurnsSkipItsSandboxResumeAndTakeAModel() {
assertEquals(listOf("/root/.local/bin/codex", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "hi"),
Codex.turn("hi", null, AgentHarness.Options()))
assertEquals(listOf("/root/.local/bin/codex", "exec", "resume", "t1", "--json", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-m", "gpt", "hi"),
Codex.turn("hi", "t1", AgentHarness.Options(model = "gpt")))
}
@Test fun aTurnWithoutAnEndWasInterrupted() {
assertTrue(AgentHost.interrupted(listOf(Entry.Asked("hi"), Entry.Used("1", "Bash", "dd-lab-build"))))
assertEquals(false, AgentHost.interrupted(listOf(Entry.Asked("hi"), Entry.Finished(null))))
}
@Test fun aChatRoundTripsThroughItsFile() {
val file = Files.createTempFile("chat", ".jsonl").toFile()
AgentChats.start(file, "claude")
AgentChats.appendConversation(file, "abc")
val written = listOf(
Entry.Asked("hi", listOf("/root/x.png")), Entry.Said("hello"), Entry.Thought("hm"), Entry.Used("t1", "Read", "/root/x", "{}"),
Entry.Result("t1", "contents", false), Entry.Finished(null, usage = AgentHost.Usage(1, 0.5, 2, 3)), Entry.Finished("boom", stopped = true),
)
written.forEach { AgentChats.append(file, it) }
file.appendText("broken line\n")
val loaded = AgentChats.read(file)
assertEquals("claude", loaded.harness)
assertEquals("abc", loaded.conversation)
assertEquals(listOf(
Entry.Asked("hi", listOf("/root/x.png")), Entry.Said("hello"), Entry.Thought("hm"), Entry.Used("t1", "Read", "/root/x", "{}", "contents", false),
Entry.Finished(null, usage = AgentHost.Usage(1, 0.5, 2, 3)), Entry.Finished("boom", stopped = true),
), loaded.entries)
assertEquals(emptyList<Entry>(), AgentChats.read(File(file.path + ".missing")).entries)
}
@Test fun longOutputKeepsItsEnd() {
val clipped = AgentChats.clip("a".repeat(30_000) + "END")
assertTrue(clipped.endsWith("END") && clipped.length < 30_000)
}
@Test fun attachmentsAreListedForTheAgent() {
assertEquals("look", AgentAttachments.prompt("look", emptyList()))
assertEquals("look\n\nAttached files (read them with your tools):\n- /root/a.png", AgentAttachments.prompt("look", listOf("/root/a.png")))
}
@Test fun procStatFieldsCountFromTheEndOfTheName() {
val stat = AgentProcesses.parseStat("1234 (java (x) y) S 1200 1234 1234 0 -1 4194304 10 0 0 0 150 50 0 0 20 0 30 0 98765 1000000 2048 18446744073709551615")
assertEquals(1200, stat.ppid)
assertEquals(200L, stat.ticks)
assertEquals(98765L, stat.startTicks)
assertEquals(2048L, stat.rssPages)
}
@Test fun labProgressIsRead() {
val p = LabProgress.parse("""{"stage":"building","mode":"build","permille":420,"task":":app:compileReleaseKotlin","done":120,"total":300,"eta":95,"elapsed":30,"pid":77}""")!!
assertEquals(420, p.permille)
assertTrue(p.running && !p.prepare)
assertEquals(95, p.etaSeconds)
}
}
@@ -0,0 +1,29 @@
package com.droiddeck.launcher.agent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.nio.file.Files
class AgentStorageTest {
@Test fun removingAFolderKeepsWhatItsLinksPointAt() {
val base = Files.createTempDirectory("agent-storage")
val outside = Files.createDirectories(base.resolve("sdcard"))
Files.write(outside.resolve("save.dat"), ByteArray(100))
val item = Files.createDirectories(base.resolve("rootfs/android-sdk"))
Files.write(item.resolve("a.jar"), ByteArray(10))
val linked = runCatching { Files.createSymbolicLink(item.resolve("saves"), outside) }.isSuccess
assumeTrue("symbolic links need privileges on this OS", linked)
assertTrue(AgentStorage.size(item) < 100)
AgentStorage.delete(item)
assertFalse(Files.exists(item))
assertTrue(Files.exists(outside.resolve("save.dat")))
}
@Test fun aMissingPathHasNoSize() {
assertEquals(0L, AgentStorage.size(Files.createTempDirectory("agent-storage").resolve("gone")))
}
}
@@ -0,0 +1,143 @@
package com.droiddeck.launcher.lab
import android.app.Application
import com.droiddeck.launcher.BuildConfig
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33], manifest = Config.NONE)
class LabOverlayTest {
private lateinit var app: Application
private lateinit var root: File
private val injected = mutableListOf<File>()
private val originalInject = LabOverlay.injectDex
private val originalResources = LabOverlay.openResources
@Before fun prepare() {
app = RuntimeEnvironment.getApplication()
app.applicationInfo.sourceDir = "/data/app/~~test==/com.droiddeck.launcher-test==/base.apk"
root = LabOverlay.root(app).apply { deleteRecursively(); mkdirs() }
LabOverlay.injectDex = { _, apk -> injected += apk }
LabOverlay.openResources = { null }
}
@After fun restore() {
LabOverlay.injectDex = originalInject
LabOverlay.openResources = originalResources
root.deleteRecursively()
}
private fun stage(dir: String, commit: String = BuildConfig.SOURCE_COMMIT, apk: String = app.applicationInfo.sourceDir) =
File(root, dir).apply {
mkdirs()
File(this, "overlay.apk").writeText("dex")
File(this, "overlay.json").writeText("""{"label":"feat/x @ abc1234","baseCommit":"$commit","baseApk":"$apk"}""")
}
@Test fun aBuildForThisInstallIsLoaded() {
stage("active")
LabOverlay.load(app, mainProcess = true)
assertEquals("feat/x @ abc1234", LabOverlay.active?.label)
assertEquals(listOf(File(root, "active/overlay.apk")), injected)
assertEquals(1, LabOverlay.failedStarts(root))
}
@Test fun aStagedBuildIsPromotedAtStart() {
stage("next")
File(root, "starts").writeText("2")
LabOverlay.load(app, mainProcess = true)
assertFalse(File(root, "next").exists())
assertNotNull(LabOverlay.active)
assertEquals(1, LabOverlay.failedStarts(root))
}
@Test fun aBuildForAnotherInstallIsTurnedOff() {
stage("active", commit = "0000000")
LabOverlay.load(app, mainProcess = true)
assertNull(LabOverlay.active)
assertTrue(injected.isEmpty())
assertFalse(File(root, "active").exists())
assertEquals("OTHER_INSTALL", File(root, "last-off-reason").readText())
}
@Test fun aBuildForAnEarlierInstallOfTheSameCommitIsTurnedOff() {
stage("active", apk = "/data/app/old/base.apk")
LabOverlay.load(app, mainProcess = true)
assertNull(LabOverlay.active)
assertEquals("OTHER_INSTALL", File(root, "last-off-reason").readText())
}
@Test fun aBuildThatKeepsFailingToStartIsTurnedOff() {
stage("active")
File(root, "starts").writeText("2")
LabOverlay.load(app, mainProcess = true)
assertNull(LabOverlay.active)
assertEquals("DID_NOT_START", File(root, "last-off-reason").readText())
}
@Test fun otherProcessesLoadWithoutCountingStarts() {
stage("active")
LabOverlay.load(app, mainProcess = false)
assertNotNull(LabOverlay.active)
assertEquals(0, LabOverlay.failedStarts(root))
}
@Test fun noBuildMeansNothingIsLoaded() {
LabOverlay.load(app, mainProcess = true)
assertNull(LabOverlay.active)
assertTrue(injected.isEmpty())
assertFalse(LabOverlay.promoted)
}
@Test fun aNewBuildKeepsTheOneItReplaces() {
stage("active")
stage("next")
LabOverlay.load(app, mainProcess = true)
assertTrue(LabOverlay.promoted)
val saved = LabOverlay.saved(app).single()
assertEquals(LabOverlay.Reason.REPLACED, saved.reason)
assertTrue(saved.usable)
}
@Test fun aRefusedBuildIsKeptButNotUsable() {
stage("active", commit = "0000000")
LabOverlay.load(app, mainProcess = true)
val saved = LabOverlay.saved(app).single()
assertEquals(LabOverlay.Reason.OTHER_INSTALL, saved.reason)
assertFalse(saved.usable)
}
@Test fun aKeptBuildCanBeStagedAgain() {
stage("active", commit = "0000000")
LabOverlay.load(app, mainProcess = true)
stage("active")
LabOverlay.archive(root, File(root, "active"), LabOverlay.Reason.TURNED_OFF)
val usable = LabOverlay.saved(app).first { it.usable }
LabOverlay.stage(app, usable)
assertNotNull(LabOverlay.pending(app))
assertEquals(1, LabOverlay.saved(app).size)
LabOverlay.load(app, mainProcess = true)
assertTrue(LabOverlay.promoted)
assertNotNull(LabOverlay.active)
}
@Test fun onlyTheNewestBuildsAreKept() {
repeat(15) {
stage("active")
LabOverlay.archive(root, File(root, "active"), LabOverlay.Reason.TURNED_OFF)
}
assertEquals(12, LabOverlay.saved(app).size)
}
}
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Test
class AgentMarkdownTest {
@Test fun blocksSplitHeadingsBulletsAndCode() {
val blocks = markdownBlocks("## Done\n\n- one\n* two\n```kotlin\nval a = 1\n\nval b = 2\n```\nplain")
assertEquals(
listOf(
MdBlock.Line("Done", heading = true),
MdBlock.Line("• one", bullet = true),
MdBlock.Line("• two", bullet = true),
MdBlock.Code("val a = 1\n\nval b = 2"),
MdBlock.Line("plain"),
),
blocks,
)
}
@Test fun inlineMarkersAreDroppedAndUnclosedOnesKept() {
val text = inlineMarkdown("a **b** `c` d ** e ` f", Color.Red, Color.Black)
assertEquals("a b c d ** e ` f", text.text)
assertEquals(2, text.spanStyles.size)
}
}
@@ -26,4 +26,11 @@ class SessionDrawerPageTest {
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
}
}
@Test fun theAgentPageComesBeforeSessionOnlyWhenTheAgentIsInstalled() {
val with = sessionDrawerPages(false, hasAgent = true)
assertEquals(SessionDrawerPage.AGENT, with.step(SessionDrawerPage.GAMES, 1))
assertEquals(SessionDrawerPage.SESSION, with.step(SessionDrawerPage.AGENT, 1))
assertFalse(SessionDrawerPage.AGENT in sessionDrawerPages(false, hasAgent = false))
}
}
+61
View File
@@ -0,0 +1,61 @@
# Lab builds
A Lab build is DroidDeck app code and resources built on the device itself, by the on-device agent
or by hand, and loaded over the installed APK at the next start. Nothing is reinstalled or
re-signed, so a tester on a release build keeps their games, sign-ins and settings while trying a
change.
## How it works
- `tools/agent/dd-lab-build` builds the checkout with Gradle on the device (`processReleaseResources`
and `mergeDexRelease`), packs the linked resources and dex files into `overlay.apk`, writes
`overlay.json`, and stages both in the app's `files/lab/next`. It then recompiles the app without
an app image and restarts DroidDeck.
- Run by the app's own agent (`DD_AGENT` set), it writes `files/agent/lab-request` instead, and the
app runs the build in a process of its own, logging to `~/.cache/dd-lab-build.log`; the agent's
copy follows that log and exits with the build. A turn's processes end with the turn, so a build
started there would otherwise die when the agent stops or finishes early.
- At start, `LabOverlay` (called from `App.attachBaseContext`) promotes `next` to `active`, checks
that the build was made for this install, puts its dex files ahead of the APK's, and adds its
resources through `ResourcesLoader` to the app and every activity. Contexts made from them
(presentations on the second screen) inherit the loader.
- The watermark shows `LAB | <branch> @ <commit>`. When the app comes back on a new build it posts
a "Lab build installed" notification, so the restart does not look like a crash.
- A build that is replaced, turned off or refused is kept in `files/lab/builds` (the newest 12).
Updates lists them under Lab builds: the one in use can be turned off, one built for this
install can be used again, and any can be deleted.
## What a Lab build cannot change
The manifest, native code (`cpp`, `jniLibs`, Rust), Gradle files, `App`, and `LabOverlay` itself:
they are loaded before a Lab build can be, or are not part of it. `dd-lab-build` refuses those
changes; they need a new APK.
## Safety
- A build is only used on the install it was built for: `overlay.json` carries the APK's commit
(`apkCommit` in `droiddeckctl state`) and the APK's path, which changes with every install and
update. A build for another install is turned off.
- Two starts in a row that do not reach a resumed activity for 10 seconds turn the build off.
- It needs Android 11 or later (`ResourcesLoader`) and no app image (classes in one load before the
Lab build). `dd-lab-build` runs `cmd package compile -m verify` before restarting. Android's
background optimization can bring an app image back; the build is then paused, a notification
says so, and Restart in Updates recompiles through the app's wireless debugging pairing.
## Setting up a device
From the DroidDeck terminal, in a checkout of this repository:
```sh
tools/agent/setup
```
It installs Claude Code, git, gh and loopback adb, plus the build toolchain: JDK 17, the Android
SDK platform, and Google's x86-64 `aapt2` run under `qemu-user`. The first Lab build compiles the
whole app and takes about 20 minutes on a Snapdragon 865; later ones are incremental. Setup
does that first build ahead of time with `dd-lab-build --prepare`, which compiles the installed
commit without making a Lab build.
`~/.cache/dd-lab-build.json` reports the stage, the current task, progress in thousandths and the
seconds left, estimated from how long each task took the last time (`~/.cache/dd-lab-build-times`).
`~/.cache/dd-lab-build-base` holds the installed commit the last successful build compiled, so the
Agent tab can offer to prepare again after DroidDeck updates.
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Opens a URL in Android's browser on the bottom screen.
url=$1
[ -n "$url" ] || exit 1
serial=${ADB_SERIAL:-127.0.0.1:5555}
adb connect "$serial" >/dev/null 2>&1
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
adb -s "$serial" shell "am start --display 2 -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
echo "Open this on any device: $url" >&2
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Claude Code in the DroidDeck checkout, with sign-in links opened in Android's browser.
export ADB_SERIAL=${ADB_SERIAL:-127.0.0.1:5555} PATH="$HOME/.local/bin:$PATH" BROWSER="$HOME/.local/bin/android-open"
adb connect "$ADB_SERIAL" >/dev/null 2>&1
cd "$HOME/DroidDeck" && exec claude "$@"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# GitHub sign-in through Android's browser on the bottom screen, with the one-time code typed in.
serial=${ADB_SERIAL:-127.0.0.1:5555}
adb connect "$serial" >/dev/null 2>&1
coproc GH { BROWSER=true gh auth login --hostname github.com --git-protocol https --web 2>&1; }
typed=
while IFS= read -r line <&"${GH[0]}"; do
printf '%s\n' "$line"
code=$(grep -oE '[A-Z0-9]{4}-[A-Z0-9]{4}' <<<"$line" | head -1)
if [ -n "$code" ] && [ -z "$typed" ]; then
typed=1
echo >&"${GH[1]}"
adb -s "$serial" shell "am start --display 2 -a android.intent.action.VIEW -d https://github.com/login/device" >/dev/null 2>&1
# Chrome drops characters from `input text` and from fast key events on this page.
keys=
for ((i = 0; i < ${#code}; i++)); do
c=${code:i:1}
[ "$c" = - ] || keys+="input -d 2 keyevent KEYCODE_$c; sleep 0.4; "
done
(sleep 7; adb -s "$serial" shell "$keys" >/dev/null 2>&1) &
fi
done
wait "$GH_PID"
+204
View File
@@ -0,0 +1,204 @@
#!/bin/bash
# Builds this checkout as a Lab build of the installed DroidDeck and restarts the app on it.
# Usage: dd-lab-build [--no-restart | --prepare]
# --prepare compiles the installed commit without making a Lab build, so later builds only
# compile what changed. Setup runs it once; it takes the longest.
set -euo pipefail
pkg=com.droiddeck.launcher
serial=${ADB_SERIAL:-127.0.0.1:5555}
mode=build
case "${1:-}" in
--no-restart) mode=no-restart ;;
--prepare) mode=prepare ;;
esac
started=$(date +%s)
cache=$HOME/.cache
progress_file=$cache/dd-lab-build.json
times_file=$cache/dd-lab-build-times
mkdir -p "$cache"
# The Agent tab reads this file to show how far the build has got and how long is left.
progress() { # stage [permille task done total eta]
local task=${3:-}; task=${task//\\/}; task=${task//\"/\'}; task=${task//$'\n'/ }
printf '{"stage":"%s","mode":"%s","permille":%d,"task":"%s","done":%d,"total":%d,"eta":%d,"elapsed":%d,"pid":%d}\n' \
"$1" "$mode" "${2:-0}" "$task" "${4:-0}" "${5:-0}" "${6:--1}" $(( $(date +%s) - started )) $$ > "$progress_file.part.$BASHPID"
mv "$progress_file.part.$BASHPID" "$progress_file"
}
fail() { progress failed 0 "$*"; echo "dd-lab-build: $*" >&2; exit 1; }
trap 'progress failed 0 stopped; exit 143' INT TERM
trap 'progress failed 0 "a command failed on line $LINENO"' ERR
device() { adb -s "$serial" shell "$@" | tr -d '\r'; }
# The app's agent loses every process it started when its turn ends, a build included, so it asks
# the app to run the build in a process of its own and follows that build here.
agent=/data/user/0/$pkg/files/agent
if [ -n "${DD_AGENT:-}" ] && [ -z "${DD_LAB_DETACHED:-}" ] && [ -d "$agent" ]; then
trap - ERR
log=$cache/dd-lab-build.log
rm -f "$progress_file"
python3 -c 'import json,os,sys; print(json.dumps({"cwd": os.getcwd(), "args": sys.argv[1:]}))' "$@" > "$agent/lab-request.part"
mv "$agent/lab-request.part" "$agent/lab-request"
echo "DroidDeck runs this build itself, so it keeps going if this turn ends. Its log follows."
for _ in $(seq 1 30); do [ -f "$progress_file" ] && break; sleep 1; done
[ -f "$progress_file" ] || { echo "dd-lab-build: DroidDeck did not start the build; is it running?" >&2; exit 1; }
tail -n +1 -F "$log" 2>/dev/null & follow=$!
result=1
while sleep 2; do
stage=$(sed -n 's/.*"stage":"\([a-z]*\)".*/\1/p' "$progress_file")
pid=$(sed -n 's/.*"pid":\([0-9]*\).*/\1/p' "$progress_file")
case $stage in
ready|prepared) result=0; break ;;
failed) break ;;
esac
[ -n "$pid" ] && [ -d "/proc/$pid" ] || break
done
sleep 1; kill $follow 2>/dev/null || true
exit $result
fi
progress checking
repo=$(git rev-parse --show-toplevel) || fail "run it inside the DroidDeck checkout"
cd "$repo"
adb connect "$serial" >/dev/null 2>&1 || true
state=$(python3 tools/droiddeckctl state 2>/dev/null | sed -n '/^{/,$p')
# A Lab build reports its own label, so the APK's commit comes from apkCommit when the app has it.
label=$(python3 -c 'import json,sys; s=json.load(sys.stdin); print(s.get("apkCommit") or s["build"].rsplit(" at ", 1)[-1])' <<<"$state")
if ! base=$(git rev-parse --verify --quiet "$label^{commit}"); then
progress fetching 0 "the installed commit"
git fetch --quiet --tags origin >/dev/null 2>&1 || true
git fetch --quiet origin main >/dev/null 2>&1 || true
base=$(git rev-parse --verify --quiet "$label^{commit}") || fail "the installed build ($label) is not in this checkout or on GitHub's main; fetch it first"
fi
if [ $mode = prepare ]; then
# Compiling the installed commit itself gives later builds of branches on it incremental caches.
if ! git merge-base --is-ancestor "$base" HEAD; then
git diff --quiet && git diff --cached --quiet || fail "HEAD does not build on the installed commit and has uncommitted changes"
git checkout --quiet --detach "$base"
fi
else
git diff --quiet && git diff --cached --quiet || fail "commit your changes first"
git merge-base --is-ancestor "$base" HEAD || fail "HEAD does not build on the installed commit ${base:0:9}; branch from it"
blocked=$(git diff --name-only "$base" HEAD | grep -E 'AndroidManifest\.xml$|/cpp/|/jniLibs/|\.rs$|(^|/)build\.gradle$|/App\.kt$|/lab/LabOverlay\.kt$' || true)
[ -z "$blocked" ] || fail "these need a new APK, not a Lab build: $(echo $blocked)"
fi
apk=$(device pm path $pkg | sed -n 's/^package://p' | grep 'base\.apk$' | head -1)
[ -n "$apk" ] || fail "DroidDeck is not installed"
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64 ANDROID_HOME=$HOME/android-sdk
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
if [ ! -d "$ANDROID_HOME/platforms/android-34" ]; then
progress sdk
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;35.0.0" >/dev/null
fi
# A full Kotlin compile of the app needs about 3 GB of heap; smaller devices get what they can spare.
total_mb=$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo)
heap=$(( total_mb * 2 / 5 )); [ $heap -gt 3072 ] && heap=3072; [ $heap -lt 1536 ] && heap=1536
heap=${DD_LAB_HEAP_MB:-$heap}
# Google publishes aapt2 for x86-64 Linux only; setup wraps it in qemu-user at ~/aapt2/aapt2.
gradle=(./gradlew :app:processReleaseResources :app:mergeDexRelease --max-workers=2 -PskipRust=true -x buildVkBridgeServer
-Pandroid.aapt2FromMavenOverride=$HOME/aapt2/aapt2 -Pkotlin.compiler.execution.strategy=in-process
-Dorg.gradle.jvmargs=-Xmx${heap}m --console=plain)
progress planning
mapfile -t plan < <("${gradle[@]}" --dry-run -q 2>/dev/null | awk '/^:/ {print $1}' || true)
total=${#plan[@]}
# Each task's estimate is how long it took last time; a full compile of the app takes about 15 minutes.
declare -A estimate
[ -f "$times_file" ] && while read -r t s; do estimate[$t]=$s; done < "$times_file"
estimate_of() { local e=${estimate[$1]:-}; [ -n "$e" ] && echo "$e" || { [[ $1 == *compileReleaseKotlin ]] && echo 900 || echo 2; }; }
planned=0
for t in "${plan[@]}"; do planned=$(( planned + $(estimate_of "$t") )); done
[ $planned -gt 0 ] || planned=1
current=$(mktemp)
measured=$(mktemp)
# "<tasks started> <task> <its start> <estimated seconds of tasks done> <its estimate>"
echo "0 starting $(date +%s) 0 0" > "$current"
report() {
local n task since done_s cur_est now part permille left
read -r n task since done_s cur_est < "$current"
now=$(date +%s)
part=$(( now - since )); [ $part -gt $(( cur_est * 95 / 100 )) ] && part=$(( cur_est * 95 / 100 ))
permille=$(( (done_s + part) * 1000 / planned )); [ $permille -gt 990 ] && permille=990
left=$(( planned - done_s - part )); [ $left -lt 0 ] && left=0
progress building "$permille" "$task" "$n" "$total" "$left"
echo "$n $task $(( (now - started) / 60 )) $left"
}
(
beat=0
while sleep 5; do
set -- $(report)
beat=$(( beat + 1 ))
[ $(( beat % 12 )) = 0 ] && echo "[build $1/$total] still on $2, $3m in, about $(( $4 / 60 + 1 ))m left"
done
) &
heartbeat=$!
trap 'kill $heartbeat 2>/dev/null || true; rm -f "$current" "$measured"' EXIT
set +o pipefail
"${gradle[@]}" 2>&1 | {
n=0 done_s=0 task="" since=0 cur_est=0 ran=0
while IFS= read -r line; do
printf '%s\n' "$line"
case $line in
"> Task "*)
now=$(date +%s)
# A task's line comes when it starts, or when it ends for one that did no work.
if [ -n "$task" ]; then
done_s=$(( done_s + cur_est ))
[ $ran = 1 ] && echo "$task $(( now - since ))" >> "$measured"
fi
rest=${line#> Task }; task=${rest%% *}
case $rest in *" "*) ran=0 ;; *) ran=1 ;; esac
n=$((n + 1)); since=$now; cur_est=$(estimate_of "$task")
echo "$n $task $since $done_s $cur_est" > "$current"
[ $ran = 1 ] && report >/dev/null && echo "[build $n/$total] $task" ;;
esac
done
[ -n "$task" ] && [ $ran = 1 ] && echo "$task $(( $(date +%s) - since ))" >> "$measured"
}
status=${PIPESTATUS[0]}
set -o pipefail
kill $heartbeat 2>/dev/null || true
[ "$status" = 0 ] || fail "Gradle failed (exit $status); the reason is above"
# Tasks that did no work this time keep their earlier time.
{ [ -f "$times_file" ] && cat "$times_file"; cat "$measured"; } | awk '{t[$1] = $2} END {for (k in t) print k, t[k]}' > "$times_file.part"
mv "$times_file.part" "$times_file"
# The app reads which installed commit the build caches are warm for.
echo "$base" > "$cache/dd-lab-build-base"
if [ $mode = prepare ]; then
progress prepared 1000
echo "Prepared: Lab builds of branches on ${base:0:9} now compile only what changed ($(( ($(date +%s) - started) / 60 ))m)"
exit 0
fi
progress packaging 995
linked=$(ls app/build/intermediates/linked_resources_binary_format/release/processReleaseResources/*.ap_ | head -1)
work=$(mktemp -d)
cp "$linked" "$work/overlay.apk"
(cd app/build/intermediates/dex/release/mergeDexRelease && zip -q "$work/overlay.apk" classes*.dex)
name="$(git branch --show-current) @ $(git rev-parse --short HEAD)"
python3 -c 'import json,sys,time; print(json.dumps({"label": sys.argv[1], "baseCommit": sys.argv[2], "baseApk": sys.argv[3], "built": int(time.time())}))' \
"$name" "$base" "$apk" > "$work/overlay.json"
lab=/data/user/0/$pkg/files/lab
mkdir -p "$lab"
rm -rf "$lab/next.part" "$lab/next"
mv "$work" "$lab/next.part"
mv "$lab/next.part" "$lab/next"
progress ready 1000 "$name"
echo "Lab build ready: $name ($(du -h "$lab/next/overlay.apk" | cut -f1)) in $(( ($(date +%s) - started) / 60 ))m"
[ $mode = build ] || exit 0
# Classes in an app image load before the Lab build can, so the app is recompiled without one.
device cmd package compile -m verify -f $pkg >/dev/null
progress restarting 1000 "$name"
echo "Restarting DroidDeck on the Lab build; it shows a notification when it is back"
# The app's own agent ends with the app, so it is told to carry on once the app is back.
if [ -n "${DD_AGENT:-}" ]; then mkdir -p "/data/user/0/$pkg/files/agent" && : > "/data/user/0/$pkg/files/agent/resume"; fi
device "nohup sh -c 'sleep 3; monkey -p $pkg -c android.intent.category.LAUNCHER 1' >/dev/null 2>&1 &"
for pid in $(device pidof $pkg); do kill "$pid" 2>/dev/null || true; done
+61
View File
@@ -0,0 +1,61 @@
#!/bin/bash
# Sets up the on-device agent inside DroidDeck's Linux userland: Claude Code, git and gh, loopback
# adb for droiddeckctl, and the toolchain dd-lab-build uses to build Lab builds on the device.
# Run it from the DroidDeck terminal, inside a checkout of the DroidDeck repository.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends git gh adb python3 zip unzip curl openjdk-17-jdk-headless qemu-user zipalign apksigner
command -v claude >/dev/null || [ -x "$HOME/.local/bin/claude" ] || curl -fsSL https://claude.ai/install.sh | bash
mkdir -p "$HOME/.local/bin"
for tool in dd-agent dd-lab-build dd-gh-login android-open; do install -m755 "$here/$tool" "$HOME/.local/bin/$tool"; done
grep -q 'droiddeck-agent-path' "$HOME/.bashrc" 2>/dev/null ||
printf '\n# droiddeck-agent-path\nexport PATH="$HOME/.local/bin:$PATH" ADB_SERIAL=127.0.0.1:5555 BROWSER="$HOME/.local/bin/android-open"\n' >> "$HOME/.bashrc"
# Google publishes aapt2 for x86-64 Linux only; it runs here under qemu-user with Ubuntu's x86-64 runtime.
agp=$(sed -n "s/.*com.android.tools.build:gradle:\([0-9.]*\).*/\1/p" build.gradle | head -1)
aapt2=$(curl -fsSL "https://dl.google.com/android/maven2/com/android/tools/build/aapt2/maven-metadata.xml" |
grep -o "<version>$agp-[0-9]*</version>" | tail -1 | sed 's/<[^>]*>//g')
x86=$HOME/x86root
mkdir -p "$HOME/aapt2/x86" "$x86/etc"
if [ ! -x "$HOME/aapt2/x86/aapt2" ]; then
curl -fsSL -o /tmp/aapt2.jar "https://dl.google.com/android/maven2/com/android/tools/build/aapt2/$aapt2/aapt2-$aapt2-linux.jar"
unzip -o -q /tmp/aapt2.jar aapt2 -d "$HOME/aapt2/x86"
chmod +x "$HOME/aapt2/x86/aapt2"
fi
if [ ! -e "$x86/lib64/ld-linux-x86-64.so.2" ]; then
codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
curl -fsSL "http://archive.ubuntu.com/ubuntu/dists/$codename/main/binary-amd64/Packages.gz" | gunzip > /tmp/amd64-packages
for package in libc6 libgcc-s1 libstdc++6 zlib1g; do
file=$(awk -v p="$package" '$0 == "Package: " p {found = 1} found && /^Filename:/ {print $2; exit}' /tmp/amd64-packages)
curl -fsSL -o /tmp/$package.deb "http://archive.ubuntu.com/ubuntu/$file"
dpkg-deb -x /tmp/$package.deb "$x86"
done
ln -sfn usr/lib64 "$x86/lib64"
ln -sfn usr/lib "$x86/lib"
: > "$x86/etc/ld.so.preload"
fi
# AGP only accepts a custom aapt2 whose file is named aapt2.
printf '#!/bin/sh\nexec qemu-x86_64 -L %s %s/aapt2/x86/aapt2 "$@"\n' "$x86" "$HOME" > "$HOME/aapt2/aapt2"
chmod +x "$HOME/aapt2/aapt2"
"$HOME/aapt2/aapt2" version
sdk=$HOME/android-sdk
if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then
mkdir -p "$sdk/cmdline-tools"
curl -fsSL -o /tmp/cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip
unzip -q -o /tmp/cmdline-tools.zip -d "$sdk/cmdline-tools"
rm -rf "$sdk/cmdline-tools/latest"
mv "$sdk/cmdline-tools/cmdline-tools" "$sdk/cmdline-tools/latest"
fi
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
yes | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
"$sdk/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;35.0.0" >/dev/null
adb connect 127.0.0.1:5555 || true
# The first build compiles the whole app; doing it now makes the agent's Lab builds incremental.
[ "${DD_SKIP_PREPARE:-}" = 1 ] || "$here/dd-lab-build" --prepare || echo "The first Lab build will compile the whole app"
echo "Done. Approve the USB debugging prompt if one appears, then run dd-agent."