Compare commits

..
9 Commits
Author SHA1 Message Date
MaxsTechReviewandKurt Himebauch 13c8edb3d5 fix(game-env): repair foreign-architecture system DLLs in ARM64 prefixes (#2318)
Co-authored-by: Kurt Himebauch <[email protected]>
2026-11-10 06:24:03 -05:00
MaxsTechReview 1ec2b696b7 fix(flatpak): stop inheriting host Zink overrides (#2391)
* fix(flatpak): stop inheriting host zink overrides

* Check every cleared driver override in the Flatpak launcher test
2026-11-10 02:55:13 -05:00
MaxsTechReview 621fd8d43e fix(gog): install owned DLC (#2348) 2026-11-09 23:58:24 -05:00
LunaDL 17936530aa feat(display): add left and right picture alignment (#2384) 2026-11-09 23:41:25 -05:00
LunaDL ae3e9509f4 Feat: Add new theme color for launcher: Scarlet snow (#2262) 2026-11-09 20:29:14 -05:00
MaxsTechReview 6bcd431ca6 fix(session): wait for Steam readiness before launch (#2362)
* fix(session): wait for Steam readiness before launch

* fix(session): keep the launch fallback when Steam readiness or the interstitial is unavailable

* fix(session): cap the Steam readiness wait by elapsed time and bound each check

* fix(session): keep waiting for Steam readiness through focus changes and never pass a zero timeout
2026-11-09 18:23:24 -05:00
MaxsTechReview 836340f3a2 fix(session): no endless loading screen after a game exits back to Steam (#2359)
* fix(session): avoid stale relaunch loading overlay

* fix(session): keep the loading screen while the runtime installs
2026-11-09 17:09:27 -05:00
Kurt Himebauch 1b19837fc6 fix(native): a guest's own seccomp filter gets its SIGSYS, so Firefox tabs stop crashing (#2360) 2026-11-09 16:42:38 -05:00
Kurt Himebauch c85b84a964 fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup (#2361)
* fix(input): fakeinput no longer aborts programs whose allocator calls a hooked function at startup

* fix(ctl): screenshot finds display ids that the device name carries inline

* fix(input): fakeinput registers its fork handlers before other threads see its config
2026-11-09 16:41:26 -05:00
115 changed files with 1420 additions and 4680 deletions

No files matched your search

-1
View File
@@ -10,4 +10,3 @@ tools/mangoapp/mangoapp text eol=lf
app/src/main/assets/desktop-removal/*.json text eol=lf
app/src/main/assets/desktop-removal/*.txt text eol=lf
*.pdf binary
app/src/main/assets/agent/* text eol=lf
-1
View File
@@ -302,7 +302,6 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
<service android:name=".agent.AgentHostService" android:exported="false" />
<service
android:name=".session.SessionService"
android:exported="false"
-9
View File
@@ -1,9 +0,0 @@
#!/bin/sh
# Opens a URL in Android's browser on the display the agent is being used on ($DD_BROWSER_DISPLAY).
url=$1
[ -n "$url" ] || exit 1
serial=${ADB_SERIAL:-127.0.0.1:5555}
adb connect "$serial" >/dev/null 2>&1
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
adb -s "$serial" shell "am start --display ${DD_BROWSER_DISPLAY:-0} -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
echo "Open this on any device: $url" >&2
-24
View File
@@ -1,24 +0,0 @@
#!/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"
+27 -16
View File
@@ -30,6 +30,7 @@
#include <linux/uinput.h>
#include <poll.h>
#include <pthread.h>
#include <sched.h>
#include <signal.h>
#include <stdarg.h>
#include <stdbool.h>
@@ -208,23 +209,33 @@ struct FakeConfig {
bool uinput_enabled;
};
// The first allocation here can start the host program's own allocator, which may call a hooked
// function (mimalloc probes NUMA nodes with access()) and land back in config(). A guarded static
// would throw a recursive-init error there, so the fields are filled without allocating, and a
// reentry on the initializing thread gets them while other threads wait for the fork handlers.
static FakeConfig &config() {
static FakeConfig *cfg = [] {
auto *c = new FakeConfig();
const char *dir = getenv("FAKE_EVDEV_DIR");
c->hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
c->udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
c->vibration_enabled =
getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
c->xbox360_identity = identity && !strcmp(identity, "xbox360");
c->uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
pthread_atfork(controller_fork_prepare, controller_fork_parent,
controller_fork_child);
Logger::init();
return c;
}();
return *cfg;
static FakeConfig cfg;
static std::atomic<int> state{0};
static __thread bool initializing __attribute__((tls_model("initial-exec")));
if (state.load(std::memory_order_acquire) == 2 || initializing) return cfg;
int expected = 0;
if (!state.compare_exchange_strong(expected, 1, std::memory_order_acq_rel)) {
while (state.load(std::memory_order_acquire) != 2) sched_yield();
return cfg;
}
initializing = true;
const char *dir = getenv("FAKE_EVDEV_DIR");
cfg.hook_dir = dir ? dir : "/data/data/com.termux/files/home/fake-input";
cfg.udev_data_dir = getenv("FAKE_UDEV_DATA_DIR");
cfg.vibration_enabled = getenv("FAKE_EVDEV_VIBRATION") && atoi(getenv("FAKE_EVDEV_VIBRATION"));
const char *identity = getenv("FAKE_EVDEV_IDENTITY");
cfg.xbox360_identity = identity && !strcmp(identity, "xbox360");
cfg.uinput_enabled = getenv("FAKE_EVDEV_UINPUT") && atoi(getenv("FAKE_EVDEV_UINPUT"));
pthread_atfork(controller_fork_prepare, controller_fork_parent, controller_fork_child);
Logger::init();
state.store(2, std::memory_order_release);
initializing = false;
return cfg;
}
static std::recursive_mutex &controller_mutex() {
@@ -213,7 +213,7 @@ static struct {
} g_map;
static pthread_mutex_t g_game_picture_lock = PTHREAD_MUTEX_INITIALIZER;
static float g_game_picture_rect[4];
static int g_game_picture_top;
static int g_game_picture_placed;
static int64_t g_swap_retry_at_ns;
static int g_swap_fail_logged;
@@ -362,7 +362,7 @@ int vkp_completion_unknown(void) { return g_dev_state == -3; }
void vk_present_set_scale_mode(int mode, int alignment) {
if (mode < VKP_MODE_OFF || mode > VKP_MODE_INTEGER) mode = VKP_MODE_FIT;
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_RIGHT) alignment = VKP_ALIGN_CENTER;
g_mode = mode;
g_align = alignment;
}
@@ -383,6 +383,8 @@ static const char *align_name(int a) {
case VKP_ALIGN_BOTTOM: return "bottom";
case VKP_ALIGN_PICTURE_TOP: return "picture top";
case VKP_ALIGN_PICTURE_BOTTOM: return "picture bottom";
case VKP_ALIGN_PICTURE_LEFT: return "picture left";
case VKP_ALIGN_PICTURE_RIGHT: return "picture right";
default: return "center";
}
}
@@ -420,9 +422,11 @@ static void update_map(int scene_w, int scene_h) {
}
if (align == VKP_ALIGN_PICTURE_TOP) g_map.off_y = 0;
else if (align == VKP_ALIGN_PICTURE_BOTTOM) g_map.off_y = H - scene_h * g_map.ky;
else if (align == VKP_ALIGN_PICTURE_LEFT) g_map.off_x = 0;
else if (align == VKP_ALIGN_PICTURE_RIGHT) g_map.off_x = W - scene_w * g_map.kx;
g_map.valid = 1;
pthread_mutex_lock(&g_game_picture_lock);
g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
g_game_picture_placed = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM || align == VKP_ALIGN_PICTURE_LEFT || align == VKP_ALIGN_PICTURE_RIGHT;
g_game_picture_rect[0] = g_map.off_x;
g_game_picture_rect[1] = g_map.off_y;
g_game_picture_rect[2] = g_map.off_x + scene_w * g_map.kx;
@@ -436,7 +440,7 @@ static void update_map(int scene_w, int scene_h) {
int vkp_game_picture_rect(float rect[4]) {
pthread_mutex_lock(&g_game_picture_lock);
int active = g_game_picture_top;
int active = g_game_picture_placed;
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
pthread_mutex_unlock(&g_game_picture_lock);
return active;
@@ -15,7 +15,9 @@ int vkp_quest_layer_presentation(void);
enum vkp_scale_mode { VKP_MODE_OFF = 0, VKP_MODE_FIT = 1, VKP_MODE_STRETCH = 2, VKP_MODE_FILL = 3,
VKP_MODE_INTEGER = 4 };
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2, VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4 };
enum vkp_align { VKP_ALIGN_CENTER = 0, VKP_ALIGN_TOP = 1, VKP_ALIGN_BOTTOM = 2,
VKP_ALIGN_PICTURE_TOP = 3, VKP_ALIGN_PICTURE_BOTTOM = 4,
VKP_ALIGN_PICTURE_LEFT = 5, VKP_ALIGN_PICTURE_RIGHT = 6 };
void vk_present_set_scale_mode(int mode, int alignment);
int vkp_game_picture_rect(float rect[4]);
int vkp_output_to_scene(double ox, double oy, double *sx, double *sy);
@@ -7,11 +7,8 @@ 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)
@@ -42,9 +39,6 @@ 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,8 +1104,6 @@ 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()
@@ -121,6 +121,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionLoadingDecision
import com.droiddeck.launcher.wayland.HdrSupport
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
@@ -172,6 +173,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val alignment = if (!active) ALIGN_CENTER else when (picturePosition) {
"top" -> ALIGN_PICTURE_TOP
"bottom" -> ALIGN_PICTURE_BOTTOM
"left" -> ALIGN_PICTURE_LEFT
"right" -> ALIGN_PICTURE_RIGHT
else -> ALIGN_CENTER
}
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment)
@@ -1148,10 +1151,6 @@ 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) {
@@ -2439,6 +2438,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun watchSession() {
val handler = Handler(Looper.getMainLooper())
var ticks = 0
val loadingStartedAt = SystemClock.elapsedRealtime()
val poll = object : Runnable {
override fun run() {
if (!watching) return
@@ -2451,6 +2451,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
finish()
return
}
if (loading.visible && !loading.ended && !SessionState.running && !installingRuntime &&
SessionLoadingDecision.shouldReturnToLauncher(
loadingStartedAt, SystemClock.elapsedRealtime(), SessionState.running, SessionState.phase)) {
finish()
return
}
if (loading.visible && !loading.ended) {
if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile)
if (ticks++ % 2 == 0) loading.tick()
@@ -2475,8 +2481,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val hint = sessionExitHint(status)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
if (status == 0) SessionState.relaunch?.let { next ->
SessionState.relaunch = null
if (status == 0) SessionState.takeRelaunch()?.let { next ->
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
setIntent(next)
@@ -3210,12 +3215,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
cursorVisible = false
return
}
val rect = drawnRect() ?: return
val rect = if (glassesOutputActive) pointerSurfaceRect() else drawnRect()
val pointerRect = rect ?: return
val position = cursorPos
val x = if (position.x >= 0f) position.x.coerceIn(rect.left, rect.right) else rect.centerX()
val y = if (position.y >= 0f) position.y.coerceIn(rect.top, rect.bottom) else rect.centerY()
val x = if (position.x >= 0f) position.x.coerceIn(pointerRect.left, pointerRect.right) else pointerRect.centerX()
val y = if (position.y >= 0f) position.y.coerceIn(pointerRect.top, pointerRect.bottom) else pointerRect.centerY()
val out = SessionState.outputSize
movePointer(x + dx * rect.width() / out.first, y + dy * rect.height() / out.second)
val nextX = (x + dx * pointerRect.width() / out.first)
.coerceIn(pointerRect.left, pointerRect.right)
val nextY = (y + dy * pointerRect.height() / out.second)
.coerceIn(pointerRect.top, pointerRect.bottom)
movePointer(nextX, nextY)
// Make frame-callback updates visible to drawing before this frame's traversal.
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
@@ -3924,8 +3934,29 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val width = outputView().width.takeIf { it > 0 } ?: return null
val height = outputView().height.takeIf { it > 0 } ?: return null
topPictureMapped = CompositorHost.isStarted && WaylandCompositor.nativeGamePictureRect(gamePictureBounds)
if (topPictureMapped) return android.graphics.RectF(
gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3])
if (topPictureMapped) {
val rect = android.graphics.RectF(
gamePictureBounds[0], gamePictureBounds[1],
gamePictureBounds[2], gamePictureBounds[3]
)
if (glassesOutputActive) {
val mode = glassesScreen?.display?.mode ?: return null
val outputWidth = mode.physicalWidth.takeIf { it > 0 } ?: return null
val outputHeight = mode.physicalHeight.takeIf { it > 0 } ?: return null
val scaleX = width.toFloat() / outputWidth
val scaleY = height.toFloat() / outputHeight
return android.graphics.RectF(
rect.left * scaleX,
rect.top * scaleY,
rect.right * scaleX,
rect.bottom * scaleY
)
}
return rect
}
val out = SessionState.outputSize
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
val drawnW = out.first * scale
@@ -5490,6 +5521,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private const val ALIGN_CENTER = 0
private const val ALIGN_PICTURE_TOP = 3
private const val ALIGN_PICTURE_BOTTOM = 4
private const val ALIGN_PICTURE_LEFT = 5
private const val ALIGN_PICTURE_RIGHT = 6
private val RUNTIME_LINES = PackageLines(
R.string.session_runtime_downloading, R.string.session_runtime_downloading_mb,
R.string.session_runtime_checking, R.string.session_runtime_unpacking,
@@ -1,44 +0,0 @@
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)
}
}
@@ -1,55 +0,0 @@
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,8 +284,6 @@ 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))
@@ -1,134 +0,0 @@
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)
}
@@ -1,278 +0,0 @@
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
/** Guest paths of everything the CLI needs installed, [binary] included. */
val files: List<String> get() = listOf(binary)
/** 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)
}
// A resumed session first answers background tasks the last run left behind; that result is not the turn's.
"result" -> if (o.optJSONObject("origin")?.optString("kind") == "task-notification") conversation?.let { AgentEvent(it) } else 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"
// Code mode runs in a helper that each release ships as its own download.
private const val HOST = "/root/.local/bin/codex-code-mode-host"
override val files get() = listOf(binary, HOST)
override val install = """
set -e
release=${'$'}(curl -fsSL https://api.github.com/repos/openai/codex/releases/latest)
mkdir -p /root/.local/bin
for name in codex codex-code-mode-host; do
url=${'$'}(printf '%s' "${'$'}release" | grep -o "https://[^\"]*/${'$'}name-aarch64-unknown-linux-musl.tar.gz" | head -1)
curl -fsSL "${'$'}url" | tar -xz -C /tmp
install -m755 /tmp/${'$'}name-aarch64-unknown-linux-musl /root/.local/bin/${'$'}name
done
""".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" -> shellCommand(item.optString("command")).let { listOf(AgentHost.Entry.Used(id, "Shell", firstLine(it), it)) }
"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)
private val SHELL_WRAPPER = Regex("^/bin/(?:ba)?sh -l?c '(.*)'$", RegexOption.DOT_MATCHES_ALL)
/** Codex runs each command as `/bin/bash -lc '<command>'`; the command is what the user wants to read. */
@VisibleForTesting
internal fun shellCommand(command: String) = SHELL_WRAPPER.find(command)?.groupValues?.get(1)?.replace("'\''", "'") ?: command
@@ -1,487 +0,0 @@
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 turn now running resends a message that got no reply, so it is not resent again. */
private var retrying = false
/** 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 = harness.files.all { guestFileExists(app, it) }
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
val raw = runCatching {
val log = File(app.filesDir, TURN_LOG).apply { parentFile?.mkdirs() }
log.renameTo(File(app.filesDir, "$TURN_LOG.1"))
log.bufferedWriter()
}.getOrNull()
runCatching {
child.inputStream.bufferedReader().forEachLine { line ->
runCatching { raw?.apply { write(line); newLine(); flush() } }
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) }
}
}
runCatching { raw?.close() }
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)
// Claude Code now and then ends a resumed turn at once without reading the message; nothing
// happened, so the message is sent once more.
val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked
if (!stopped && error == null && asked != null && answerless(entries) && !retrying) {
retrying = true
main.postDelayed({ if (state == State.IDLE) send(app, asked.text, asked.files) }, RETRY_MS)
return
}
retrying = false
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 last turn ended without saying or doing anything. */
fun answerless(entries: List<Entry>): Boolean {
val start = entries.indexOfLast { it is Entry.Asked }
if (start < 0) return false
val turn = entries.subList(start + 1, entries.size)
return turn.lastOrNull() is Entry.Finished && turn.none { it is Entry.Said || it is Entry.Used || it is Entry.Thought } &&
(turn.last() as Entry.Finished).let { it.error == null && !it.stopped }
}
/** Sends the last message again, after a turn that ended without a reply. */
fun retry(context: Context) {
val asked = entries.lastOrNull { it is Entry.Asked } as? Entry.Asked ?: return
send(context, asked.text, asked.files)
}
/** 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"
/** The raw output of the latest turn, for working out what a CLI did when the chat shows nothing. */
private const val TURN_LOG = "agent/turn.log"
private const val RETRY_MS = 3_000L
private fun modelKey(harness: AgentHarness) = "model_${harness.id}"
internal fun prefs(context: Context) = context.getSharedPreferences("agent", Context.MODE_PRIVATE)
}
@@ -1,110 +0,0 @@
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()
}
}
}
@@ -1,105 +0,0 @@
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
}
@@ -1,149 +0,0 @@
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 (harness.files.all { AgentHost.guestFileExists(app, it) }) 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_DETACHED=1 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)
}
}
}
@@ -1,95 +0,0 @@
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", "/root/.local/bin/codex-code-mode-host")),
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
}
})
}
}
@@ -1,53 +0,0 @@
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()
}
}
@@ -1,60 +0,0 @@
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) }
}
}
@@ -1,267 +0,0 @@
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
}
}
@@ -14,6 +14,7 @@ object SessionEvents {
SessionPaths.take()
val dir = SessionPaths.beginOrCurrent(context, label ?: SessionPaths.label(mode, null))
synchronized(SessionState) {
SessionState.clearRelaunch()
SessionState.phase = SessionPhase.PREPARING
SessionState.mode = mode
SessionState.sessionId = dir.name
@@ -43,6 +43,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-game-gpu" to "usr/local/bin/droiddeck-game-gpu",
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
"usr/local/bin/droiddeck-wine-pe" to "usr/local/bin/droiddeck-wine-pe",
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
"usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat",
@@ -0,0 +1,8 @@
package com.droiddeck.launcher.session
internal object SessionLoadingDecision {
const val TIMEOUT_MS = 10_000L
fun shouldReturnToLauncher(startedAt: Long, now: Long, running: Boolean, phase: SessionPhase): Boolean =
!running && phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED) && now - startedAt >= TIMEOUT_MS
}
@@ -558,11 +558,11 @@ object SessionPrefs {
fun picturePosition(context: Context): String =
prefs(context).getString("picturePosition", "center")
?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
?.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center"
fun setPicturePosition(context: Context, position: String) {
prefs(context).edit().putString("picturePosition",
position.takeIf { it in listOf("top", "bottom", "center") } ?: "center").apply()
position.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center").apply()
}
fun returnAfterGame(context: Context): Boolean = prefs(context).getBoolean("returnAfterGame", true)
@@ -1697,8 +1697,8 @@ class SessionService : Service() {
.putExtra(EXTRA_STEAM_UI, ui)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (url.isNotEmpty()) next.putExtra(EXTRA_STEAM_URL, url)
SessionState.relaunch = next
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0) }
SessionState.queueRelaunch(next)
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0, relaunch = true) }
}
}
watcher.startWatching()
@@ -2013,7 +2013,7 @@ class SessionService : Service() {
}
}
private fun stopSession(status: Int, gen: Int? = null) {
private fun stopSession(status: Int, gen: Int? = null, relaunch: Boolean = false) {
if (gen != null && gen != sessionGen) {
Log.i(TAG, "ignoring a stop from session $gen; session $sessionGen is current")
return
@@ -2024,6 +2024,7 @@ class SessionService : Service() {
cancelQueuedSteamGame()
gameExitWatchToken++
if (!SessionState.running) return
if (relaunch) SessionState.confirmRelaunchStop()
SessionEvents.record("guest.exited", mapOf("status" to status))
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
SessionState.running = false
@@ -73,7 +73,44 @@ object SessionState {
@Volatile
var steamClientArch: String? = null
var steamVr by mutableStateOf(false)
@Volatile
var relaunch: android.content.Intent? = null
@Volatile
private var relaunchSessionId: String? = null
@Volatile
private var relaunchStopRequested = false
fun queueRelaunch(intent: android.content.Intent) {
synchronized(this) {
relaunch = intent
relaunchSessionId = sessionId
relaunchStopRequested = false
}
}
fun confirmRelaunchStop() {
synchronized(this) {
relaunchStopRequested = relaunch != null && relaunchSessionId == sessionId
}
}
fun takeRelaunch(): android.content.Intent? = synchronized(this) {
val next = relaunch
val valid = relaunchStopRequested && relaunchSessionId == sessionId
relaunch = null
relaunchSessionId = null
relaunchStopRequested = false
next.takeIf { valid }
}
fun clearRelaunch() {
synchronized(this) {
relaunch = null
relaunchSessionId = null
relaunchStopRequested = false
}
}
@JvmStatic var hdr = false
@Volatile private var deckPadValue = false
@@ -16,6 +16,7 @@ internal object GogContent {
fun token(context: Context) = GogAuth.validToken(context) ?: error("Sign in to GOG and retry")
@JvmStatic
fun builds(id: String, token: String): JSONObject {
val result = json("$SYSTEM/products/$id/os/windows/builds?generation=2", token)
if (result.optJSONArray("items")?.length() != 0) return result
@@ -24,6 +25,36 @@ internal object GogContent {
fun product(id: String, token: String): JSONObject = json("https://api.gog.com/products/$id?expand=downloads", token)
@JvmStatic
fun ownedProductIds(token: String): Set<String>? = runCatching {
val body = StoreNet.get("https://embed.gog.com/user/data/games", bearer = token) ?: return@runCatching null
JSONObject(body).optJSONArray("owned")?.strings()?.toSet() ?: emptySet()
}.getOrNull()
@JvmStatic
fun depotProducts(depot: JSONObject, baseProductId: String): List<String> =
depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId").ifEmpty { baseProductId })
@JvmStatic
fun clientCredentials(manifest: JSONObject): Pair<String, String>? {
val clientId = manifest.optString("clientId")
val clientSecret = manifest.optString("clientSecret")
return clientId.takeIf { it.isNotBlank() }?.let { id -> clientSecret.takeIf { it.isNotBlank() }?.let { id to it } }
}
@JvmStatic
fun tempExecutable(manifest: JSONObject): String? = manifest.optJSONArray("products")?.optJSONObject(0)
?.optString("temp_executable")?.takeIf { it.isNotBlank() }
@JvmStatic
fun installProducts(baseProductId: String, depots: JSONArray, installed: List<String>, token: String,
ownership: (String) -> Set<String>? = { ownedProductIds(it) }): Set<String> {
val listed = depots.objects().flatMap { depotProducts(it, baseProductId) }.distinct()
val owned = if (listed.any { it != baseProductId } || installed.any { it != baseProductId }) ownership(token) else emptySet()
check(installed.all { it == baseProductId || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
return (listOf(baseProductId) + installed + listed.filter { it == baseProductId || owned?.contains(it) == true }).toSet()
}
fun downloadFilename(url: String): String = java.net.URLDecoder.decode(URL(url).path.substringAfterLast('/').replace("+", "%2B"), "UTF-8")
fun resolveDownload(download: GogOfflineDownload, token: String): GogOfflineDownload = download.copy(parts = download.parts.map { part ->
@@ -42,6 +73,7 @@ internal object GogContent {
} finally { connection.disconnect() }
})
@JvmStatic
fun json(url: String, token: String? = null): JSONObject {
require(URL(url).protocol == "https") { "GOG metadata requires HTTPS" }
val connection = URL(url).openConnection() as HttpURLConnection
@@ -61,21 +93,21 @@ internal object GogContent {
}
fun snapshot(old: GogInstallRecord, build: GogBuild, token: String,
metadata: (String, String?) -> JSONObject = ::json): GogInstallRecord {
ownership: (String) -> Set<String>? = { ownedProductIds(it) },
metadata: (String, String?) -> JSONObject = ::json,
onManifest: (JSONObject) -> Unit = {}): GogInstallRecord {
check(build.link.isNotBlank()) { "GOG build manifest is unavailable" }
val manifest = metadata(build.link, token)
onManifest(manifest)
val files = JSONArray()
val depots = if (build.generation == 1) manifest.optJSONObject("product")?.optJSONArray("depots") ?: manifest.optJSONArray("depot") else manifest.optJSONArray("depots")
check(depots != null) { "GOG build has no depot list" }
val resolvedLanguage = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), old.language)
val selectedBitness = if (depots.objects().any { it.optJSONArray("osBitness")?.strings()?.contains("64") == true }) "64" else "32"
val owned = if (old.products.any { it != old.id }) StoreNet.get("https://embed.gog.com/user/data/games", bearer = token)?.let { JSONObject(it).optJSONArray("owned")?.strings()?.toSet() } else emptySet()
val products = old.products.filter { it == old.id || owned?.contains(it) == true }
check(old.products.all { it == old.id || owned?.contains(it) == true }) { "Could not confirm ownership of installed GOG DLC" }
val products = installProducts(old.id, depots, old.products, token, ownership)
for (depot in depots.objects()) {
if (depot.has("redist")) continue
val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id))
val product = ids.firstOrNull { it in products } ?: continue
val product = depotProducts(depot, old.id).firstOrNull { it in products } ?: continue
val languages = depot.optJSONArray("languages")?.strings().orEmpty()
if (!languageSelected(languages, resolvedLanguage)) continue
val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty()
@@ -108,7 +140,7 @@ internal object GogContent {
}
check(files.length() > 0) { "GOG build contains no matching files" }
return old.copy(source = "depot", generation = build.generation, build = build.id, version = build.version,
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true)
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products.toList(), verified = true)
}
fun languageSelected(list: List<String>, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected)
@@ -125,211 +125,22 @@ public final class GogDownloadManager {
private static Gen2Outcome runGen2(Context ctx, GogGame game, String token, String buildsJson, File installPath, Callback cb, AtomicBoolean cancelled, String language) {
Gen2Outcome out = new Gen2Outcome();
try {
cb.onProgress("Fetching manifest…", 5);
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, cb);
if (gm == null) { out.error = "gen2 manifest unavailable"; return out; }
JSONObject manifest = gm.manifest;
JSONArray depots = manifest.optJSONArray("depots");
if (depots == null) { out.error = "no depots in manifest"; return out; }
JSONArray products = manifest.optJSONArray("products");
String tempExe = null;
if (products != null && products.length() > 0) {
tempExe = products.getJSONObject(0).optString("temp_executable", null);
if (tempExe != null && tempExe.isEmpty()) tempExe = null;
}
String baseProductId = manifest.optString("baseProductId", null);
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
baseProductId = products.getJSONObject(0).optString("productId", null);
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
JSONArray deps = manifest.optJSONArray("dependencies");
List<String> dependencies = new ArrayList<>();
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
cb.onProgress("Reading depot manifests…", 10);
List<DepotFile> files = new ArrayList<>();
List<String> depotJsons = new ArrayList<>();
JSONArray owned = new JSONArray();
List<JSONObject> ownedDepots = new ArrayList<>();
for (int i = 0; i < depots.length(); i++) {
JSONObject depot = depots.getJSONObject(i);
String depotPid = depot.optString("productId", "");
if (!depotPid.isEmpty() && !depotPid.equals(baseProductId)) continue;
owned.put(depot);
ownedDepots.add(depot);
}
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(owned), language);
cb.onLog("gog: language " + resolved);
for (int i = 0; i < ownedDepots.size(); i++) {
if (cancelled.get()) return out;
JSONObject depot = ownedDepots.get(i);
if (!depotSelected(depot, resolved)) continue;
String manifestHash = depot.optString("manifest");
if (manifestHash == null || manifestHash.isEmpty()) continue;
byte[] dmRaw = fetchBytes("https://gog-cdn-fastly.gog.com/content-system/v2/meta/" + buildCdnPath(manifestHash), null);
if (dmRaw == null) { cb.onLog("gog: depot " + i + " meta fetch failed"); continue; }
String dmStr = decompressBytes(dmRaw);
if (dmStr == null) { cb.onLog("gog: depot " + i + " decompress failed"); continue; }
parseDepotManifest(dmStr, files);
depotJsons.add(dmStr);
}
if (files.isEmpty()) { out.error = "no depot files collected"; return out; }
cb.onProgress("Fetching CDN link…", 15);
String secureLinkUrl = "https://content-system.gog.com/products/" + baseProductId + "/secure_link?_version=2&generation=2&path=/";
String cdnBase = parseCdnUrl(httpGet(secureLinkUrl, token));
if (cdnBase == null) { out.error = "secure link refused"; return out; }
installPath.mkdirs();
long plannedBytes = 0;
for (DepotFile df : files) plannedBytes += df.totalSize;
long remainingBytes = remainingInstallBytes(files, installPath);
if (remainingBytes > 0) {
long usable = com.droiddeck.launcher.stores.StoreInstallRoot.availableBytes(installPath);
if (com.droiddeck.launcher.stores.StoreInstallRoot.insufficientSpace(remainingBytes, usable)) {
out.hardStop = true;
out.error = "Not enough free space: need " + formatBytes(remainingBytes) + ", only " + formatBytes(usable) + " free";
return out;
}
}
Collections.sort(files, (a, b) -> Long.compare(b.totalSize, a.totalSize));
final int total = files.size();
final long planned = plannedBytes;
final AtomicInteger doneCount = new AtomicInteger(0);
final AtomicLong totalBytes = new AtomicLong(0);
final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
final AtomicLong lastSpeedB = new AtomicLong(0);
final AtomicLong speedBps = new AtomicLong(0);
final AtomicBoolean anyFailed = new AtomicBoolean(false);
final AtomicReference<String> cdnBaseRef = new AtomicReference<>(cdnBase);
final AtomicInteger cdnRefreshCount = new AtomicInteger(0);
final int MAX_CDN_REFRESH = 5;
cb.onSizes(planned, planned);
cb.onBytes(0, planned, 0);
cb.onLog("gog: " + total + " files, " + formatBytes(planned) + ", base=" + baseProductId);
int threads = downloadThreads();
if (GogNative.isAvailable()) {
runNativeEngine(ctx, GogNative.KIND_GEN2_CHUNKS, "Verified…", depotJsons, installPath, cdnBaseRef, secureLinkUrl, token,
cdnRefreshCount, MAX_CDN_REFRESH, threads, threads, true, "gog base=" + baseProductId,
cancelled, anyFailed, doneCount, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
cb.onLog("gog: engine=built-in (" + threads + " threads)");
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
List<Future<Void>> futures = new ArrayList<>();
for (DepotFile df : files) {
futures.add(pool.submit((Callable<Void>) () -> {
if (cancelled.get() || anyFailed.get()) return null;
File outFile = new File(installPath, df.relativePath);
outFile.getParentFile().mkdirs();
if (fileVerified(outFile, df.totalSize, df.md5)) {
int done = doneCount.incrementAndGet();
long tb = totalBytes.addAndGet(df.totalSize);
cb.onProgress("Verified…", 15 + (int) ((done / (float) total) * 80));
cb.onBytes(tb, planned, speedBps.get());
return null;
}
boolean ok = assembleDepotFile(df, outFile, cdnBaseRef, secureLinkUrl, token, cdnRefreshCount, MAX_CDN_REFRESH, cancelled, cb);
if (cancelled.get()) return null;
if (!ok) { cb.onLog("FAIL file=" + df.relativePath); anyFailed.set(true); return null; }
int done = doneCount.incrementAndGet();
long tb = totalBytes.addAndGet(df.totalSize);
sampleSpeed(tb, lastSpeedMs, lastSpeedB, speedBps);
cb.onProgress("Downloading: " + baseName(df.relativePath) + speedSuffix(speedBps.get()), 15 + (int) ((done / (float) total) * 80));
cb.onBytes(tb, planned, speedBps.get());
return null;
}));
}
pool.shutdown();
try {
for (Future<Void> f : futures) f.get();
} catch (Exception e) {
pool.shutdownNow();
out.error = "download pool error: " + e.getClass().getSimpleName();
return out;
}
}
if (cancelled.get()) return out;
if (anyFailed.get()) { out.error = "one or more chunks failed to download"; return out; }
cb.onProgress("Finishing…", 96);
try {
GogMaintenance.captureDepot(game.gameId, installPath, gm.buildId, buildsJson, manifest, new JSONArray(depotJsons), resolved);
} catch (Exception e) {
cb.onLog("gog: maintenance inventory not recorded: " + e.getMessage());
}
writeFile(new File(installPath, MARKER), ("{\"gameId\":\"" + game.gameId + "\",\"buildId\":\"" + gm.buildId + "\"}").getBytes("UTF-8"));
deleteCounted(new File(installPath, ".gog_chunks"), cb);
String clientId = manifest.optString("clientId", null);
if (clientId != null && !clientId.isEmpty()) GogPrefs.get(ctx).edit().putString("client_id_" + game.gameId, clientId).apply();
String clientSecret = manifest.optString("clientSecret", null);
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
GameTask task = pickTask(installPath, game.gameId, tempExe, game.title);
out.result = new Result(task == null ? "" : task.path, gm.buildId, planned);
if (task != null) out.result.workDir = task.workDir;
out.result.dependencies = dependencies;
out.result.dependenciesRead = true;
out.result.language = resolved;
return out;
out.result = GogMaintenance.installContent(ctx, game, installPath, new JSONObject(buildsJson), language, cb, cancelled);
if (out.result == null && !cancelled.get()) out.error = "no usable public Windows build";
} catch (Exception e) {
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
return out;
}
}
private static final class Gen2Manifest {
final JSONObject manifest; final String buildId;
Gen2Manifest(JSONObject manifest, String buildId) { this.manifest = manifest; this.buildId = buildId; }
}
private static Gen2Manifest resolveGen2Manifest(String buildsJson, String token, Callback cb) {
try {
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
if (items == null || items.length() == 0) { cb.onLog("gog: no gen2 builds"); return null; }
String buildId = null, manifestUrl = null;
for (int i = 0; i < items.length(); i++) {
JSONObject item = items.getJSONObject(i);
if ("windows".equals(item.optString("os")) && (item.isNull("branch") || item.optString("branch").isEmpty())) {
buildId = item.optString("build_id");
manifestUrl = item.optString("link");
if (manifestUrl == null || manifestUrl.isEmpty()) manifestUrl = item.optString("meta_url");
break;
}
}
if (buildId == null || manifestUrl == null || manifestUrl.isEmpty()) { cb.onLog("gog: no windows build"); return null; }
byte[] raw = fetchBytes(manifestUrl, token);
if (raw == null) { cb.onLog("gog: manifest fetch failed"); return null; }
String str = decompressBytes(raw);
if (str == null) { cb.onLog("gog: manifest decompress failed"); return null; }
return new Gen2Manifest(new JSONObject(str), buildId);
} catch (Exception e) {
cb.onLog("gog: manifest " + e.getClass().getSimpleName());
return null;
}
}
private static boolean depotSelected(JSONObject depot, String resolved) {
JSONArray languages = depot.optJSONArray("languages");
if (languages == null || languages.length() == 0) return true;
String[] listed = new String[languages.length()];
for (int i = 0; i < languages.length(); i++) listed[i] = languages.optString(i, "");
return StoreLanguage.depotMatchesResolved(listed, resolved);
return out;
}
public static List<String> listLanguages(Context ctx, String gameId) {
try {
String token = GogAuth.validToken(ctx);
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
String buildsJson = httpGet(buildsUrl, null);
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
if (buildsJson == null) return Collections.emptyList();
Callback silent = new Callback() {
@Override public void onProgress(String message, int pct) {}
};
Gen2Manifest gm = resolveGen2Manifest(buildsJson, token, silent);
if (gm == null) return Collections.emptyList();
return StoreLanguage.codesFromDepots(gm.manifest.optJSONArray("depots"));
JSONObject builds = GogContent.builds(gameId, token);
GogBuild build = GogBuild.Companion.select(builds, "", "");
if (build == null || build.getGeneration() != 2) return Collections.emptyList();
return StoreLanguage.codesFromDepots(GogContent.json(build.getLink(), token).optJSONArray("depots"));
} catch (Exception e) {
return Collections.emptyList();
}
@@ -398,7 +209,7 @@ public final class GogDownloadManager {
int threads = downloadThreads();
if (GogNative.isAvailable()) {
runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath,
new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, threads, threads, false, "gog gen1=" + game.gameId,
threads, threads, false, "gog gen1=" + game.gameId,
cancelled, anyFailed, done, total, planned, totalBytes, lastSpeedMs, lastSpeedB, speedBps, cb);
} else {
ExecutorService pool = Executors.newFixedThreadPool(threads, com.droiddeck.launcher.stores.download.DownloadQueue.workerFactory("gog-dl"));
@@ -659,8 +470,7 @@ public final class GogDownloadManager {
private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List<String> depotJsons, File installPath,
AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, int maxWorkers, int processWorkers,
int maxWorkers, int processWorkers,
boolean sortLargestFirst, String label, AtomicBoolean cancelled, AtomicBoolean anyFailed,
AtomicInteger doneCount, int total, long planned, AtomicLong totalBytes,
AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps, Callback cb) {
@@ -673,10 +483,8 @@ public final class GogDownloadManager {
int run = 0;
while (!cancelled.get()) {
run++;
final String base = cdnBaseRef.get();
final CountDownLatch latch = new CountDownLatch(1);
final AtomicBoolean okRef = new AtomicBoolean(false);
final AtomicBoolean linkExpiryRef = new AtomicBoolean(false);
final AtomicReference<String> errRef = new AtomicReference<>("");
final long runStart = totalBytes.get();
final AtomicLong verifiedInRun = new AtomicLong(0);
@@ -702,12 +510,10 @@ public final class GogDownloadManager {
@Override public void onLog(String line) { cb.onLog(line); }
@Override public void onMaintenance(long networkBytes, long reusedBytes, long appliedBytes) {}
@Override public void onComplete(boolean success, boolean wasCancelled, boolean linkExpiry, String error, long bytesWritten, int filesDone) {
okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown();
okRef.set(success); errRef.set(error == null ? "" : error); latch.countDown();
}
};
String[] bases = (base == null || base.isEmpty()) ? new String[0] : base.split("\n");
if (run == 1) cb.onLog("gog: " + bases.length + " CDN host(s) offered");
long handle = GogNative.start(kind, manifests, bases, installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
long handle = GogNative.start(kind, manifests, new String[0], installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
workers, process, sortLargestFirst, label + " run=" + run, listener);
if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; }
boolean interrupted = false;
@@ -721,85 +527,13 @@ public final class GogDownloadManager {
}
if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; }
if (cancelled.get() || okRef.get()) return;
String err = errRef.get();
if (linkExpiryRef.get() && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) {
cb.onLog("gog: secure link expired (" + err + "); refreshed, running again for the remaining files");
continue;
}
cb.onLog("gog: engine failed: " + err);
cb.onLog("gog: engine failed: " + errRef.get());
anyFailed.set(true);
return;
}
}
private static void parseDepotManifest(String json, List<DepotFile> out) {
try {
JSONObject depotObj = new JSONObject(json).optJSONObject("depot");
if (depotObj == null) return;
JSONArray depot = depotObj.optJSONArray("items");
if (depot == null) return;
for (int i = 0; i < depot.length(); i++) {
JSONObject entry = depot.getJSONObject(i);
String path = entry.optString("path", "").replace("\\", "/");
if (path.startsWith("/")) path = path.substring(1);
JSONArray chunks = entry.optJSONArray("chunks");
if (path.isEmpty() || chunks == null || chunks.length() == 0) continue;
DepotFile df = new DepotFile(path);
String fileMd5 = entry.optString("md5", null);
if (fileMd5 != null && !fileMd5.isEmpty()) df.md5 = fileMd5;
long fileTotal = 0;
for (int c = 0; c < chunks.length(); c++) {
JSONObject chunk = chunks.getJSONObject(c);
String compressedMd5 = chunk.optString("compressedMd5"), decMd5 = chunk.optString("md5");
String hash = (compressedMd5 != null && !compressedMd5.isEmpty()) ? compressedMd5 : decMd5;
if (hash == null || hash.isEmpty()) continue;
long decSize = chunk.optLong("size", 0);
df.chunks.add(new DepotFile.ChunkRef(hash, compressedMd5, decMd5, chunk.optLong("compressedSize", 0), decSize));
fileTotal += decSize;
}
df.totalSize = fileTotal;
if (!df.chunks.isEmpty()) out.add(df);
}
} catch (Exception e) {
Log.w(TAG, "depot manifest parse: " + e.getClass().getSimpleName());
}
}
static String buildCdnPath(String hash) { return hash.substring(0, 2) + "/" + hash.substring(2, 4) + "/" + hash; }
private static boolean assembleDepotFile(DepotFile df, File outFile, AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
File tmpFile = new File(outFile.getAbsolutePath() + ".bhtmp");
File parent = outFile.getParentFile();
if (parent != null) parent.mkdirs();
tmpFile.delete();
boolean ok = true;
try (FileOutputStream fos = new FileOutputStream(tmpFile)) {
for (DepotFile.ChunkRef chunk : df.chunks) {
if (cancelled.get()) { ok = false; break; }
byte[] inflated = fetchChunkVerified(cdnBaseRef, buildCdnPath(chunk.hash), chunk, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cancelled, cb);
if (inflated == null) { ok = false; break; }
fos.write(inflated);
}
} catch (Exception e) {
ok = false;
}
if (!ok || cancelled.get()) { tmpFile.delete(); return false; }
if (df.totalSize > 0 && tmpFile.length() != df.totalSize) { cb.onLog("size mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
if (df.md5 != null && !df.md5.isEmpty()) {
String actual = md5HexFile(tmpFile);
if (actual == null || !actual.equalsIgnoreCase(df.md5)) { cb.onLog("md5 mismatch file=" + df.relativePath); tmpFile.delete(); return false; }
}
if (outFile.exists()) outFile.delete();
return tmpFile.renameTo(outFile);
}
private static String parseCdnUrl(String json) {
java.util.List<String> all = parseCdnUrls(json);
return all.isEmpty() ? null : String.join("\n", all);
}
static java.util.List<String> parseCdnUrls(String json) {
java.util.List<String> out = new ArrayList<>();
if (json == null) return out;
@@ -824,18 +558,6 @@ public final class GogDownloadManager {
return out;
}
private static final AtomicInteger CDN_TURN = new AtomicInteger();
static String pickCdn(String set) {
String[] all = set.split("\n");
return all[Math.floorMod(CDN_TURN.getAndIncrement(), all.length)];
}
static String firstCdn(String set) {
int nl = set.indexOf('\n');
return nl < 0 ? set : set.substring(0, nl);
}
private static boolean downloadRange(String url, long offset, long size, File out) {
try {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
@@ -951,15 +673,6 @@ public final class GogDownloadManager {
}
}
private static String buildChunkUrl(String base, String chunkPath) {
int q = base.indexOf('?');
return q >= 0 ? base.substring(0, q) + "/" + chunkPath + base.substring(q) : base + "/" + chunkPath;
}
private static String md5Hex(byte[] data) {
try { return toHex(java.security.MessageDigest.getInstance("MD5").digest(data)); } catch (Exception e) { return null; }
}
private static String md5HexFile(File f) {
try (FileInputStream fis = new FileInputStream(f)) {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
@@ -988,58 +701,6 @@ public final class GogDownloadManager {
return true;
}
private static void sleepBackoff(int attempt) {
try { Thread.sleep(Math.min(1000L << Math.max(0, attempt - 1), 8000L)); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}
private static boolean tryRefreshCdn(AtomicReference<String> cdnBaseRef, String staleBase, String secureLinkUrl, String token,
AtomicInteger refreshCount, int cap, Callback cb) {
if (secureLinkUrl == null) return false;
synchronized (cdnBaseRef) {
String current = cdnBaseRef.get();
if (staleBase == null || !staleBase.equals(current)) return true;
if (refreshCount.get() >= cap) { cb.onLog("gog: CDN refresh cap reached (" + cap + ")"); return false; }
String neu = parseCdnUrl(httpGet(secureLinkUrl, token));
if (neu == null || neu.isEmpty()) { cb.onLog("gog: CDN refresh failed"); return false; }
cdnBaseRef.set(neu);
cb.onLog("gog: secure link refreshed #" + refreshCount.incrementAndGet());
return true;
}
}
private static byte[] fetchChunkVerified(AtomicReference<String> cdnBaseRef, String chunkPath, DepotFile.ChunkRef chunk, String secureLinkUrl, String token,
AtomicInteger cdnRefreshCount, int maxCdnRefresh, AtomicBoolean cancelled, Callback cb) {
int hardFail = 0, guard = 0;
while (guard++ < 8) {
if (cancelled.get()) return null;
String base = cdnBaseRef.get();
HttpResult res = fetchBytesEx(buildChunkUrl(pickCdn(base), chunkPath), null);
if (res.body == null) {
int code = res.status;
if ((code == 401 || code == 403 || code == 404 || code == 500) && tryRefreshCdn(cdnBaseRef, base, secureLinkUrl, token, cdnRefreshCount, maxCdnRefresh, cb)) continue;
cb.onLog("chunk http fail code=" + code);
if (++hardFail >= 3) return null;
sleepBackoff(hardFail);
continue;
}
byte[] raw = res.body;
if (chunk.compressedSize > 0 && raw.length != chunk.compressedSize) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
if (chunk.compressedMd5 != null && !chunk.compressedMd5.isEmpty()) {
String actual = md5Hex(raw);
if (actual == null || !actual.equalsIgnoreCase(chunk.compressedMd5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
}
byte[] inflated = inflateZlib(raw);
if (inflated == null) inflated = raw;
if (chunk.size > 0 && inflated.length != chunk.size) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
if (chunk.md5 != null && !chunk.md5.isEmpty()) {
String actual = md5Hex(inflated);
if (actual == null || !actual.equalsIgnoreCase(chunk.md5)) { if (++hardFail >= 3) return null; sleepBackoff(hardFail); continue; }
}
return inflated;
}
return null;
}
private static void sampleSpeed(long totalBytes, AtomicLong lastSpeedMs, AtomicLong lastSpeedB, AtomicLong speedBps) {
long nowMs = System.currentTimeMillis(), prevMs = lastSpeedMs.get();
if (nowMs - prevMs >= 500 && lastSpeedMs.compareAndSet(prevMs, nowMs)) {
@@ -1289,30 +950,6 @@ public final class GogDownloadManager {
for (File f : files) if (f.isDirectory()) collectExeRecursive(f, out);
}
static long remainingInstallBytes(List<DepotFile> files, File installPath) {
long remaining = 0;
for (DepotFile file : files) {
File output = new File(installPath, file.relativePath);
if (!fileVerified(output, file.totalSize, file.md5)) remaining += file.totalSize;
}
return remaining;
}
static final class DepotFile {
final String relativePath;
final List<ChunkRef> chunks = new ArrayList<>();
String md5;
long totalSize;
DepotFile(String relativePath) { this.relativePath = relativePath; }
static final class ChunkRef {
final String hash, compressedMd5, md5;
final long compressedSize, size;
ChunkRef(String hash, String compressedMd5, String md5, long compressedSize, long size) {
this.hash = hash; this.compressedMd5 = compressedMd5; this.md5 = md5; this.compressedSize = compressedSize; this.size = size;
}
}
}
private static final class Gen1File {
final String path, url; final long offset, size;
Gen1File(String path, String url, long offset, long size) { this.path = path; this.url = url; this.offset = offset; this.size = size; }
@@ -1382,7 +1019,11 @@ public final class GogDownloadManager {
String resolved = StoreLanguage.pick(StoreLanguage.codesFromDepots(depots), StoreLanguage.preferredCode());
for (int i = 0; i < depots.length(); i++) {
JSONObject depot = depots.getJSONObject(i);
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
JSONArray languages = depot.optJSONArray("languages");
String[] listed = new String[languages == null ? 0 : languages.length()];
for (int j = 0; j < listed.length; j++) listed[j] = languages.optString(j, "");
if (listed.length == 0 || StoreLanguage.depotMatchesResolved(listed, resolved))
total += depot.optLong("size", 0);
}
return total > 0 ? total : -1;
} catch (Exception e) {
@@ -377,16 +377,24 @@ internal object GogMaintenance {
val build = GogBuild.select(builds) ?: return null
folder.mkdirs()
val old = GogInstallRecord(game.gameId, "depot", language = language)
val target = GogContent.snapshot(old, build, GogContent.token(context))
var credentials: Pair<String, String>? = null
val target = GogContent.snapshot(old, build, GogContent.token(context), onManifest = { manifest ->
credentials = GogContent.clientCredentials(manifest)
})
GogFiles.validate(target.files, folder)
val bytes = target.files.objects().sumOf { it.optJSONArray("chunks")?.objects()?.sumOf { c -> c.optLong("compressedSize") }?.takeIf { n -> n > 0 } ?: it.optLong("size") }
val disk = target.files.objects().sumOf { it.optLong("size") }
check(!StoreInstallRoot.insufficientSpace(disk, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
val remaining = GogEstimate.calculate(folder, old, target, JSONArray(), reuseChunks = GogNative.isAvailable()).required
check(!StoreInstallRoot.insufficientSpace(remaining, StoreInstallRoot.availableBytes(folder))) { "Not enough free space for GOG installation" }
callback.onSizes(bytes, disk)
transfer(context, Proposal(folder, old, target, GogOperation.UPDATE, bytes, disk), null, cancelled, callback)
if (cancelled.get()) return null
target.write(folder)
val task = GogDownloadManager.pickTask(folder, game.gameId, null, game.title)
credentials?.let { (clientId, clientSecret) ->
GogPrefs.get(context).edit().putString("client_id_${game.gameId}", clientId)
.putString("client_secret_${game.gameId}", clientSecret).apply()
}
val task = GogDownloadManager.pickTask(folder, game.gameId, GogContent.tempExecutable(target.manifest), game.title)
val result = GogDownloadManager.Result(task?.path ?: "", build.id, disk)
result.workDir = task?.workDir
result.language = target.language
@@ -395,20 +403,6 @@ internal object GogMaintenance {
return result
}
@JvmStatic
fun captureDepot(id: String, folder: File, build: String, buildsJson: String, manifest: JSONObject, depots: JSONArray, language: String) {
val selected = GogBuild.select(JSONObject(buildsJson), pinned = build)
val files = JSONArray()
for (depot in depots.strings()) for (item in JSONObject(depot).getJSONObject("depot").getJSONArray("items").objects()) {
val chunks = item.optJSONArray("chunks") ?: continue
files.put(JSONObject().put("path", item.getString("path").replace('\\', '/')).put("size", chunks.objects().sumOf { it.optLong("size") })
.put("md5", item.optString("md5")).put("chunks", chunks).put("product", id))
}
GogFiles.validate(files, folder)
GogInstallRecord(id, "depot", 2, build, selected?.version.orEmpty(), selected?.branch.orEmpty(), language,
manifest = GogContent.sanitize(manifest), files = files).write(folder)
}
@JvmStatic
fun captureOffline(id: String, folder: File, downloads: JSONObject) {
val selected = GogOfflineDownload.parse(downloads, "installers").firstOrNull() ?: return
@@ -6,8 +6,6 @@ import com.droiddeck.launcher.stores.StoresNative
object GogNative {
private const val TAG = "GogNative"
const val KIND_GEN2_CHUNKS = 0
const val KIND_GEN1_RANGES = 1
interface Listener {
@@ -1,99 +0,0 @@
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),
)
}
}
}
@@ -1,307 +0,0 @@
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
import com.droiddeck.launcher.agent.AgentHost.Entry
@Composable
internal fun AgentEntry(entry: Entry, running: Boolean, last: Boolean = false) {
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 -> if (last && AgentHost.answerless(AgentHost.entries)) AgentNoReply() else 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))
}
@Composable
private fun AgentNoReply() {
val context = LocalContext.current
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(horizontal = 4.dp)) {
Text(stringResource(R.string.agent_no_reply), fontSize = 13.sp, color = AttentionAmber)
SecondaryButton(stringResource(R.string.agent_retry), compact = true) { AgentHost.retry(context) }
}
}
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++])
}
}
}
@@ -1,389 +0,0 @@
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, last = !working && i == AgentHost.entries.lastIndex)
}
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))
}
}
@@ -1,182 +0,0 @@
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))
}
}
}
@@ -1,198 +0,0 @@
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.runtime.saveable.rememberSaveable
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) }
}
}
val steps = Step.entries
val done = steps.count { AgentSetup.status[it] == Status.DONE }
val busy = steps.any { AgentSetup.status[it] == Status.WORKING }
var details by rememberSaveable { mutableStateOf(false) }
val summary = if (AgentSetup.ready) buildList {
add(stringResource(R.string.agent_summary_signed_in, AgentHost.harness.name))
if (AgentSetup.status[Step.GITHUB] == Status.DONE) add(stringResource(R.string.agent_step_github))
if (AgentSetup.status[Step.BUILD_TOOLS] == Status.DONE) add(stringResource(R.string.agent_summary_lab))
if (AgentSetup.status[Step.PREPARE] == Status.DONE) add(stringResource(R.string.agent_summary_fast))
}.joinToString(" · ") else steps.firstOrNull { it.required && AgentSetup.status[it] != Status.DONE }?.let { labels(it, AgentHost.harness.name).first }.orEmpty()
ReadinessCard(
done, steps.size, summary, details || !AgentSetup.ready || busy, { details = !details },
title = stringResource(if (AgentSetup.ready) R.string.agent_summary_ready else R.string.agent_summary_not_ready),
ready = AgentSetup.ready,
) {
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 }
var open by rememberSaveable { mutableStateOf(false) }
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp)) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.agent_storage_title), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
Text(Formatter.formatShortFileSize(context, total), fontSize = 13.sp, color = pal.onSurfaceVariant)
}
SecondaryButton(stringResource(if (open) R.string.common_hide else R.string.setup_details), compact = true) { open = !open }
}
if (open) items.forEachIndexed { index, item ->
if (index == 0) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
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,8 +38,7 @@ object BuildWatermark {
if (text.length <= TITLE_LIMIT) text else text.take(TITLE_LIMIT - 1).trimEnd() + "…"
fun install(app: Application) {
val lab = com.droiddeck.launcher.lab.LabOverlay.active?.let { "LAB | ${truncate(it.label)}" }
val lines = (listOfNotNull(lab) + lines().orEmpty()).ifEmpty { return }
val lines = lines() ?: return
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity is QuestXrActivity) return
@@ -270,10 +270,6 @@ 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,7 +47,6 @@ 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
@@ -124,9 +123,7 @@ internal fun BottomBar(
internal fun navigationKeys(s: FrontEndState) = buildList {
add("steam"); add("games")
if (s.gameStoresEnabled) add("stores")
add("desktop")
if (com.droiddeck.launcher.agent.AgentHost.available) add("agent")
add("components")
add("desktop"); add("components")
if (s.isHomeApp) add("android-apps")
add("setup"); add("updates")
}
@@ -159,7 +156,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 = navigationKeys(s).size
val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
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
@@ -200,7 +197,6 @@ 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
internal enum class CheckState { OK, WARN, BUSY }
private 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
internal fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
private 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,12 +690,10 @@ internal fun SetupPanel(
}
@Composable
internal fun ReadinessCard(
passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, title: String? = null, ready: Boolean = passing == total,
checks: @Composable () -> Unit,
) {
private 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
Column(Modifier.fillMaxWidth().padding(top = 8.dp).clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
@@ -711,7 +709,7 @@ internal fun ReadinessCard(
}
Column(Modifier.weight(1f)) {
Text(
title ?: stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
)
if (line.isNotEmpty()) Text(line, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
@@ -1,161 +0,0 @@
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() }
}
}
@@ -224,6 +224,8 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
ChoiceRow(host, "picture-position", stringResource(R.string.display_picture_position), null,
listOf("top" to stringResource(R.string.display_position_top),
"bottom" to stringResource(R.string.display_position_bottom),
"left" to stringResource(R.string.display_position_left),
"right" to stringResource(R.string.display_position_right),
"center" to stringResource(R.string.display_position_center)),
position, onPick = a.onPicturePosition)
}
@@ -9,16 +9,12 @@ 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, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
): List<SessionDrawerPage> =
internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List<SessionDrawerPage> =
SessionDrawerPage.entries.filter {
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) &&
(hasAgent || it != SessionDrawerPage.AGENT)
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES)
}
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
@@ -18,7 +18,6 @@ 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
@@ -44,7 +43,6 @@ 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
@@ -174,7 +172,6 @@ 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
}
@@ -187,7 +184,6 @@ 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
}
@@ -766,13 +762,6 @@ 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) {
@@ -58,6 +58,7 @@ object Themes {
const val RGB_SPEEDS = 10
const val DEFAULT_RGB_SPEED = 3
const val RGB_HUE_STEP = 3f
const val WHITE_RED = "white_red"
var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED)
private set
@@ -127,6 +128,13 @@ object Themes {
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
good = Color(0xFF4CD37F),
),
Palette(
WHITE_RED, R.string.theme_white_red, R.string.theme_white_red_detail,
background = Color(0xFFF8F8F8), surface = Color(0xFFFFFFFF), surfaceVariant = Color(0xFFF0F0F0), line = Color(0xFFDDDDDD), line2 = Color(0xFFC8C8C8),
onBackground = Color(0xFF191919), onSurfaceVariant = Color(0xFF555555),
primary = Color(0xFFD62828), primary2 = Color(0xFFB71C1C), onPrimary = Color(0xFFFFFFFF), signal = Color(0xFFD62828),
good = Color(0xFF248548),
),
innerGamer(0f),
ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail),
)
@@ -285,13 +285,6 @@ 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)
}
}
+5
View File
@@ -2976,6 +2976,8 @@
<string name="display_position_top">أعلى</string>
<string name="display_position_bottom">أسفل</string>
<string name="display_position_center">الوسط (افتراضي)</string>
<string name="display_position_left">يسار</string>
<string name="display_position_right">يمين</string>
<string name="setup_tab_storage">التخزين</string>
<string name="storage_measuring">جارٍ قياس التخزين…</string>
<string name="storage_measuring_hint">يستغرق القياس الأول بضع ثوانٍ.</string>
@@ -3809,4 +3811,7 @@
<string name="input_reset_positions">إعادة تعيين المواضع</string>
<string name="input_show_label">إظهار التسمية</string>
<string name="input_layout_import_failed">تعذّر استيراد هذا التخطيط</string>
<string name="theme_white_red">ثلج قرمزي</string>
</resources>
@@ -2828,6 +2828,8 @@
<string name="display_position_top">Arriba</string>
<string name="display_position_bottom">Abajo</string>
<string name="display_position_center">Centro (predeterminado)</string>
<string name="display_position_left">Izquierda</string>
<string name="display_position_right">Derecha</string>
<string name="setup_tab_storage">Almacenamiento</string>
<string name="storage_measuring">Midiendo el almacenamiento…</string>
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
@@ -3665,4 +3667,6 @@
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se pudo importar esta disposición</string>
<string name="theme_white_red">Nieve escarlata</string>
<string name="theme_white_red_detail">Superficies blancas con acentos rojos.</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Горе</string>
<string name="display_position_bottom">Долу</string>
<string name="display_position_center">В средата (по подразбиране)</string>
<string name="display_position_left">Наляво</string>
<string name="display_position_right">Надясно</string>
<string name="setup_tab_storage">Съхранение</string>
<string name="storage_measuring">Измерване на съхранението…</string>
<string name="storage_measuring_hint">Първият път отнема няколко секунди.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Нулиране на позициите</string>
<string name="input_show_label">Показване на етикета</string>
<string name="input_layout_import_failed">Това разположение не можа да бъде импортирано</string>
<string name="theme_white_red">Ален сняг</string>
</resources>
+5
View File
@@ -2918,6 +2918,8 @@
<string name="display_position_top">Nahoře</string>
<string name="display_position_bottom">Dole</string>
<string name="display_position_center">Uprostřed (výchozí)</string>
<string name="display_position_left">Vlevo</string>
<string name="display_position_right">Vpravo</string>
<string name="setup_tab_storage">Úložiště</string>
<string name="storage_measuring">Měření úložiště…</string>
<string name="storage_measuring_hint">Poprvé to trvá pár sekund.</string>
@@ -3731,4 +3733,7 @@
<string name="input_reset_positions">Obnovit pozice</string>
<string name="input_show_label">Zobrazit popisek</string>
<string name="input_layout_import_failed">Toto rozložení nelze importovat</string>
<string name="theme_white_red">Šarlatový sníh</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Øverst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Midten (standard)</string>
<string name="display_position_left">Venstre</string>
<string name="display_position_right">Højre</string>
<string name="setup_tab_storage">Lager</string>
<string name="storage_measuring">Måler lager…</string>
<string name="storage_measuring_hint">Første gang tager et par sekunder.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Nulstil placeringer</string>
<string name="input_show_label">Vis etiket</string>
<string name="input_layout_import_failed">Dette layout kunne ikke importeres</string>
<string name="theme_white_red">Skarlagensne</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Oben</string>
<string name="display_position_bottom">Unten</string>
<string name="display_position_center">Mitte (Standard)</string>
<string name="display_position_left">Links</string>
<string name="display_position_right">Rechts</string>
<string name="setup_tab_storage">Speicher</string>
<string name="storage_measuring">Speicher wird erfasst…</string>
<string name="storage_measuring_hint">Die erste Erfassung dauert ein paar Sekunden.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Positionen zurücksetzen</string>
<string name="input_show_label">Beschriftung anzeigen</string>
<string name="input_layout_import_failed">Dieses Layout konnte nicht importiert werden</string>
<string name="theme_white_red">Scharlachroter Schnee</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Πάνω</string>
<string name="display_position_bottom">Κάτω</string>
<string name="display_position_center">Κέντρο (προεπιλογή)</string>
<string name="display_position_left">Αριστερά</string>
<string name="display_position_right">Δεξιά</string>
<string name="setup_tab_storage">Αποθήκευση</string>
<string name="storage_measuring">Μέτρηση αποθήκευσης…</string>
<string name="storage_measuring_hint">Την πρώτη φορά παίρνει λίγα δευτερόλεπτα.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Επαναφορά θέσεων</string>
<string name="input_show_label">Εμφάνιση ετικέτας</string>
<string name="input_layout_import_failed">Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης</string>
<string name="theme_white_red">Κατακόκκινο χιόνι</string>
</resources>
+5
View File
@@ -2889,6 +2889,8 @@
<string name="display_position_top">Arriba</string>
<string name="display_position_bottom">Abajo</string>
<string name="display_position_center">Centro (predeterminado)</string>
<string name="display_position_left">Izquierda</string>
<string name="display_position_right">Derecha</string>
<string name="setup_tab_storage">Almacenamiento</string>
<string name="storage_measuring">Midiendo el almacenamiento…</string>
<string name="storage_measuring_hint">La primera vez tarda unos segundos.</string>
@@ -3692,4 +3694,7 @@
<string name="input_reset_positions">Restablecer posiciones</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">No se ha podido importar esta disposición</string>
<string name="theme_white_red">Nieve escarlata</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Ylhäällä</string>
<string name="display_position_bottom">Alhaalla</string>
<string name="display_position_center">Keskellä (oletus)</string>
<string name="display_position_left">Vasen</string>
<string name="display_position_right">Oikea</string>
<string name="setup_tab_storage">Tallennustila</string>
<string name="storage_measuring">Mitataan tallennustilaa…</string>
<string name="storage_measuring_hint">Ensimmäisellä kerralla kestää muutaman sekunnin.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Palauta sijainnit</string>
<string name="input_show_label">Näytä nimi</string>
<string name="input_layout_import_failed">Tätä asettelua ei voitu tuoda</string>
<string name="theme_white_red">Helakanpunainen lumi</string>
</resources>
+5
View File
@@ -2889,6 +2889,8 @@
<string name="display_position_top">Haut</string>
<string name="display_position_bottom">Bas</string>
<string name="display_position_center">Centre (par défaut)</string>
<string name="display_position_left">Gauche</string>
<string name="display_position_right">Droite</string>
<string name="setup_tab_storage">Stockage</string>
<string name="storage_measuring">Mesure du stockage…</string>
<string name="storage_measuring_hint">Cela prend quelques secondes la première fois.</string>
@@ -3692,4 +3694,7 @@
<string name="input_reset_positions">Réinitialiser les positions</string>
<string name="input_show_label">Afficher le libellé</string>
<string name="input_layout_import_failed">Impossible d’importer cette disposition</string>
<string name="theme_white_red">Neige écarlate</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">ऊपर</string>
<string name="display_position_bottom">नीचे</string>
<string name="display_position_center">बीच में (डिफ़ॉल्ट)</string>
<string name="display_position_left">बाएँ</string>
<string name="display_position_right">दाएँ</string>
<string name="setup_tab_storage">स्टोरेज</string>
<string name="storage_measuring">स्टोरेज मापा जा रहा है…</string>
<string name="storage_measuring_hint">पहली बार कुछ सेकंड लगते हैं।</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">स्थितियाँ रीसेट करें</string>
<string name="input_show_label">लेबल दिखाएँ</string>
<string name="input_layout_import_failed">यह लेआउट इम्पोर्ट नहीं किया जा सका</string>
<string name="theme_white_red">किरमिज़ी बर्फ</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Fent</string>
<string name="display_position_bottom">Lent</string>
<string name="display_position_center">Középen (alapértelmezett)</string>
<string name="display_position_left">Bal</string>
<string name="display_position_right">Jobb</string>
<string name="setup_tab_storage">Tárhely</string>
<string name="storage_measuring">Tárhely mérése…</string>
<string name="storage_measuring_hint">Első alkalommal pár másodpercig tart.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Pozíciók visszaállítása</string>
<string name="input_show_label">Címke megjelenítése</string>
<string name="input_layout_import_failed">Ez az elrendezés nem importálható</string>
<string name="theme_white_red">Skarlátvörös hó</string>
</resources>
+5
View File
@@ -2831,6 +2831,8 @@
<string name="display_position_top">Atas</string>
<string name="display_position_bottom">Bawah</string>
<string name="display_position_center">Tengah (bawaan)</string>
<string name="display_position_left">Kiri</string>
<string name="display_position_right">Kanan</string>
<string name="setup_tab_storage">Penyimpanan</string>
<string name="storage_measuring">Mengukur penyimpanan…</string>
<string name="storage_measuring_hint">Butuh beberapa detik pada kali pertama.</string>
@@ -3623,4 +3625,7 @@
<string name="input_reset_positions">Atur ulang posisi</string>
<string name="input_show_label">Tampilkan label</string>
<string name="input_layout_import_failed">Tata letak ini tidak dapat diimpor</string>
<string name="theme_white_red">Salju Kirmizi</string>
</resources>
+5
View File
@@ -2889,6 +2889,8 @@
<string name="display_position_top">In alto</string>
<string name="display_position_bottom">In basso</string>
<string name="display_position_center">Centro (predefinito)</string>
<string name="display_position_left">Sinistra</string>
<string name="display_position_right">Destra</string>
<string name="setup_tab_storage">Archiviazione</string>
<string name="storage_measuring">Misurazione dell\'archiviazione…</string>
<string name="storage_measuring_hint">La prima volta richiede qualche secondo.</string>
@@ -3692,4 +3694,7 @@
<string name="input_reset_positions">Ripristina posizioni</string>
<string name="input_show_label">Mostra etichetta</string>
<string name="input_layout_import_failed">Impossibile importare questo layout</string>
<string name="theme_white_red">Neve scarlatta</string>
</resources>
+5
View File
@@ -2831,6 +2831,8 @@
<string name="display_position_top">上</string>
<string name="display_position_bottom">下</string>
<string name="display_position_center">中央(既定)</string>
<string name="display_position_left">左</string>
<string name="display_position_right">右</string>
<string name="setup_tab_storage">ストレージ</string>
<string name="storage_measuring">ストレージを集計しています…</string>
<string name="storage_measuring_hint">初回の集計には数秒かかります。</string>
@@ -3623,4 +3625,7 @@
<string name="input_reset_positions">位置をリセット</string>
<string name="input_show_label">ラベルを表示</string>
<string name="input_layout_import_failed">このレイアウトをインポートできませんでした</string>
<string name="theme_white_red">緋色の雪</string>
</resources>
+5
View File
@@ -2831,6 +2831,8 @@
<string name="display_position_top">위</string>
<string name="display_position_bottom">아래</string>
<string name="display_position_center">가운데(기본)</string>
<string name="display_position_left">왼쪽</string>
<string name="display_position_right">오른쪽</string>
<string name="setup_tab_storage">저장 공간</string>
<string name="storage_measuring">저장 공간 계산 중…</string>
<string name="storage_measuring_hint">처음 계산할 때는 몇 초 걸립니다.</string>
@@ -3623,4 +3625,7 @@
<string name="input_reset_positions">위치 초기화</string>
<string name="input_show_label">라벨 표시</string>
<string name="input_layout_import_failed">이 레이아웃을 가져올 수 없습니다</string>
<string name="theme_white_red">진홍빛 눈</string>
</resources>
+5
View File
@@ -2831,6 +2831,8 @@
<string name="display_position_top">Atas</string>
<string name="display_position_bottom">Bawah</string>
<string name="display_position_center">Tengah (lalai)</string>
<string name="display_position_left">Kiri</string>
<string name="display_position_right">Kanan</string>
<string name="setup_tab_storage">Storan</string>
<string name="storage_measuring">Mengukur storan…</string>
<string name="storage_measuring_hint">Mengambil beberapa saat pada kali pertama.</string>
@@ -3623,4 +3625,7 @@
<string name="input_reset_positions">Tetapkan semula kedudukan</string>
<string name="input_show_label">Tunjukkan label</string>
<string name="input_layout_import_failed">Susun atur ini tidak dapat diimport</string>
<string name="theme_white_red">Salji Merah Skarlet</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Øverst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Midten (standard)</string>
<string name="display_position_left">Venstre</string>
<string name="display_position_right">Høyre</string>
<string name="setup_tab_storage">Lagring</string>
<string name="storage_measuring">Måler lagring…</string>
<string name="storage_measuring_hint">Første gang tar noen sekunder.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Tilbakestill plasseringer</string>
<string name="input_show_label">Vis etikett</string>
<string name="input_layout_import_failed">Kunne ikke importere dette oppsettet</string>
<string name="theme_white_red">Skarlagenssnø</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Boven</string>
<string name="display_position_bottom">Onder</string>
<string name="display_position_center">Midden (standaard)</string>
<string name="display_position_left">Links</string>
<string name="display_position_right">Rechts</string>
<string name="setup_tab_storage">Opslag</string>
<string name="storage_measuring">Opslag meten…</string>
<string name="storage_measuring_hint">De eerste keer duurt een paar seconden.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Posities herstellen</string>
<string name="input_show_label">Label tonen</string>
<string name="input_layout_import_failed">Deze indeling kan niet worden geïmporteerd</string>
<string name="theme_white_red">Scharlakenrode sneeuw</string>
</resources>
+5
View File
@@ -2918,6 +2918,8 @@
<string name="display_position_top">Góra</string>
<string name="display_position_bottom">Dół</string>
<string name="display_position_center">Środek (domyślnie)</string>
<string name="display_position_left">Lewo</string>
<string name="display_position_right">Prawo</string>
<string name="setup_tab_storage">Pamięć</string>
<string name="storage_measuring">Mierzenie pamięci…</string>
<string name="storage_measuring_hint">Pierwszy raz trwa kilka sekund.</string>
@@ -3731,4 +3733,7 @@
<string name="input_reset_positions">Resetuj położenie</string>
<string name="input_show_label">Pokaż etykietę</string>
<string name="input_layout_import_failed">Nie można zaimportować tego układu</string>
<string name="theme_white_red">Szkarłatny śnieg</string>
</resources>
@@ -2887,6 +2887,8 @@
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
<string name="display_picture_position">Posição da imagem</string>
<string name="display_position_top">Topo</string>
<string name="display_position_left">Esquerda</string>
<string name="display_position_right">Direita</string>
<string name="display_position_bottom">Fundo</string>
<string name="display_position_center">Centro (predefinido)</string>
<string name="setup_tab_storage">Armazenamento</string>
@@ -3692,4 +3694,7 @@
<string name="input_reset_positions">Repor posições</string>
<string name="input_show_label">Mostrar etiqueta</string>
<string name="input_layout_import_failed">Não foi possível importar este esquema</string>
<string name="theme_white_red">Neve Escarlate</string>
</resources>
+5
View File
@@ -2887,6 +2887,8 @@
<string name="stores_target_free_unknown">Espaço livre desconhecido</string>
<string name="display_picture_position">Posição da imagem</string>
<string name="display_position_top">Topo</string>
<string name="display_position_left">Esquerda</string>
<string name="display_position_right">Direita</string>
<string name="display_position_bottom">Base</string>
<string name="display_position_center">Centro (padrão)</string>
<string name="setup_tab_storage">Armazenamento</string>
@@ -3692,4 +3694,7 @@
<string name="input_reset_positions">Redefinir posições</string>
<string name="input_show_label">Mostrar rótulo</string>
<string name="input_layout_import_failed">Não foi possível importar este layout</string>
<string name="theme_white_red">Neve Escarlate</string>
</resources>
+5
View File
@@ -2889,6 +2889,8 @@
<string name="display_position_top">Sus</string>
<string name="display_position_bottom">Jos</string>
<string name="display_position_center">Centru (implicit)</string>
<string name="display_position_left">Stânga</string>
<string name="display_position_right">Dreapta</string>
<string name="setup_tab_storage">Stocare</string>
<string name="storage_measuring">Se măsoară stocarea…</string>
<string name="storage_measuring_hint">Prima dată durează câteva secunde.</string>
@@ -3692,4 +3694,7 @@
<string name="input_reset_positions">Resetează pozițiile</string>
<string name="input_show_label">Afișează eticheta</string>
<string name="input_layout_import_failed">Acest aranjament nu a putut fi importat</string>
<string name="theme_white_red">Zăpadă stacojie</string>
</resources>
+5
View File
@@ -3001,6 +3001,8 @@
<string name="display_position_top">Сверху</string>
<string name="display_position_bottom">Снизу</string>
<string name="display_position_center">По центру (по умолчанию)</string>
<string name="display_position_left">Слева</string>
<string name="display_position_right">Справа</string>
<string name="setup_tab_storage">Хранилище</string>
<string name="storage_measuring">Измерение хранилища…</string>
<string name="storage_measuring_hint">В первый раз занимает несколько секунд.</string>
@@ -3814,4 +3816,7 @@
<string name="input_reset_positions">Сбросить положение</string>
<string name="input_show_label">Показывать подпись</string>
<string name="input_layout_import_failed">Не удалось импортировать эту раскладку</string>
<string name="theme_white_red">Алый снег</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Överst</string>
<string name="display_position_bottom">Nederst</string>
<string name="display_position_center">Mitten (standard)</string>
<string name="display_position_left">Vänster</string>
<string name="display_position_right">Höger</string>
<string name="setup_tab_storage">Lagring</string>
<string name="storage_measuring">Mäter lagring…</string>
<string name="storage_measuring_hint">Första gången tar några sekunder.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Återställ positioner</string>
<string name="input_show_label">Visa etikett</string>
<string name="input_layout_import_failed">Det gick inte att importera layouten</string>
<string name="theme_white_red">Scharlakansröd snö</string>
</resources>
+5
View File
@@ -2831,6 +2831,8 @@
<string name="display_position_top">บน</string>
<string name="display_position_bottom">ล่าง</string>
<string name="display_position_center">กลาง (ค่าเริ่มต้น)</string>
<string name="display_position_left">ซ้าย</string>
<string name="display_position_right">ขวา</string>
<string name="setup_tab_storage">พื้นที่จัดเก็บ</string>
<string name="storage_measuring">กำลังวัดพื้นที่…</string>
<string name="storage_measuring_hint">ครั้งแรกใช้เวลาสองสามวินาที</string>
@@ -3623,4 +3625,7 @@
<string name="input_reset_positions">รีเซ็ตตำแหน่ง</string>
<string name="input_show_label">แสดงป้ายกำกับ</string>
<string name="input_layout_import_failed">ไม่สามารถนำเข้าการจัดวางนี้ได้</string>
<string name="theme_white_red">หิมะสีแดงชาด</string>
</resources>
+5
View File
@@ -2860,6 +2860,8 @@
<string name="display_position_top">Üst</string>
<string name="display_position_bottom">Alt</string>
<string name="display_position_center">Orta (varsayılan)</string>
<string name="display_position_left">Sol</string>
<string name="display_position_right">Sağ</string>
<string name="setup_tab_storage">Depolama</string>
<string name="storage_measuring">Depolama ölçülüyor…</string>
<string name="storage_measuring_hint">İlk seferde birkaç saniye sürer.</string>
@@ -3653,4 +3655,7 @@
<string name="input_reset_positions">Konumları sıfırla</string>
<string name="input_show_label">Etiketi göster</string>
<string name="input_layout_import_failed">Bu düzen içe aktarılamadı</string>
<string name="theme_white_red">Kızıl Kar</string>
</resources>
+5
View File
@@ -2918,6 +2918,8 @@
<string name="display_position_top">Угорі</string>
<string name="display_position_bottom">Унизу</string>
<string name="display_position_center">По центру (типово)</string>
<string name="display_position_left">Ліворуч</string>
<string name="display_position_right">Праворуч</string>
<string name="setup_tab_storage">Сховище</string>
<string name="storage_measuring">Вимірювання сховища…</string>
<string name="storage_measuring_hint">Уперше триває кілька секунд.</string>
@@ -3731,4 +3733,7 @@
<string name="input_reset_positions">Скинути розташування</string>
<string name="input_show_label">Показувати підпис</string>
<string name="input_layout_import_failed">Не вдалося імпортувати цю розкладку</string>
<string name="theme_white_red">Багряний сніг</string>
</resources>
+5
View File
@@ -2831,6 +2831,8 @@
<string name="display_position_top">Trên</string>
<string name="display_position_bottom">Dưới</string>
<string name="display_position_center">Giữa (mặc định)</string>
<string name="display_position_left">Trái</string>
<string name="display_position_right">Phải</string>
<string name="setup_tab_storage">Lưu trữ</string>
<string name="storage_measuring">Đang đo dung lượng…</string>
<string name="storage_measuring_hint">Lần đầu mất vài giây.</string>
@@ -3623,4 +3625,7 @@
<string name="input_reset_positions">Đặt lại vị trí</string>
<string name="input_show_label">Hiện nhãn</string>
<string name="input_layout_import_failed">Không thể nhập bố cục này</string>
<string name="theme_white_red">Tuyết đỏ thẫm</string>
</resources>
@@ -2835,6 +2835,8 @@
<string name="display_position_top">置顶</string>
<string name="display_position_bottom">置底</string>
<string name="display_position_center">居中(默认)</string>
<string name="display_position_left">左侧</string>
<string name="display_position_right">右侧</string>
<string name="setup_tab_storage">存储</string>
<string name="storage_measuring">正在统计存储…</string>
<string name="storage_measuring_hint">首次统计需要几秒钟。</string>
@@ -3627,4 +3629,7 @@
<string name="input_reset_positions">重置位置</string>
<string name="input_show_label">显示标签</string>
<string name="input_layout_import_failed">无法导入此布局</string>
<string name="theme_white_red">猩红之雪</string>
</resources>
@@ -2831,6 +2831,8 @@
<string name="display_position_top">置頂</string>
<string name="display_position_bottom">置底</string>
<string name="display_position_center">置中(預設)</string>
<string name="display_position_left">左側</string>
<string name="display_position_right">右側</string>
<string name="setup_tab_storage">儲存空間</string>
<string name="storage_measuring">正在統計儲存空間…</string>
<string name="storage_measuring_hint">首次統計需要幾秒鐘。</string>
@@ -3626,4 +3628,7 @@
<string name="input_reset_positions">重設位置</string>
<string name="input_show_label">顯示標籤</string>
<string name="input_layout_import_failed">無法匯入此佈局</string>
<string name="theme_white_red">緋紅之雪</string>
</resources>
@@ -2831,6 +2831,8 @@
<string name="display_position_top">置頂</string>
<string name="display_position_bottom">置底</string>
<string name="display_position_center">置中(預設)</string>
<string name="display_position_left">左側</string>
<string name="display_position_right">右側</string>
<string name="setup_tab_storage">儲存空間</string>
<string name="storage_measuring">正在統計儲存空間…</string>
<string name="storage_measuring_hint">首次統計需要幾秒鐘。</string>
@@ -3626,4 +3628,7 @@
<string name="input_reset_positions">重設位置</string>
<string name="input_show_label">顯示標籤</string>
<string name="input_layout_import_failed">無法匯入此版面配置</string>
<string name="theme_white_red">緋紅之雪</string>
</resources>
+5 -117
View File
@@ -1118,123 +1118,6 @@
<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_no_reply">Ended without a reply</string>
<string name="agent_retry">Retry</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_summary_ready">Agent ready</string>
<string name="agent_summary_not_ready">Finish setting up the agent</string>
<string name="agent_summary_signed_in">Signed in to %1$s</string>
<string name="agent_summary_lab">Lab builds</string>
<string name="agent_summary_fast">fast builds</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>
@@ -3223,6 +3106,8 @@
<string name="glasses_pro2_experimental">Experimental Pro 2 head tracking. Brightness, volume, and display switching are not supported yet.</string>
<string name="display_picture_position">Picture position</string>
<string name="display_position_top">Top</string>
<string name="display_position_left">Left</string>
<string name="display_position_right">Right</string>
<string name="display_position_bottom">Bottom</string>
<string name="display_position_center">Center (default)</string>
<string name="glasses_6dof">6DoF virtual screen</string>
@@ -3931,4 +3816,7 @@
<string name="input_reset_positions">Reset positions</string>
<string name="input_show_label">Show label</string>
<string name="input_layout_import_failed">Could not import this layout</string>
<string name="theme_white_red">Scarlet Snow</string>
<string name="theme_white_red_detail">White surfaces with red accents.</string>
</resources>
@@ -1,166 +0,0 @@
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 aResultForALeftoverBackgroundTaskDoesNotEndTheTurn() {
val event = ClaudeCode.parse("""{"type":"result","is_error":false,"num_turns":0,"session_id":"s","total_cost_usd":0,"usage":{"input_tokens":0,"output_tokens":0},"origin":{"kind":"task-notification"}}""")
assertTrue(event == null || event.entries.none { it is Entry.Finished })
}
@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 codexShellWrappersAreUnwrapped() {
assertEquals("uname -m", shellCommand("/bin/bash -lc 'uname -m'"))
assertEquals("echo 'hi'", shellCommand("/bin/bash -lc 'echo '\''hi'\'''"))
assertEquals("ls", shellCommand("ls"))
}
@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 aTurnThatEndsWithNothingIsAnswerless() {
assertTrue(AgentHost.answerless(listOf(Entry.Said("x"), Entry.Asked("go on"), Entry.Finished(null))))
assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Said("ok"), Entry.Finished(null))))
assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Finished("boom"))))
assertFalse(AgentHost.answerless(listOf(Entry.Asked("go on"), Entry.Finished(null, stopped = true))))
}
@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)
}
}
@@ -1,29 +0,0 @@
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")))
}
}
@@ -1,143 +0,0 @@
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,53 @@
package com.droiddeck.launcher.session
import android.content.Intent
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class SessionLoadingDecisionTest {
private var previousSessionId: String? = null
private var previousRelaunch: Intent? = null
@Before
fun setUp() {
previousSessionId = SessionState.sessionId
previousRelaunch = SessionState.relaunch
SessionState.clearRelaunch()
}
@After
fun tearDown() {
SessionState.sessionId = previousSessionId
SessionState.relaunch = previousRelaunch
SessionState.clearRelaunch()
if (previousRelaunch != null) SessionState.queueRelaunch(previousRelaunch!!)
}
@Test
fun loadingTimesOutOnlyWhenNoSessionStarted() {
assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_099L, false, SessionPhase.IDLE))
assertEquals(true, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, false, SessionPhase.IDLE))
assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, true, SessionPhase.IDLE))
assertEquals(false, SessionLoadingDecision.shouldReturnToLauncher(100L, 10_100L, false, SessionPhase.PREPARING))
}
@Test
fun relaunchRequiresTheCurrentSessionToHaveRequestedTheStop() {
SessionState.sessionId = "session-a"
val next = Intent().apply { action = "next" }
SessionState.queueRelaunch(next)
assertNull(SessionState.takeRelaunch())
SessionState.queueRelaunch(next)
SessionState.confirmRelaunchStop()
assertEquals(next, SessionState.takeRelaunch())
assertNull(SessionState.takeRelaunch())
SessionState.queueRelaunch(next)
SessionState.confirmRelaunchStop()
SessionState.sessionId = "session-b"
assertNull(SessionState.takeRelaunch())
}
}
@@ -15,16 +15,12 @@ class GogCdnTest {
{"endpoint_name":"lumen","url_format":"https://cdn.gog.com/{path}","parameters":{}}
]}"""
@Test fun allHostsAreParsedInOrderAndTakenInTurn() {
@Test fun allHostsAreParsedInOrder() {
val all = GogDownloadManager.parseCdnUrls(answer)
assertEquals(3, all.size)
assertEquals("https://gog-cdn-fastly.gog.com/token=nva=1~token=t", all[0])
assertEquals("https://gog-cdn-akamai.gog.com/s", all[1])
assertEquals("https://cdn.gog.com", all[2])
val set = all.joinToString("\n")
assertEquals(all[0], GogDownloadManager.firstCdn(set))
val picks = (0 until 6).map { GogDownloadManager.pickCdn(set) }.toSet()
assertEquals(all.toSet(), picks)
assertEquals(0, GogDownloadManager.parseCdnUrls("not json").size)
}
}
@@ -26,6 +26,45 @@ import java.util.concurrent.atomic.AtomicBoolean
class GogMaintenanceTest {
@get:Rule val temporary = TemporaryFolder()
@Test fun ownedDlcDepotIsSelected() {
val allowed = GogContent.installProducts("1", JSONArray("[{\"gameIDs\":[\"2\"]}]"), emptyList(), "unused") { setOf("2") }
assertEquals(setOf("1", "2"), allowed)
}
@Test fun unownedDlcDepotIsSkipped() {
val allowed = GogContent.installProducts("1", JSONArray("[{\"productId\":\"2\"}]"), emptyList(), "unused") { emptySet() }
assertEquals(setOf("1"), allowed)
}
@Test fun ownershipFetchFailureFallsBackToBaseOnly() {
val allowed = GogContent.installProducts("1", JSONArray("[{\"productId\":\"2\"}]"), emptyList(), "unused") { null }
assertEquals(setOf("1"), allowed)
}
@Test fun installManifestCredentialsAndTempExecutableAreExtractedBeforeSanitizing() {
val manifest = JSONObject("""{"clientId":"client","clientSecret":"secret","products":[{"temp_executable":"bin/game.exe"}]}""")
assertEquals("client" to "secret", GogContent.clientCredentials(manifest))
assertEquals("bin/game.exe", GogContent.tempExecutable(GogContent.sanitize(manifest)))
assertFalse(GogContent.sanitize(manifest).toString().contains("secret"))
}
@Test fun emptyProductIdDepotUsesBaseAndDlcFilesKeepTheirProduct() {
val build = GogBuild("1", "", 2, "", "https://example.invalid/build")
val snapshot = GogContent.snapshot(GogInstallRecord("1", "depot"), build, "unused", ownership = { setOf("2") }, metadata = { url, _ ->
when (url) {
build.link -> JSONObject("{\"depots\":[{\"gameIDs\":[\"1\"],\"manifest\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},{\"gameIDs\":[\"2\"],\"manifest\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},{\"productId\":\"\",\"manifest\":\"cccccccccccccccccccccccccccccccc\"}]}")
"https://gog-cdn-fastly.gog.com/content-system/v2/meta/aa/aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"base.bin\",\"chunks\":[{\"size\":1}]}]}}")
"https://gog-cdn-fastly.gog.com/content-system/v2/meta/bb/bb/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"dlc.bin\",\"chunks\":[{\"size\":1}]}]}}")
"https://gog-cdn-fastly.gog.com/content-system/v2/meta/cc/cc/cccccccccccccccccccccccccccccccc" -> JSONObject("{\"depot\":{\"items\":[{\"path\":\"empty.bin\",\"chunks\":[{\"size\":1}]}]}}")
else -> error("Unexpected metadata: $url")
}
})
assertEquals(setOf("1", "2"), snapshot.products.toSet())
assertEquals("1", snapshot.files.getJSONObject(0).getString("product"))
assertEquals("2", snapshot.files.getJSONObject(1).getString("product"))
assertEquals("1", snapshot.files.getJSONObject(2).getString("product"))
}
@Test fun selectsPublicBranchAndTreatsBuildIdsAsOpaque() {
val builds = JSONObject("""{"items":[
{"os":"windows","branch":"beta","build_id":"99","generation":2,"link":"https://example.invalid/beta"},
@@ -213,7 +252,7 @@ class GogMaintenanceTest {
fun fixture(name: String) = JSONObject(javaClass.classLoader!!.getResource("gog/$name")!!.readText())
val build = GogBuild("3301", "", 1, "", "https://example.invalid/repository.json")
val requested = ArrayList<String>()
val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused") { url, _ ->
val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused", metadata = { url, _ ->
requested.add(url)
when {
url == build.link -> fixture("rayman-gen1-repository.json")
@@ -221,7 +260,7 @@ class GogMaintenanceTest {
url.endsWith("ec88140c-2589-41a0-bbf3-8fc1f215fe5a.json") -> fixture("rayman-gen1-english-excerpt.json")
else -> error("Unexpected metadata: $url")
}
}
})
assertEquals("3301", snapshot.build)
assertEquals(1, snapshot.generation)
assertEquals(4, snapshot.files.length())
@@ -269,6 +308,17 @@ class GogMaintenanceTest {
assertThrows(IllegalArgumentException::class.java) { GogFiles.inside(folder, "link/game.exe") }
}
@Test fun resumedInstallSpaceCountsOnlyFilesStillMissing() {
val folder = temporary.newFolder()
val done = File(folder, "done.bin").apply { writeText("verified") }
val files = JSONArray()
.put(JSONObject().put("path", done.name).put("size", done.length()).put("md5", GogFiles.hash(done)))
.put(JSONObject().put("path", "missing.bin").put("size", 12L).put("md5", "0".repeat(32)))
val missingOnly = GogInstallRecord("1", "depot", files = JSONArray().put(files.getJSONObject(1)))
fun required(target: GogInstallRecord) = GogEstimate.calculate(folder, GogInstallRecord("1", "depot"), target, JSONArray(), reuseChunks = false).required
assertEquals(required(missingOnly), required(GogInstallRecord("1", "depot", files = files)))
}
@Test fun sameSizeCorruptionFailsVerificationAndLanguageVariantsRemainDistinct() {
val folder = temporary.newFolder()
val file = File(folder, "game.bin").apply { writeText("original") }
@@ -1,43 +0,0 @@
package com.droiddeck.launcher.stores.gog
import java.io.File
import java.security.MessageDigest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowStatFs
import com.droiddeck.launcher.stores.StoreInstallRoot
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GogResumeSpaceTest {
@Test fun remainingWorkRejectsMeasuredZeroButNotUnknownSpace() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-zero").apply { mkdirs() }
ShadowStatFs.registerStats(root.path, 1024, 0, 0)
val files = listOf(GogDownloadManager.DepotFile("missing").apply { totalSize = 4 })
val needed = GogDownloadManager.remainingInstallBytes(files, root)
assertEquals(4L, needed)
assertTrue(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.availableBytes(root)))
assertFalse(StoreInstallRoot.insufficientSpace(needed, StoreInstallRoot.SPACE_UNKNOWN))
}
@Test fun remainingSpaceExcludesVerifiedFilesButIncludesCorruptAndPartialFiles() {
val root = File(RuntimeEnvironment.getApplication().filesDir, "gog-resume").apply { mkdirs() }
val data = byteArrayOf(1, 2, 3, 4)
val hash = MessageDigest.getInstance("MD5").digest(data).joinToString("") { "%02x".format(it) }
val files = listOf("good", "corrupt", "partial", "missing").map {
GogDownloadManager.DepotFile(it).apply { totalSize = 4; md5 = hash }
}
File(root, "good").writeBytes(data)
File(root, "corrupt").writeBytes(ByteArray(4))
File(root, "partial").writeBytes(byteArrayOf(1))
assertEquals(12L, GogDownloadManager.remainingInstallBytes(files, root))
for (file in files) File(root, file.relativePath).writeBytes(data)
assertEquals(0L, GogDownloadManager.remainingInstallBytes(files, root))
}
}
@@ -1,27 +0,0 @@
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,11 +26,4 @@ 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))
}
}
-63
View File
@@ -1,63 +0,0 @@
# 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 8 minutes on a Snapdragon 865; later ones compile only what changed.
That needs the Kotlin daemon: Kotlin's in-process compiles always redo the whole app. BuildConfig
is given the installed commit, so committing a change does not change it. Setup
does that first build ahead of time with `dd-lab-build --prepare`, which compiles the installed
commit without making a Lab build.
`~/.cache/dd-lab-build.json` reports the stage, the current task, progress in thousandths and the
seconds left, estimated from how long each task took the last time (`~/.cache/dd-lab-build-times`).
`~/.cache/dd-lab-build-base` holds the installed commit the last successful build compiled, so the
Agent tab can offer to prepare again after DroidDeck updates.
-9
View File
@@ -1,9 +0,0 @@
#!/bin/sh
# Opens a URL in Android's browser on the bottom screen.
url=$1
[ -n "$url" ] || exit 1
serial=${ADB_SERIAL:-127.0.0.1:5555}
adb connect "$serial" >/dev/null 2>&1
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
adb -s "$serial" shell "am start --display 2 -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
echo "Open this on any device: $url" >&2
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
# Claude Code in the DroidDeck checkout, with sign-in links opened in Android's browser.
export ADB_SERIAL=${ADB_SERIAL:-127.0.0.1:5555} PATH="$HOME/.local/bin:$PATH" BROWSER="$HOME/.local/bin/android-open"
adb connect "$ADB_SERIAL" >/dev/null 2>&1
cd "$HOME/DroidDeck" && exec claude "$@"
-23
View File
@@ -1,23 +0,0 @@
#!/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"
-221
View File
@@ -1,221 +0,0 @@
#!/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] [--explain]
# --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.
# --explain logs why each task ran and Kotlin's build report, to find out what makes a build slow.
set -euo pipefail
pkg=com.droiddeck.launcher
serial=${ADB_SERIAL:-127.0.0.1:5555}
mode=build
explain=()
for arg in "$@"; do
case $arg in
--no-restart) mode=no-restart ;;
--prepare) mode=prepare ;;
--explain) explain=(--info -Pkotlin.build.report.output=file) ;;
esac
done
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
# BuildConfig describes the installed base, not each Lab commit; its commit changing would make the
# Kotlin compile redo the whole app (the overlay's own label names the Lab build).
export DROIDDECK_SOURCE_COMMIT=$base DROIDDECK_BUILD_LABEL="lab on ${base:0:7}" DROIDDECK_BUILD_TREE_STATE=clean GITHUB_HEAD_REF=lab
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 2.5 GB of heap and Gradle about 1 GB; smaller devices get
# what they can spare. Both daemons end with this script's process when the build is done.
total_mb=$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo)
heap=$(( total_mb / 2 )); [ $heap -gt 4096 ] && heap=4096; [ $heap -lt 2048 ] && heap=2048
heap=${DD_LAB_HEAP_MB:-$heap}
kotlin_heap=$(( heap * 2 / 3 )); gradle_heap=$(( heap - kotlin_heap ))
# Only the Kotlin daemon compiles incrementally; in-process compiles always redo the whole app, and the
# caches they leave behind make the daemon's first incremental compile fail.
if [ "$(cat "$cache/dd-lab-build-strategy" 2>/dev/null)" != daemon ]; then
rm -rf app/build/kotlin/compileReleaseKotlin
echo daemon > "$cache/dd-lab-build-strategy"
fi
# 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=daemon
-Pkotlin.daemon.jvmargs=-Xmx${kotlin_heap}m -Dorg.gradle.jvmargs=-Xmx${gradle_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[@]}" ${explain[@]+"${explain[@]}"} 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
if [ -n "$task" ] && [ $ran = 1 ]; then echo "$task $(( $(date +%s) - since ))" >> "$measured"; fi
}
status=${PIPESTATUS[0]}
set -o pipefail
kill $heartbeat 2>/dev/null || true
[ "$status" = 0 ] || fail "Gradle failed (exit $status); the reason is above"
# Tasks that did no work this time keep their earlier time.
{ [ -f "$times_file" ] && cat "$times_file"; cat "$measured"; } | awk '{t[$1] = $2} END {for (k in t) print k, t[k]}' > "$times_file.part"
mv "$times_file.part" "$times_file"
# The app reads which installed commit the build caches are warm for.
echo "$base" > "$cache/dd-lab-build-base"
if [ $mode = prepare ]; then
progress prepared 1000
echo "Prepared: Lab builds of branches on ${base:0:9} now compile only what changed ($(( ($(date +%s) - started) / 60 ))m)"
exit 0
fi
progress packaging 995
linked=$(ls app/build/intermediates/linked_resources_binary_format/release/processReleaseResources/*.ap_ | head -1)
work=$(mktemp -d)
cp "$linked" "$work/overlay.apk"
(cd app/build/intermediates/dex/release/mergeDexRelease && zip -q "$work/overlay.apk" classes*.dex)
name="$(git branch --show-current) @ $(git rev-parse --short HEAD)"
python3 -c 'import json,sys,time; print(json.dumps({"label": sys.argv[1], "baseCommit": sys.argv[2], "baseApk": sys.argv[3], "built": int(time.time())}))' \
"$name" "$base" "$apk" > "$work/overlay.json"
lab=/data/user/0/$pkg/files/lab
mkdir -p "$lab"
rm -rf "$lab/next.part" "$lab/next"
mv "$work" "$lab/next.part"
mv "$lab/next.part" "$lab/next"
progress ready 1000 "$name"
echo "Lab build ready: $name ($(du -h "$lab/next/overlay.apk" | cut -f1)) in $(( ($(date +%s) - started) / 60 ))m"
[ $mode = build ] || exit 0
# Classes in an app image load before the Lab build can, so the app is recompiled without one.
device cmd package compile -m verify -f $pkg >/dev/null
progress restarting 1000 "$name"
echo "Restarting DroidDeck on the Lab build; it shows a notification when it is back"
# The app's own agent ends with the app, so it is told to carry on once the app is back.
if [ -n "${DD_AGENT:-}" ]; then mkdir -p "/data/user/0/$pkg/files/agent" && : > "/data/user/0/$pkg/files/agent/resume"; fi
device "nohup sh -c 'sleep 3; monkey -p $pkg -c android.intent.category.LAUNCHER 1' >/dev/null 2>&1 &"
for pid in $(device pidof $pkg); do kill "$pid" 2>/dev/null || true; done
-61
View File
@@ -1,61 +0,0 @@
#!/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."
+6 -1
View File
@@ -263,10 +263,15 @@ def displays(adb: str, serial: str) -> list[dict]:
device = re.search(r"mPrimaryDisplayDevice=(.+)", block)
size = re.search(r"real (\d+) x (\d+)", block)
name = device.group(1).strip() if device else None
physical = devices.get(name) if name else None
if name and not physical:
# Some ROMs (Retroid Pocket Duo) print the device as "Built-in Screen(local:<id>)"
inline = re.search(r"\(local:(\d+)\)$", name)
physical = inline.group(1) if inline else None
out.append({
"display": logical,
"name": name,
"physicalId": devices.get(name) if name else None,
"physicalId": physical,
"size": [int(size.group(1)), int(size.group(2))] if size else None,
})
return out
@@ -927,6 +927,33 @@ def steam_shortcut_state(agent_dir, game_id, timeout=6):
time.sleep(min(0.5, max(0, deadline - time.monotonic())))
def steam_ready(agent_dir):
expression = """(() => {
const store = globalThis.appStore;
return !!store?.m_bIsInitialized && typeof globalThis.SteamClient?.Apps?.ContinueGameAction === 'function';
})()"""
return cdp_eval(agent_dir, expression, await_promise=False, timeout=3).get("value") is True
def steam_clear_pending_interstitial(agent_dir, app_id):
if not isinstance(app_id, str) or not app_id.isascii() or not app_id.isdigit() or int(app_id) <= 0:
raise AgentError("INVALID_APP_ID", "appId must be a positive decimal Steam app id")
expression = """(async () => {
const apps = globalThis.SteamClient?.Apps;
if (typeof apps?.GetActiveGameActions !== 'function' || typeof apps.ContinueGameAction !== 'function') return false;
const active = await apps.GetActiveGameActions() || [];
for (const action of active) {
const appid = String(action?.strAppID ?? action?.gameid ?? action?.nAppID ?? '');
if (appid === %s && action?.strTaskName === 'ShowInterstitials') {
apps.ContinueGameAction(action.nGameActionID, appid, 'ShowInterstitials');
return true;
}
}
return false;
})()""" % json.dumps(app_id)
return cdp_eval(agent_dir, expression, timeout=3).get("value") is True
def game_pids(app_id):
wanted = [("SteamAppId=%s" % app_id).encode(), ("SteamGameId=%s" % app_id).encode()]
me = os.getpid()
@@ -1261,6 +1288,19 @@ def main(argv):
return 1
print(state)
return {"ready": 0, "missing": 4, "not-shortcut": 3}.get(state, 1)
if argv and argv[0] == "--steam-ready":
try:
return 0 if steam_ready(Path(base) / "agent") else 1
except (AgentError, OSError, ValueError):
return 1
if argv and argv[0] == "--steam-clear-pending-interstitial":
if len(argv) != 2:
print("usage: droiddeck-agent --steam-clear-pending-interstitial <appid>", file=sys.stderr)
return 2
try:
return 0 if steam_clear_pending_interstitial(Path(base) / "agent", argv[1]) else 1
except (AgentError, OSError, ValueError):
return 1
serve(Path(base) / "agent", focus="--no-focus" not in argv)
return 0
@@ -1,6 +1,7 @@
#!/bin/bash
set -u
export FLATPAK_BWRAP=/usr/local/bin/droiddeck-bwrap
unset MESA_LOADER_DRIVER_OVERRIDE GALLIUM_DRIVER LIBGL_KOPPER_DRI2 VK_ICD_FILENAMES VK_DRIVER_FILES
case ":${XDG_DATA_DIRS:-}:" in
*"/flatpak/exports/share:"*) ;;
*) export XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} ;;
@@ -333,12 +333,12 @@ def engine_fixes(exe, args):
return env, extra, notes
def win_components(compat, app_id):
def win_components(compat, app_id, proton=None, env=None):
if not compat:
return ""
try:
module = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wincomponents"))
names, notes = module["apply"](compat.rstrip("/"), app_id)
names, notes = module["apply"](compat.rstrip("/"), app_id, proton=proton, env=env or os.environ)
for note in notes:
print("droiddeck-game-env: " + note, file=sys.stderr)
return module["overrides_value"](names)
@@ -859,6 +859,60 @@ WINE_DLL_DIRS = ("/lib/wine/i386-windows", "/lib/wine/x86_64-windows", "/lib/win
"/lib/wine/fakedlls", "/lib64/wine", "/lib64/wine/fakedlls", "/lib64/wine/x86_64-windows")
FOREIGN_SUFFIX = ".foreign-arch"
PE = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wine-pe"))
pe_machine = PE["pe_machine"]
def repair_foreign_system_dlls(prefix, proton, env=None):
dist = Path(proton).parent / "files/lib/wine"
windows = Path(prefix) / "pfx/drive_c/windows"
machines_by_folder = PE["proton_pe_machines"](proton, env or {})
if machines_by_folder is None:
return []
repaired = []
for folder, (libdir, machines) in machines_by_folder.items():
builtins = dist / libdir
try:
builtin_names = {name.lower(): name for name in os.listdir(builtins)}
except OSError:
continue
try:
names = sorted(os.listdir(windows / folder))
except OSError:
continue
for name in names:
path = windows / folder / name
builtin_name = builtin_names.get(name.lower())
source = builtins / builtin_name if builtin_name else builtins / name
if path.is_symlink() or not name.lower().endswith(".dll") or not source.is_file() or not path.is_file():
continue
machine = pe_machine(path)
if machine is None or PE["machine_compatible"](path, machines, machine):
continue
link = windows / folder / (name + ".bl-new")
backup = windows / folder / (name + FOREIGN_SUFFIX)
serial = 1
while os.path.lexists(backup):
backup = windows / folder / (name + FOREIGN_SUFFIX + ".%d" % serial)
serial += 1
try:
os.symlink(os.path.relpath(source, path.parent), link)
os.replace(path, backup)
try:
os.replace(link, path)
except OSError:
if not os.path.lexists(path) and os.path.lexists(backup):
os.replace(backup, path)
raise
except OSError:
if link.is_symlink():
link.unlink()
continue
repaired.append("%s/%s (machine 0x%x)" % (folder, name, machine))
return repaired
def is_wine_dll_link(path):
try:
return os.path.islink(path) and os.path.dirname(os.readlink(path)).endswith(WINE_DLL_DIRS)
@@ -1156,6 +1210,42 @@ def main():
env = game_gpu(os.environ.copy())
mic_enabled = env.get("BL_MIC_ENABLED")
debug_dir = env.get("BL_DEBUG_DIR", "")
prefix_path = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
app_id = game_id(env)
components = ""
waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun")
config = {}
launch_config = None
launch_config_valid = True
effective_env = env
if waiting:
path = Path.home() / ".config/droiddeck/game-environment.json"
try:
with path.open(encoding="utf-8") as source:
config = json.load(source)
launch_config = config
except FileNotFoundError:
launch_config = {"version": 1}
except (OSError, ValueError, TypeError, AttributeError):
launch_config_valid = False
if launch_config_valid:
try:
effective_env = apply_config(env, launch_config, app_id)
except (OSError, ValueError, TypeError, AttributeError):
launch_config_valid = False
if not launch_config_valid:
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
if waiting:
components = win_components(prefix_path, app_id, command[0], effective_env)
if prefix_path and command:
try:
fixed = repair_foreign_system_dlls(prefix_path, command[0], effective_env)
except OSError as error:
fixed = []
print("droiddeck-game-env: prefix system DLL check skipped (%s)" % error, file=sys.stderr)
if fixed:
print("droiddeck-game-env: replaced foreign-architecture system DLLs with this Proton's own: %s" % ", ".join(fixed),
file=sys.stderr)
scripts = mark_install_scripts(command, env)
if scripts and install_scripts_done(scripts, env):
print("droiddeck-game-env: install scripts already run in this prefix; evaluator skipped", file=sys.stderr)
@@ -1164,8 +1254,6 @@ def main():
digest = scripts_digest(scripts) if scripts else None
except OSError:
digest = None
app_id = game_id(env)
waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun")
diagnostic_app_id = app_id if waiting and os.environ.get("DROIDDECK_ENHANCED_DIAGNOSTICS") == "1" else None
dirt_prefix = None
if app_id:
@@ -1183,7 +1271,6 @@ def main():
for note in notes:
print("droiddeck-game-env: " + note, file=sys.stderr)
command += extra
components = win_components(env.get("STEAM_COMPAT_DATA_PATH", ""), app_id)
if components:
own = fixes.get("WINEDLLOVERRIDES")
fixes["WINEDLLOVERRIDES"] = components + ";" + own if own else components
@@ -1191,22 +1278,21 @@ def main():
if dirt_prefix:
own = fixes.get("WINEDLLOVERRIDES")
fixes["WINEDLLOVERRIDES"] = own + ";ws2_32=n,b" if own else "ws2_32=n,b"
path = Path.home() / ".config/droiddeck/game-environment.json"
config = {}
try:
with path.open(encoding="utf-8") as source:
config = json.load(source)
if waiting:
if waiting:
if launch_config_valid:
if "vblank_mode" in config.get("shared", {}):
fixes.pop("vblank_mode", None)
env = apply_config(env, config, app_id, fixes)
else:
env = apply_config(env, launch_config, app_id, fixes)
else:
path = Path.home() / ".config/droiddeck/game-environment.json"
try:
with path.open(encoding="utf-8") as source:
config = json.load(source)
env = apply_config(env, sync_config(config, app_id), app_id)
except FileNotFoundError:
if waiting:
env = apply_config(env, {"version": 1}, app_id, fixes)
except (OSError, ValueError, TypeError, AttributeError):
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
except FileNotFoundError:
pass
except (OSError, ValueError, TypeError, AttributeError):
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
if waiting:
before_debug = env
env = apply_game_debug_logs(env, config, debug_dir)
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more