mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-09 17:11:32 -10:00
Compare commits
35
Commits
main
...
feat/agent-host
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9c542f148 | ||
|
|
3014d0369b | ||
|
|
99b72f9095 | ||
|
|
9530d72164 | ||
|
|
a7dc7256e1 | ||
|
|
2594b0c9c3 | ||
|
|
1afd634d9f | ||
|
|
305a53c20c | ||
|
|
aea3672bd3 | ||
|
|
cc08d0dca5 | ||
|
|
f247e42329 | ||
|
|
af764059c8 | ||
|
|
6bdcd669f8 | ||
|
|
77bfbd732f | ||
|
|
2d86dbf1ed | ||
|
|
56ee9aa81a | ||
|
|
d7a6fc8fbc | ||
|
|
5d19335233 | ||
|
|
2cd572c0e0 | ||
|
|
65a52da90a | ||
|
|
5a7fda603c | ||
|
|
78b89e9ad3 | ||
|
|
65c04be072 | ||
|
|
2213362336 | ||
|
|
5683be3b9b | ||
|
|
d7dd802429 | ||
|
|
0cf83ce71d | ||
|
|
acecbcd79a | ||
|
|
b6d70b177d | ||
|
|
5f0519dfbf | ||
|
|
914c659f4a | ||
|
|
a178553254 | ||
|
|
774abb738d | ||
|
|
5d63cc3b9e | ||
|
|
f3f3f9ce08 |
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+9
@@ -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
|
||||
Executable
+5
@@ -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 "$@"
|
||||
Executable
+23
@@ -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"
|
||||
Executable
+204
@@ -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
|
||||
Executable
+61
@@ -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."
|
||||
Reference in new issue
Block a user