mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-10 01:51:33 -10:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d46d2461a | ||
|
|
7426c6b682 | ||
|
|
0995cbd5c7 | ||
|
|
2cbb1e47e0 | ||
|
|
9e3814b546 | ||
|
|
3e349d4c78 | ||
|
|
deb9e950b7 | ||
|
|
d3ae5579ad | ||
|
|
58c83968de | ||
|
|
eb3da7595f | ||
|
|
90b80cb84b | ||
|
|
dd075ee138 | ||
|
|
173ffac05b | ||
|
|
c64a72b2a1 | ||
|
|
9a6c46e4ac | ||
|
|
fc46d3c658 | ||
|
|
53cdf00b43 | ||
|
|
7836c32aac | ||
|
|
d9358e8f19 | ||
|
|
5d25f79fa7 | ||
|
|
d44bf32ae8 | ||
|
|
bdaa155baa | ||
|
|
44e72258b1 | ||
|
|
77e30e4b4e | ||
|
|
3f5e3bb19d | ||
|
|
8ffac40b4f | ||
|
|
8337166cc9 | ||
|
|
2d15205ff7 | ||
|
|
71481ac2ff | ||
|
|
2e34213b28 | ||
|
|
05efdf61d8 | ||
|
|
f638979507 | ||
|
|
9d73ff75bc | ||
|
|
2213362336 | ||
|
|
5683be3b9b | ||
|
|
d7dd802429 | ||
|
|
0cf83ce71d | ||
|
|
acecbcd79a | ||
|
|
b6d70b177d | ||
|
|
5f0519dfbf | ||
|
|
914c659f4a | ||
|
|
a178553254 | ||
|
|
774abb738d | ||
|
|
5d63cc3b9e | ||
|
|
f3f3f9ce08 |
No files matched your search
@@ -10,3 +10,4 @@ tools/mangoapp/mangoapp text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.json text eol=lf
|
||||
app/src/main/assets/desktop-removal/*.txt text eol=lf
|
||||
*.pdf binary
|
||||
app/src/main/assets/agent/* text eol=lf
|
||||
@@ -302,6 +302,7 @@
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
|
||||
android:theme="@style/SessionTheme" />
|
||||
|
||||
<service android:name=".agent.AgentHostService" android:exported="false" />
|
||||
<service
|
||||
android:name=".session.SessionService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Opens a URL in Android's browser on the display the agent is being used on ($DD_BROWSER_DISPLAY).
|
||||
url=$1
|
||||
[ -n "$url" ] || exit 1
|
||||
serial=${ADB_SERIAL:-127.0.0.1:5555}
|
||||
adb connect "$serial" >/dev/null 2>&1
|
||||
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
|
||||
adb -s "$serial" shell "am start --display ${DD_BROWSER_DISPLAY:-0} -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
|
||||
echo "Open this on any device: $url" >&2
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
# GitHub sign-in through Android's browser, with the one-time code typed in for the person.
|
||||
serial=${ADB_SERIAL:-127.0.0.1:5555}
|
||||
display=${DD_BROWSER_DISPLAY:-0}
|
||||
adb connect "$serial" >/dev/null 2>&1
|
||||
coproc GH { BROWSER=true gh auth login --hostname github.com --git-protocol https --web 2>&1; }
|
||||
typed=
|
||||
while IFS= read -r line <&"${GH[0]}"; do
|
||||
printf '%s\n' "$line"
|
||||
code=$(grep -oE '[A-Z0-9]{4}-[A-Z0-9]{4}' <<<"$line" | head -1)
|
||||
if [ -n "$code" ] && [ -z "$typed" ]; then
|
||||
typed=1
|
||||
echo >&"${GH[1]}"
|
||||
adb -s "$serial" shell "am start --display $display -a android.intent.action.VIEW -d https://github.com/login/device" >/dev/null 2>&1
|
||||
# Chrome drops characters from `input text` and from fast key events on this page.
|
||||
keys=
|
||||
for ((i = 0; i < ${#code}; i++)); do
|
||||
c=${code:i:1}
|
||||
[ "$c" = - ] || keys+="input -d $display keyevent KEYCODE_$c; sleep 0.4; "
|
||||
done
|
||||
(sleep 7; adb -s "$serial" shell "$keys" >/dev/null 2>&1) &
|
||||
fi
|
||||
done
|
||||
wait "$GH_PID"
|
||||
@@ -30,7 +30,6 @@
|
||||
#include <linux/uinput.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
@@ -209,33 +208,23 @@ 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;
|
||||
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 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 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_placed;
|
||||
static int g_game_picture_top;
|
||||
|
||||
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_RIGHT) alignment = VKP_ALIGN_CENTER;
|
||||
if (alignment < VKP_ALIGN_CENTER || alignment > VKP_ALIGN_PICTURE_BOTTOM) alignment = VKP_ALIGN_CENTER;
|
||||
g_mode = mode;
|
||||
g_align = alignment;
|
||||
}
|
||||
@@ -383,8 +383,6 @@ 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";
|
||||
}
|
||||
}
|
||||
@@ -422,11 +420,9 @@ 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_placed = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM || align == VKP_ALIGN_PICTURE_LEFT || align == VKP_ALIGN_PICTURE_RIGHT;
|
||||
g_game_picture_top = align == VKP_ALIGN_PICTURE_TOP || align == VKP_ALIGN_PICTURE_BOTTOM;
|
||||
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;
|
||||
@@ -440,7 +436,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_placed;
|
||||
int active = g_game_picture_top;
|
||||
if (active) memcpy(rect, g_game_picture_rect, sizeof(g_game_picture_rect));
|
||||
pthread_mutex_unlock(&g_game_picture_lock);
|
||||
return active;
|
||||
|
||||
@@ -15,9 +15,7 @@ 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,
|
||||
VKP_ALIGN_PICTURE_LEFT = 5, VKP_ALIGN_PICTURE_RIGHT = 6 };
|
||||
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 };
|
||||
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,8 +7,11 @@ import com.droiddeck.launcher.session.EnhancedSessionLogShare
|
||||
import com.droiddeck.launcher.session.GpuClockPin
|
||||
|
||||
class App : Application() {
|
||||
override fun attachBaseContext(base: android.content.Context) =
|
||||
override fun attachBaseContext(base: android.content.Context) {
|
||||
super.attachBaseContext(com.droiddeck.launcher.core.AppLanguage.wrap(base))
|
||||
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName() else packageName
|
||||
com.droiddeck.launcher.lab.LabOverlay.load(this, process == packageName)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
@@ -39,6 +42,9 @@ class App : Application() {
|
||||
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
|
||||
process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
|
||||
CrashHandler.install(this)
|
||||
com.droiddeck.launcher.agent.AgentHost.refresh(this)
|
||||
if (com.droiddeck.launcher.agent.AgentHost.available) com.droiddeck.launcher.lab.LabNotice.openOnInstall = "agent"
|
||||
com.droiddeck.launcher.lab.LabNotice.started(this)
|
||||
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
|
||||
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
|
||||
|
||||
@@ -1104,6 +1104,8 @@ class FrontEndActivity : ComponentActivity() {
|
||||
gameSpaceOffered = savedInstanceState?.getBoolean(STATE_GAME_SPACE_OFFERED) == true
|
||||
if (savedInstanceState == null) readIntegrationIntent(intent)
|
||||
readGameIntent(intent)
|
||||
// A Lab build is usually the agent's work, so the app comes back where the agent is.
|
||||
if (savedInstanceState == null && navRequest == null && com.droiddeck.launcher.lab.LabOverlay.promoted && com.droiddeck.launcher.agent.AgentHost.available) navRequest = "agent"
|
||||
gameSyncFolder = GameFileSync.folder(this)
|
||||
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
|
||||
refreshPhantomStatus()
|
||||
|
||||
@@ -121,7 +121,6 @@ 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
|
||||
@@ -173,8 +172,6 @@ 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)
|
||||
@@ -1151,6 +1148,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onClose = { pcKeyboardOpen = false },
|
||||
)
|
||||
if (!drawerOpen && !pcKeyboardOpen) com.droiddeck.launcher.ui.AgentBubble {
|
||||
drawerPage = SessionDrawerPage.AGENT
|
||||
drawerOpen = true
|
||||
}
|
||||
if (!drawerDocked) SessionMenu(docked = false)
|
||||
LaunchedEffect(achievementsOpen) {
|
||||
if (achievementsOpen) {
|
||||
@@ -2438,7 +2439,6 @@ 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,12 +2451,6 @@ 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()
|
||||
@@ -2481,7 +2475,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val hint = sessionExitHint(status)
|
||||
runOnUiThread {
|
||||
if (isFinishing || isDestroyed) return@runOnUiThread
|
||||
if (status == 0) SessionState.takeRelaunch()?.let { next ->
|
||||
if (status == 0) SessionState.relaunch?.let { next ->
|
||||
SessionState.relaunch = null
|
||||
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
|
||||
next.putExtra(EXTRA_LINK_TASK, intent.getIntExtra(EXTRA_LINK_TASK, -1))
|
||||
setIntent(next)
|
||||
@@ -3215,17 +3210,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
cursorVisible = false
|
||||
return
|
||||
}
|
||||
val rect = if (glassesOutputActive) pointerSurfaceRect() else drawnRect()
|
||||
val pointerRect = rect ?: return
|
||||
val rect = drawnRect() ?: return
|
||||
val position = cursorPos
|
||||
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 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 out = SessionState.outputSize
|
||||
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)
|
||||
movePointer(x + dx * rect.width() / out.first, y + dy * rect.height() / out.second)
|
||||
// Make frame-callback updates visible to drawing before this frame's traversal.
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
}
|
||||
@@ -3934,29 +3924,8 @@ 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) {
|
||||
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
|
||||
}
|
||||
if (topPictureMapped) return android.graphics.RectF(
|
||||
gamePictureBounds[0], gamePictureBounds[1], gamePictureBounds[2], gamePictureBounds[3])
|
||||
val out = SessionState.outputSize
|
||||
val scale = minOf(width / out.first.toFloat(), height / out.second.toFloat())
|
||||
val drawnW = out.first * scale
|
||||
@@ -5521,8 +5490,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.WirelessAdbFix
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Loopback adb for the agent, so droiddeckctl in the Linux userland reaches the app. It reuses
|
||||
* the Wireless debugging pairing DroidDeck already made (for the phantom process fix): the paired
|
||||
* key goes to the guest as an adb vendor key, and the serial is the paired TLS connect port.
|
||||
* Without a pairing it falls back to adbd on 127.0.0.1:5555, which some devices and `adb tcpip`
|
||||
* leave listening.
|
||||
*/
|
||||
object AgentAdb {
|
||||
data class Link(val serial: String, val vendorKeys: String, val paired: Boolean)
|
||||
|
||||
private const val LEGACY = "127.0.0.1:5555"
|
||||
private const val VENDOR_KEY = "/root/.android/droiddeck-paired.adbkey"
|
||||
|
||||
/** The display the browser opens on for sign-ins: the one the agent is being used on. */
|
||||
@Volatile var browserDisplay = 0
|
||||
|
||||
/** Blocks while it looks up the connect port when none is saved; call it off the main thread. */
|
||||
fun prepare(app: Context): Link {
|
||||
val key = File(app.noBackupFilesDir, "wireless-adb-key.pem")
|
||||
val address = WirelessAdbFix.savedConnectionAddress(app)
|
||||
if (address == null || !key.isFile) return Link(LEGACY, VENDOR_KEY, false)
|
||||
val guestKey = File(LinuxRuntime.rootDir(app), VENDOR_KEY.removePrefix("/"))
|
||||
runCatching {
|
||||
if (!guestKey.isFile || !guestKey.readBytes().contentEquals(key.readBytes())) {
|
||||
guestKey.parentFile?.mkdirs()
|
||||
guestKey.writeBytes(key.readBytes())
|
||||
guestKey.setReadable(false, false)
|
||||
guestKey.setReadable(true, true)
|
||||
}
|
||||
}
|
||||
val host = address.substringBeforeLast(':')
|
||||
val port = address.substringAfterLast(':').toIntOrNull()
|
||||
?: WirelessAdbFix.findConnectPort(app, host.trim('[', ']'))
|
||||
?: return Link(LEGACY, VENDOR_KEY, false)
|
||||
return Link("$host:$port", VENDOR_KEY, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Files the user attaches to a message, copied into the Linux userland so the agent's own tools
|
||||
* can read them (Claude Code's Read shows images and PDFs to the model).
|
||||
*/
|
||||
object AgentAttachments {
|
||||
/** Guest paths waiting to go with the next message. */
|
||||
val pending = mutableStateListOf<String>()
|
||||
|
||||
private const val GUEST_DIR = "/root/.cache/droiddeck/attachments"
|
||||
private val IMAGE = setOf("png", "jpg", "jpeg", "webp", "gif", "bmp")
|
||||
|
||||
fun hostDir(context: Context) = File(LinuxRuntime.rootDir(context.applicationContext), GUEST_DIR.removePrefix("/"))
|
||||
|
||||
fun host(context: Context, guestPath: String) = File(LinuxRuntime.rootDir(context.applicationContext), guestPath.removePrefix("/"))
|
||||
|
||||
fun isImage(path: String) = path.substringAfterLast('.', "").lowercase() in IMAGE
|
||||
|
||||
/** Copies [uris] for [chat] (or a new conversation) on the calling thread; returns their guest paths. */
|
||||
fun copy(context: Context, chat: String?, uris: List<Uri>): List<String> {
|
||||
val app = context.applicationContext
|
||||
val folder = chat ?: "new"
|
||||
val dir = File(hostDir(app), folder).apply { mkdirs() }
|
||||
return uris.mapNotNull { uri ->
|
||||
runCatching {
|
||||
val name = displayName(app, uri).replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "file" }
|
||||
var target = File(dir, name)
|
||||
var n = 1
|
||||
while (target.exists()) target = File(dir, "${name.substringBeforeLast('.')}-${n++}${name.substringAfterLast('.', "").let { if (it.isEmpty()) "" else ".$it" }}")
|
||||
app.contentResolver.openInputStream(uri)!!.use { input -> target.outputStream().use { input.copyTo(it) } }
|
||||
"$GUEST_DIR/$folder/${target.name}"
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** The message the CLI gets: the user's text, then where the attached files are. */
|
||||
fun prompt(text: String, files: List<String>): String {
|
||||
if (files.isEmpty()) return text
|
||||
val list = files.joinToString("\n") { "- $it" }
|
||||
return "$text\n\nAttached files (read them with your tools):\n$list".trim()
|
||||
}
|
||||
|
||||
private fun displayName(context: Context, uri: Uri): String =
|
||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
|
||||
if (c.moveToFirst()) c.getString(0) else null
|
||||
} ?: uri.lastPathSegment?.substringAfterLast('/') ?: "file"
|
||||
}
|
||||
@@ -284,6 +284,8 @@ class AgentBridgeProvider : ContentProvider() {
|
||||
return JSONObject()
|
||||
.put("schema", SCHEMA)
|
||||
.put("build", BuildConfig.BUILD_LABEL)
|
||||
.put("apkCommit", com.droiddeck.launcher.lab.LabOverlay.apkCommit)
|
||||
.put("lab", com.droiddeck.launcher.lab.LabOverlay.active?.label ?: JSONObject.NULL)
|
||||
.put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName)
|
||||
.put("runtime", JSONObject()
|
||||
.put("installed", LinuxRuntime.isInstalled(context))
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.agent.AgentHost.Entry
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Conversations kept on the device, one JSON Lines file each in `files/agent/chats`: a header
|
||||
* line with the harness, a line with the CLI's conversation id once it has one, then the entries.
|
||||
*/
|
||||
object AgentChats {
|
||||
data class Chat(val id: String, val harness: String, val title: String, val updated: Long)
|
||||
|
||||
class Loaded(val harness: String?, val conversation: String?, val entries: List<Entry>)
|
||||
|
||||
private const val MAX_OUTPUT = 24_000
|
||||
|
||||
fun dir(context: Context) = File(context.filesDir, "agent/chats")
|
||||
|
||||
fun file(context: Context, id: String) = File(dir(context), "$id.jsonl")
|
||||
|
||||
fun newId(context: Context): String {
|
||||
var stamp = System.currentTimeMillis()
|
||||
while (file(context, stamp.toString()).exists()) stamp++
|
||||
return stamp.toString()
|
||||
}
|
||||
|
||||
/** Newest first; chats with nothing asked yet are left out. */
|
||||
fun list(context: Context): List<Chat> = dir(context).listFiles { f -> f.name.endsWith(".jsonl") }.orEmpty()
|
||||
.mapNotNull { f ->
|
||||
val loaded = read(f)
|
||||
val title = loaded.entries.firstNotNullOfOrNull { (it as? Entry.Asked)?.text } ?: return@mapNotNull null
|
||||
Chat(f.name.removeSuffix(".jsonl"), loaded.harness ?: ClaudeCode.id, title.lineSequence().first().take(120), f.lastModified())
|
||||
}
|
||||
.sortedByDescending { it.updated }
|
||||
|
||||
fun delete(context: Context, id: String) {
|
||||
file(context, id).delete()
|
||||
File(AgentAttachments.hostDir(context), id).deleteRecursively()
|
||||
}
|
||||
|
||||
fun start(file: File, harness: String) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(JSONObject().put("harness", harness).toString() + "\n")
|
||||
}
|
||||
|
||||
fun appendConversation(file: File, conversation: String) =
|
||||
runCatching { file.appendText(JSONObject().put("conversation", conversation).toString() + "\n") }
|
||||
|
||||
fun append(file: File, entry: Entry) = runCatching { file.appendText(encode(entry) + "\n") }
|
||||
|
||||
/** The transcript before chats were kept, moved into the first chat. */
|
||||
fun migrate(context: Context, harness: String, conversation: String?): String? {
|
||||
val old = File(context.filesDir, "agent/transcript.jsonl")
|
||||
if (!old.isFile) return null
|
||||
val id = newId(context)
|
||||
val target = file(context, id)
|
||||
start(target, harness)
|
||||
conversation?.let { appendConversation(target, it) }
|
||||
target.appendText(old.readText())
|
||||
old.delete()
|
||||
return id
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun encode(entry: Entry): String = when (entry) {
|
||||
is Entry.Asked -> JSONObject().put("asked", entry.text).apply { if (entry.files.isNotEmpty()) put("files", JSONArray(entry.files)) }
|
||||
is Entry.Said -> JSONObject().put("said", entry.text)
|
||||
is Entry.Thought -> JSONObject().put("thought", entry.text)
|
||||
is Entry.Result -> JSONObject().put("result", entry.id).put("output", entry.output).put("failed", entry.failed)
|
||||
is Entry.Used -> JSONObject().put("used", entry.tool).put("detail", entry.detail).put("id", entry.id).put("input", entry.input)
|
||||
.apply { entry.output?.let { put("output", it).put("failed", entry.failed) } }
|
||||
is Entry.Finished -> JSONObject().put("finished", true).put("error", entry.error ?: JSONObject.NULL).put("stopped", entry.stopped).apply {
|
||||
entry.usage?.let { put("ms", it.durationMs).put("cost", it.costUsd).put("in", it.inputTokens).put("out", it.outputTokens) }
|
||||
}
|
||||
}.toString()
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun read(file: File): Loaded {
|
||||
var harness: String? = null
|
||||
var conversation: String? = null
|
||||
val entries = mutableListOf<Entry>()
|
||||
runCatching {
|
||||
file.forEachLine { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@forEachLine
|
||||
when {
|
||||
o.has("harness") -> harness = o.getString("harness")
|
||||
o.has("conversation") -> conversation = o.getString("conversation")
|
||||
else -> decode(o)?.let { merge(entries, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return Loaded(harness, conversation, entries)
|
||||
}
|
||||
|
||||
private fun decode(o: JSONObject): Entry? = when {
|
||||
o.has("asked") -> Entry.Asked(o.getString("asked"), o.optJSONArray("files")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty())
|
||||
o.has("said") -> Entry.Said(o.getString("said"))
|
||||
o.has("thought") -> Entry.Thought(o.getString("thought"))
|
||||
o.has("used") -> Entry.Used(o.optString("id"), o.getString("used"), o.optString("detail"), o.optString("input"),
|
||||
if (o.has("output")) o.optString("output") else null, o.optBoolean("failed"))
|
||||
o.has("result") -> Entry.Result(o.getString("result"), o.optString("output"), o.optBoolean("failed"))
|
||||
o.has("finished") -> Entry.Finished(
|
||||
if (o.isNull("error")) null else o.optString("error"), o.optBoolean("stopped"),
|
||||
if (o.has("ms")) AgentHost.Usage(o.optLong("ms"), o.optDouble("cost", 0.0), o.optLong("in"), o.optLong("out")) else null,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** A tool's result is shown on the call it answers, and a call seen again (an updated todo list) replaces it. */
|
||||
fun merge(entries: MutableList<Entry>, entry: Entry): Boolean {
|
||||
if (entry is Entry.Used && entry.id.isNotEmpty()) {
|
||||
val same = entries.indexOfLast { it is Entry.Used && it.id == entry.id }
|
||||
if (same >= 0) {
|
||||
entries[same] = entry
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (entry !is Entry.Result) {
|
||||
entries += entry
|
||||
return true
|
||||
}
|
||||
val index = entries.indexOfLast { it is Entry.Used && it.id == entry.id && entry.id.isNotEmpty() }
|
||||
if (index < 0) return false
|
||||
entries[index] = (entries[index] as Entry.Used).copy(output = clip(entry.output), failed = entry.failed)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Long outputs keep their end, where errors and summaries usually are. */
|
||||
fun clip(output: String) = if (output.length <= MAX_OUTPUT) output else "…\n" + output.takeLast(MAX_OUTPUT)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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
|
||||
@@ -0,0 +1,487 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import com.droiddeck.launcher.FrontEndActivity
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
/** Keeps the app process alive while the agent works, and says what it is doing, with a way to stop it. */
|
||||
class AgentHostService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
if (AgentHost.lab?.running == true) AgentHost.cancelLabBuild(this)
|
||||
AgentHost.stop(this)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, build(this))
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "agent"
|
||||
private const val NOTIFICATION_ID = 4107
|
||||
private const val ACTION_STOP = "com.droiddeck.launcher.agent.STOP"
|
||||
private var lastText: String? = null
|
||||
@Volatile private var started = false
|
||||
/** Setup steps that keep the app alive while they run, besides the agent's turn. */
|
||||
private var setupSteps = 0
|
||||
|
||||
fun start(context: Context) {
|
||||
lastText = null
|
||||
started = true
|
||||
runCatching { context.startForegroundService(Intent(context, AgentHostService::class.java)) }
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
if (setupSteps > 0) return
|
||||
started = false
|
||||
context.stopService(Intent(context, AgentHostService::class.java))
|
||||
}
|
||||
|
||||
fun setupStarted(context: Context) {
|
||||
setupSteps++
|
||||
start(context)
|
||||
}
|
||||
|
||||
fun setupEnded(context: Context) {
|
||||
setupSteps = (setupSteps - 1).coerceAtLeast(0)
|
||||
if (AgentHost.state != AgentHost.State.WORKING) stop(context)
|
||||
}
|
||||
|
||||
/** Refreshes the notification with the current step; unchanged text is not re-posted. */
|
||||
fun update(context: Context) {
|
||||
if (!started) return
|
||||
val text = describe(context)
|
||||
if (text == lastText) return
|
||||
lastText = text
|
||||
context.getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, build(context))
|
||||
}
|
||||
|
||||
private fun describe(context: Context): String {
|
||||
val lab = AgentHost.lab?.takeIf { it.running }
|
||||
if (lab != null) return context.getString(R.string.agent_lab_progress, lab.permille / 10, minutes(lab.etaSeconds.toLong().coerceAtLeast(0)))
|
||||
if (AgentHost.state != AgentHost.State.WORKING) return context.getString(R.string.agent_setting_up)
|
||||
val tool = AgentHost.running()
|
||||
val elapsed = minutes((SystemClock.elapsedRealtime() - AgentHost.turnStarted) / 1000)
|
||||
return if (tool != null) "${tool.tool} · ${tool.detail}".take(120) + " · $elapsed" else context.getString(R.string.agent_thinking_for, elapsed)
|
||||
}
|
||||
|
||||
private fun minutes(seconds: Long) = if (seconds < 60) "${seconds.coerceAtLeast(0)}s" else "${seconds / 60}m ${seconds % 60}s"
|
||||
|
||||
private fun build(context: Context): Notification {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, context.getString(R.string.agent_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW).apply { setShowBadge(false) })
|
||||
val open = PendingIntent.getActivity(context, 0,
|
||||
Intent(context, FrontEndActivity::class.java)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent"),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val stop = PendingIntent.getService(context, 1, Intent(context, AgentHostService::class.java).setAction(ACTION_STOP),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val lab = AgentHost.lab?.takeIf { it.running }
|
||||
return Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(context.getString(when {
|
||||
lab?.prepare == true -> R.string.agent_lab_preparing
|
||||
lab != null -> R.string.agent_lab_building
|
||||
else -> R.string.agent_working
|
||||
}))
|
||||
.setContentText(describe(context))
|
||||
.setContentIntent(open)
|
||||
.addAction(Notification.Action.Builder(null, context.getString(R.string.agent_stop), stop).build())
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.apply { if (lab != null) setProgress(1000, lab.permille, lab.stage != "building") }
|
||||
.setCategory(Notification.CATEGORY_PROGRESS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.os.Process
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Linux userland processes, read from /proc: proot runs them as real processes under the app's
|
||||
* own uid, so the app can list and end them without a shell.
|
||||
*/
|
||||
object AgentProcesses {
|
||||
data class Proc(
|
||||
val pid: Int, val ppid: Int, val name: String, val args: String,
|
||||
val seconds: Long, val rssKb: Long, val cpuPercent: Int, val agent: Boolean,
|
||||
)
|
||||
|
||||
private const val HZ = 100L
|
||||
private var lastTicks = emptyMap<Int, Long>()
|
||||
private var lastAt = 0L
|
||||
|
||||
@VisibleForTesting
|
||||
internal class Stat(val pid: Int, val ppid: Int, val ticks: Long, val startTicks: Long, val rssPages: Long)
|
||||
|
||||
/** Guest processes, those under [turnPid] (the agent's current turn) first. */
|
||||
@Synchronized
|
||||
fun snapshot(turnPid: Int): List<Proc> {
|
||||
val me = Process.myPid()
|
||||
val uid = Process.myUid()
|
||||
val stats = HashMap<Int, Stat>()
|
||||
val args = HashMap<Int, String>()
|
||||
for (dir in File("/proc").listFiles().orEmpty()) {
|
||||
val pid = dir.name.toIntOrNull() ?: continue
|
||||
if (pid == me) continue
|
||||
val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
|
||||
if (!owned(dir, uid)) continue
|
||||
stats[pid] = stat
|
||||
args[pid] = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).trimEnd('\u0000').replace('\u0000', ' ') }.getOrDefault("")
|
||||
}
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val uptimeTicks = now * HZ / 1000
|
||||
val elapsed = (now - lastAt).coerceAtLeast(1)
|
||||
fun isProot(pid: Int) = args[pid].orEmpty().substringBefore(' ').endsWith("proot.so") || args[pid].orEmpty().substringBefore(' ').endsWith("/proot")
|
||||
fun ancestors(pid: Int) = generateSequence(stats[pid]?.ppid) { stats[it]?.ppid }.takeWhile { it > 1 }.take(64)
|
||||
val out = stats.values.filter { s ->
|
||||
!isProot(s.pid) && args[s.pid].orEmpty().isNotEmpty() && ancestors(s.pid).any { isProot(it) }
|
||||
}.map { s ->
|
||||
val full = args[s.pid].orEmpty()
|
||||
val cpu = lastTicks[s.pid]?.let { ((s.ticks - it) * 1000 * 100 / HZ / elapsed).toInt().coerceIn(0, 800) } ?: 0
|
||||
Proc(
|
||||
s.pid, s.ppid, full.substringBefore(' ').substringAfterLast('/'), full,
|
||||
((uptimeTicks - s.startTicks) / HZ).coerceAtLeast(0), s.rssPages * 4, cpu,
|
||||
turnPid > 0 && ancestors(s.pid).any { it == turnPid },
|
||||
)
|
||||
}
|
||||
lastTicks = stats.mapValues { it.value.ticks }
|
||||
lastAt = now
|
||||
return out.sortedWith(compareByDescending<Proc> { it.agent }.thenBy { it.seconds })
|
||||
}
|
||||
|
||||
/** Ends [pid] and everything it started, children first so none is left to be re-parented. */
|
||||
fun killTree(pid: Int) {
|
||||
if (pid <= 0) return
|
||||
val children = HashMap<Int, MutableList<Int>>()
|
||||
for (dir in File("/proc").listFiles().orEmpty()) {
|
||||
val child = dir.name.toIntOrNull() ?: continue
|
||||
val stat = runCatching { parseStat(File(dir, "stat").readText()) }.getOrNull() ?: continue
|
||||
children.getOrPut(stat.ppid) { mutableListOf() } += child
|
||||
}
|
||||
val order = mutableListOf<Int>()
|
||||
fun walk(p: Int) {
|
||||
children[p].orEmpty().forEach(::walk)
|
||||
order += p
|
||||
}
|
||||
walk(pid)
|
||||
order.forEach { runCatching { Process.sendSignal(it, Process.SIGNAL_KILL) } }
|
||||
}
|
||||
|
||||
fun alive(pid: Int) = pid > 0 && File("/proc/$pid").exists()
|
||||
|
||||
/** Processes whose command line contains [text], for ending a build the app did not start. */
|
||||
fun find(text: String): List<Int> = File("/proc").listFiles().orEmpty().mapNotNull { dir ->
|
||||
val pid = dir.name.toIntOrNull() ?: return@mapNotNull null
|
||||
if (pid == Process.myPid()) return@mapNotNull null
|
||||
val args = runCatching { File(dir, "cmdline").readBytes().toString(Charsets.UTF_8).replace('\u0000', ' ') }.getOrDefault("")
|
||||
pid.takeIf { args.contains(text) && owned(dir, Process.myUid()) }
|
||||
}
|
||||
|
||||
private fun owned(dir: File, uid: Int) = runCatching {
|
||||
File(dir, "status").useLines { lines -> lines.first { it.startsWith("Uid:") }.split(Regex("\\s+"))[1].toInt() == uid }
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** `/proc/<pid>/stat`: the name in parentheses may hold spaces, so fields count from its end. */
|
||||
@VisibleForTesting
|
||||
internal fun parseStat(text: String): Stat {
|
||||
val pid = text.substringBefore(' ').toInt()
|
||||
val f = text.substring(text.lastIndexOf(')') + 2).split(' ')
|
||||
return Stat(pid, f[1].toInt(), f[11].toLong() + f[12].toLong(), f[19].toLong(), f[21].toLong())
|
||||
}
|
||||
|
||||
/** The pid of a [java.lang.Process] the app started. */
|
||||
fun pidOf(process: java.lang.Process): Int =
|
||||
runCatching { process.javaClass.getDeclaredField("pid").apply { isAccessible = true }.getInt(process) }.getOrNull()
|
||||
?: Regex("pid=(\\d+)").find(process.toString())?.groupValues?.get(1)?.toInt() ?: 0
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* First-run setup for the agent, as steps with a check and an action each. The first three are
|
||||
* needed to talk to the agent; GitHub and the build tools are only needed to open pull requests
|
||||
* and to make Lab builds on the device.
|
||||
*/
|
||||
object AgentSetup {
|
||||
enum class Step(val required: Boolean) { CONNECTION(true), INSTALL(true), SIGN_IN(true), GITHUB(false), BUILD_TOOLS(false), PREPARE(false) }
|
||||
enum class Status { UNKNOWN, DONE, TODO, WORKING, FAILED }
|
||||
|
||||
val status = mutableStateMapOf<Step, Status>()
|
||||
/** The last line of output from the step that is running or just failed. */
|
||||
val detail = mutableStateMapOf<Step, String>()
|
||||
var runtimeInstalled by mutableStateOf(true)
|
||||
private set
|
||||
|
||||
val ready: Boolean get() = Step.entries.filter { it.required }.all { status[it] == Status.DONE }
|
||||
|
||||
/** Set once the required steps have all passed, so the chat opens without waiting for the checks. */
|
||||
fun completed(context: Context) = AgentHost.prefs(context).getBoolean(completedKey(), false)
|
||||
|
||||
private fun completedKey() = "setup_completed_${AgentHost.harness.id}"
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private const val REPO = "https://github.com/Droid-Deck/DroidDeck.git"
|
||||
private const val CHECKOUT = "/root/DroidDeck"
|
||||
private val HELPERS = listOf("android-open", "dd-gh-login")
|
||||
|
||||
fun check(context: Context) {
|
||||
val app = context.applicationContext
|
||||
runtimeInstalled = LinuxRuntime.isInstalled(app)
|
||||
if (!runtimeInstalled) return
|
||||
AgentHost.refresh(app)
|
||||
val harness = AgentHost.harness
|
||||
val probes = mutableListOf<Pair<Step, List<String>>>()
|
||||
for (step in Step.entries) if (status[step] != Status.WORKING) {
|
||||
when (step) {
|
||||
Step.INSTALL -> set(step, if (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.FileVisitResult
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.SimpleFileVisitor
|
||||
import java.nio.file.attribute.BasicFileAttributes
|
||||
|
||||
/**
|
||||
* What the agent adds to the Linux userland, measured and removable piece by piece. Sign-ins
|
||||
* (~/.claude, ~/.codex, gh's config) are left alone, so removing a CLI and installing it again
|
||||
* does not sign out.
|
||||
*/
|
||||
object AgentStorage {
|
||||
enum class Item(val paths: List<String>) {
|
||||
CLAUDE_CODE(listOf("/root/.local/share/claude", "/root/.local/bin/claude", "/root/cc")),
|
||||
CODEX(listOf("/root/.local/bin/codex", "/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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/** A Lab build in progress, as dd-lab-build reports it in `~/.cache/dd-lab-build.json`. */
|
||||
data class LabProgress(
|
||||
val stage: String, val prepare: Boolean, val permille: Int, val task: String,
|
||||
val done: Int, val total: Int, val etaSeconds: Int, val elapsedSeconds: Int, val pid: Int,
|
||||
) {
|
||||
val running get() = stage in RUNNING
|
||||
val failed get() = stage == "failed"
|
||||
|
||||
companion object {
|
||||
private val RUNNING = setOf("checking", "fetching", "sdk", "planning", "building", "packaging", "restarting")
|
||||
private const val PATH = "root/.cache/dd-lab-build.json"
|
||||
/** A finished build stays on screen this long. */
|
||||
private const val SHOW_DONE_MS = 60_000L
|
||||
|
||||
/** The build, while it runs and shortly after; null when there is none to show. */
|
||||
fun read(rootfs: File): LabProgress? {
|
||||
val file = File(rootfs, PATH)
|
||||
if (!file.isFile) return null
|
||||
val parsed = parse(runCatching { file.readText() }.getOrNull() ?: return null) ?: return null
|
||||
val progress = when {
|
||||
!parsed.running || AgentProcesses.alive(parsed.pid) -> parsed
|
||||
// The restart ends the app and the script with it, so a restart that got that far succeeded.
|
||||
parsed.stage == "restarting" -> parsed.copy(stage = "ready")
|
||||
else -> parsed.copy(stage = "failed", task = "stopped")
|
||||
}
|
||||
return progress.takeIf { it.running || System.currentTimeMillis() - file.lastModified() < SHOW_DONE_MS }
|
||||
}
|
||||
|
||||
/** Ends the build and Gradle's daemon, which keeps compiling after its client is gone. */
|
||||
fun cancel(rootfs: File, progress: LabProgress) {
|
||||
AgentProcesses.killTree(progress.pid)
|
||||
AgentProcesses.find("GradleDaemon").forEach(AgentProcesses::killTree)
|
||||
runCatching {
|
||||
File(rootfs, PATH).writeText(JSONObject().put("stage", "failed").put("task", "stopped").put("pid", 0).toString())
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun parse(json: String): LabProgress? = runCatching {
|
||||
val o = JSONObject(json)
|
||||
LabProgress(
|
||||
o.getString("stage"), o.optString("mode") == "prepare", o.optInt("permille"), o.optString("task"),
|
||||
o.optInt("done"), o.optInt("total"), o.optInt("eta", -1), o.optInt("elapsed"), o.optInt("pid"),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.droiddeck.launcher.lab
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.droiddeck.launcher.FrontEndActivity
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.stores.download.StoreDownloadService
|
||||
|
||||
/**
|
||||
* Says what happened to a Lab build when the app starts, so a restart onto a new build does not
|
||||
* look like a crash, and a build Android or the safety checks turned off is not lost silently.
|
||||
*/
|
||||
object LabNotice {
|
||||
private const val CHANNEL_ID = "lab"
|
||||
private const val NOTIFICATION_ID = 4120
|
||||
|
||||
/** Where a start that just moved a Lab build into use opens; the agent that built it sets it. */
|
||||
@Volatile var openOnInstall: String = "updates"
|
||||
|
||||
fun started(context: Context) {
|
||||
val app = context.applicationContext
|
||||
val active = LabOverlay.active
|
||||
val skipped = LabOverlay.skipped
|
||||
val current = LabOverlay.current(app)
|
||||
when {
|
||||
LabOverlay.promoted && active != null ->
|
||||
post(app, app.getString(R.string.lab_installed_title), app.getString(R.string.lab_installed_text, active.label), openOnInstall)
|
||||
skipped == LabOverlay.SKIPPED_APP_IMAGE && current != null ->
|
||||
post(app, app.getString(R.string.lab_paused_title), app.getString(R.string.lab_paused_text, current.label), "updates")
|
||||
skipped == LabOverlay.Reason.OTHER_INSTALL.name || skipped == LabOverlay.Reason.DID_NOT_START.name -> {
|
||||
val reason = if (skipped == LabOverlay.Reason.OTHER_INSTALL.name) R.string.lab_off_other_install else R.string.lab_off_did_not_start
|
||||
post(app, app.getString(R.string.lab_off_title), app.getString(reason), "updates")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(app: Context, title: String, text: String, nav: String) {
|
||||
val manager = app.getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, app.getString(R.string.upd_lab), NotificationManager.IMPORTANCE_DEFAULT))
|
||||
val open = PendingIntent.getActivity(
|
||||
app, NOTIFICATION_ID,
|
||||
Intent(app, FrontEndActivity::class.java).putExtra(StoreDownloadService.EXTRA_NAV, nav)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
val notification = Notification.Builder(app, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_session)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
runCatching { manager.notify(NOTIFICATION_ID, notification) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.droiddeck.launcher.lab
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Resources
|
||||
import android.content.res.loader.ResourcesLoader
|
||||
import android.content.res.loader.ResourcesProvider
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A Lab build: app code and resources an on-device agent built from this install's own commit,
|
||||
* loaded over the installed APK at startup so it can be tried without reinstalling or re-signing.
|
||||
*
|
||||
* `files/lab/next` holds a build waiting for the next start and `files/lab/active` the one in use,
|
||||
* each with `overlay.apk` (the build's dex files and linked resources) and `overlay.json`. A build
|
||||
* that is replaced, turned off or refused moves to `files/lab/builds`, so it can be used again.
|
||||
*/
|
||||
object LabOverlay {
|
||||
data class Info(val label: String, val baseCommit: String, val baseApk: String, val built: Long = 0)
|
||||
|
||||
enum class Reason { REPLACED, TURNED_OFF, OTHER_INSTALL, DID_NOT_START, UNREADABLE }
|
||||
|
||||
/** A build kept in `files/lab/builds`; [usable] when it was made for this install. */
|
||||
data class Saved(val dir: File, val info: Info?, val reason: Reason?, val usable: Boolean)
|
||||
|
||||
private const val TAG = "LabOverlay"
|
||||
private const val MAX_FAILED_STARTS = 2
|
||||
private const val STABLE_AFTER_MS = 10_000L
|
||||
private const val KEEP_BUILDS = 12
|
||||
const val SKIPPED_APP_IMAGE = "app image present"
|
||||
|
||||
/** The installed APK's commit; this class always comes from the APK, never from a Lab build. */
|
||||
val apkCommit: String = BuildConfig.SOURCE_COMMIT
|
||||
|
||||
@Volatile var active: Info? = null
|
||||
private set
|
||||
@Volatile var skipped: String? = null
|
||||
private set
|
||||
/** This start moved a newly staged build into use. */
|
||||
@Volatile var promoted = false
|
||||
private set
|
||||
|
||||
private var loader: Any? = null
|
||||
|
||||
@VisibleForTesting
|
||||
internal var injectDex: (ClassLoader, File) -> Unit = ::prependDex
|
||||
@VisibleForTesting
|
||||
internal var openResources: (File) -> Any? = { if (Build.VERSION.SDK_INT >= 30) createLoader(it) else null }
|
||||
@VisibleForTesting
|
||||
internal var appImage: (Context) -> Boolean = ::hasAppImage
|
||||
|
||||
fun root(context: Context) = File(context.filesDir, "lab")
|
||||
|
||||
/** Called from [Application.attachBaseContext], before any class the build may change is loaded. */
|
||||
fun load(app: Application, mainProcess: Boolean) {
|
||||
active = null
|
||||
skipped = null
|
||||
promoted = false
|
||||
try {
|
||||
val root = root(app)
|
||||
if (mainProcess) promoted = promote(root)
|
||||
val dir = File(root, "active")
|
||||
val apk = File(dir, "overlay.apk")
|
||||
val meta = File(dir, "overlay.json")
|
||||
if (!apk.isFile || !meta.isFile) return
|
||||
val info = parse(meta.readText()) ?: return refuse(root, mainProcess, Reason.UNREADABLE)
|
||||
if (!madeFor(app, info)) return refuse(root, mainProcess, Reason.OTHER_INSTALL)
|
||||
if (Build.VERSION.SDK_INT < 30) return skip("needs Android 11")
|
||||
// Classes in an app image are loaded before attachBaseContext and would ignore the overlay;
|
||||
// dd-lab-build recompiles the app without one (cmd package compile -m verify).
|
||||
if (appImage(app)) return skip(SKIPPED_APP_IMAGE)
|
||||
if (mainProcess) {
|
||||
val starts = failedStarts(root) + 1
|
||||
if (starts > MAX_FAILED_STARTS) return refuse(root, true, Reason.DID_NOT_START)
|
||||
File(root, "starts").writeText(starts.toString())
|
||||
}
|
||||
apk.setReadOnly()
|
||||
loader = openResources(apk)
|
||||
injectDex(app.classLoader, apk)
|
||||
active = info
|
||||
addLoader(app.resources)
|
||||
app.registerActivityLifecycleCallbacks(Callbacks(root, mainProcess))
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Lab build not loaded", e)
|
||||
skipped = e.message ?: e.javaClass.simpleName
|
||||
}
|
||||
}
|
||||
|
||||
/** The APK's path changes with every install and update of the app, so it pins a build to one install. */
|
||||
fun madeFor(context: Context, info: Info) = info.baseCommit == apkCommit && info.baseApk == context.applicationInfo.sourceDir
|
||||
|
||||
/** The build in `active`, whether or not this start loaded it. */
|
||||
fun current(context: Context): Info? = read(File(root(context), "active"))
|
||||
|
||||
/** A build staged for the next start. */
|
||||
fun pending(context: Context): Info? = read(File(root(context), "next"))
|
||||
|
||||
/** Kept builds, newest first. */
|
||||
fun saved(context: Context): List<Saved> = File(root(context), "builds").listFiles().orEmpty()
|
||||
.filter { File(it, "overlay.apk").isFile }
|
||||
.sortedByDescending { it.name.toLongOrNull() ?: 0L }
|
||||
.map { dir ->
|
||||
val info = read(dir)
|
||||
val reason = File(dir, "reason").takeIf { it.isFile }?.readText()?.trim()?.let { r -> Reason.entries.firstOrNull { it.name == r } }
|
||||
Saved(dir, info, reason, info != null && madeFor(context, info))
|
||||
}
|
||||
|
||||
/** Leaves the Lab build and restarts the app on the installed APK. */
|
||||
fun turnOff(context: Context) {
|
||||
val root = root(context)
|
||||
archive(root, File(root, "active"), Reason.TURNED_OFF)
|
||||
File(root, "next").deleteRecursively()
|
||||
File(root, "starts").delete()
|
||||
restart(context)
|
||||
}
|
||||
|
||||
/** Stages [build] for the next start; the caller restarts the app. */
|
||||
fun stage(context: Context, build: Saved) {
|
||||
val root = root(context)
|
||||
File(root, "next").deleteRecursively()
|
||||
File(build.dir, "reason").delete()
|
||||
if (!build.dir.renameTo(File(root, "next"))) error("could not stage ${build.dir.name}")
|
||||
}
|
||||
|
||||
fun delete(build: Saved) {
|
||||
build.dir.deleteRecursively()
|
||||
}
|
||||
|
||||
/**
|
||||
* Android's background optimization can give the APK an app image again, which keeps a Lab
|
||||
* build from loading. Recompiling without one needs a shell, which the caller provides.
|
||||
*/
|
||||
fun needsRecompile(context: Context) = appImage(context)
|
||||
|
||||
fun recompileCommand(context: Context) = "cmd package compile -m verify -f ${context.packageName}"
|
||||
|
||||
fun restart(context: Context) {
|
||||
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
if (launch != null) context.startActivity(launch)
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun parse(json: String): Info? = runCatching {
|
||||
val o = JSONObject(json)
|
||||
Info(o.getString("label"), o.getString("baseCommit"), o.getString("baseApk"), o.optLong("built"))
|
||||
}.getOrNull()
|
||||
|
||||
private fun read(dir: File): Info? = File(dir, "overlay.json").takeIf { it.isFile && File(dir, "overlay.apk").isFile }?.let { parse(it.readText()) }
|
||||
|
||||
/** Moves `next` into use and keeps the build it replaces. Returns whether there was one to move. */
|
||||
@VisibleForTesting
|
||||
internal fun promote(root: File): Boolean {
|
||||
val next = File(root, "next")
|
||||
if (!File(next, "overlay.apk").isFile || !File(next, "overlay.json").isFile) return false
|
||||
val active = File(root, "active")
|
||||
archive(root, active, Reason.REPLACED)
|
||||
active.deleteRecursively()
|
||||
if (!next.renameTo(active)) return false
|
||||
File(root, "starts").delete()
|
||||
return true
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun archive(root: File, dir: File, reason: Reason) {
|
||||
if (!File(dir, "overlay.apk").isFile) return
|
||||
val builds = File(root, "builds").apply { mkdirs() }
|
||||
var stamp = System.currentTimeMillis()
|
||||
while (File(builds, stamp.toString()).exists()) stamp++
|
||||
val target = File(builds, stamp.toString())
|
||||
if (!dir.renameTo(target)) return
|
||||
File(target, "reason").writeText(reason.name)
|
||||
builds.listFiles().orEmpty().sortedByDescending { it.name.toLongOrNull() ?: 0L }.drop(KEEP_BUILDS).forEach { it.deleteRecursively() }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun failedStarts(root: File) = File(root, "starts").takeIf { it.isFile }?.readText()?.trim()?.toIntOrNull() ?: 0
|
||||
|
||||
private fun refuse(root: File, mainProcess: Boolean, reason: Reason) {
|
||||
skipped = reason.name
|
||||
Log.w(TAG, "Lab build turned off: $reason")
|
||||
if (!mainProcess) return
|
||||
archive(root, File(root, "active"), reason)
|
||||
File(root, "starts").delete()
|
||||
File(root, "last-off-reason").writeText(reason.name)
|
||||
}
|
||||
|
||||
private fun skip(reason: String) {
|
||||
skipped = reason
|
||||
Log.w(TAG, "Lab build not loaded: $reason")
|
||||
}
|
||||
|
||||
private fun hasAppImage(context: Context): Boolean {
|
||||
val apk = File(context.applicationInfo.sourceDir)
|
||||
val oat = File(apk.parentFile, "oat")
|
||||
return oat.listFiles().orEmpty().any { File(it, "${apk.nameWithoutExtension}.art").isFile }
|
||||
}
|
||||
|
||||
@RequiresApi(30)
|
||||
private fun createLoader(apk: File): ResourcesLoader =
|
||||
ParcelFileDescriptor.open(apk, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
|
||||
ResourcesLoader().apply { addProvider(ResourcesProvider.loadFromApk(fd)) }
|
||||
}
|
||||
|
||||
/** Gives [resources] the Lab build's resources; contexts made from them inherit it. */
|
||||
fun addLoader(resources: Resources) {
|
||||
if (Build.VERSION.SDK_INT < 30) return
|
||||
val current = loader as? ResourcesLoader ?: return
|
||||
resources.addLoaders(current)
|
||||
}
|
||||
|
||||
private fun prependDex(classLoader: ClassLoader, apk: File) {
|
||||
val pathList = field(Class.forName("dalvik.system.BaseDexClassLoader"), "pathList").get(classLoader)!!
|
||||
val elementsField = field(pathList.javaClass, "dexElements")
|
||||
@Suppress("UNCHECKED_CAST") val installed = elementsField.get(pathList) as Array<Any?>
|
||||
val errors = ArrayList<java.io.IOException>()
|
||||
val made = try {
|
||||
pathList.javaClass.getDeclaredMethod("makeDexElements", List::class.java, File::class.java, List::class.java, ClassLoader::class.java)
|
||||
.apply { isAccessible = true }.invoke(null, listOf(apk), null, errors, classLoader)
|
||||
} catch (_: NoSuchMethodException) {
|
||||
pathList.javaClass.getDeclaredMethod(
|
||||
"makeDexElements", List::class.java, File::class.java, List::class.java, ClassLoader::class.java, Boolean::class.javaPrimitiveType,
|
||||
).apply { isAccessible = true }.invoke(null, listOf(apk), null, errors, classLoader, false)
|
||||
}
|
||||
errors.firstOrNull()?.let { throw it }
|
||||
@Suppress("UNCHECKED_CAST") val added = made as Array<Any?>
|
||||
if (added.isEmpty()) error("no dex in ${apk.name}")
|
||||
val merged = java.lang.reflect.Array.newInstance(installed.javaClass.componentType!!, added.size + installed.size)
|
||||
System.arraycopy(added, 0, merged, 0, added.size)
|
||||
System.arraycopy(installed, 0, merged, added.size, installed.size)
|
||||
elementsField.set(pathList, merged)
|
||||
}
|
||||
|
||||
private fun field(type: Class<*>, name: String) = type.getDeclaredField(name).apply { isAccessible = true }
|
||||
|
||||
private class Callbacks(private val root: File, private val mainProcess: Boolean) : Application.ActivityLifecycleCallbacks {
|
||||
private var settled = false
|
||||
|
||||
override fun onActivityPreCreated(activity: Activity, savedInstanceState: Bundle?) = addLoader(activity.resources)
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (settled || !mainProcess) return
|
||||
settled = true
|
||||
Handler(Looper.getMainLooper()).postDelayed({ File(root, "starts").delete() }, STABLE_AFTER_MS)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||
override fun onActivityStarted(activity: Activity) = Unit
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivityStopped(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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,7 +43,6 @@ 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",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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", "left", "right", "center") } ?: "center"
|
||||
?.takeIf { it in listOf("top", "bottom", "center") } ?: "center"
|
||||
|
||||
fun setPicturePosition(context: Context, position: String) {
|
||||
prefs(context).edit().putString("picturePosition",
|
||||
position.takeIf { it in listOf("top", "bottom", "left", "right", "center") } ?: "center").apply()
|
||||
position.takeIf { it in listOf("top", "bottom", "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.queueRelaunch(next)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0, relaunch = true) }
|
||||
SessionState.relaunch = next
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { stopSession(0) }
|
||||
}
|
||||
}
|
||||
watcher.startWatching()
|
||||
@@ -2013,7 +2013,7 @@ class SessionService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSession(status: Int, gen: Int? = null, relaunch: Boolean = false) {
|
||||
private fun stopSession(status: Int, gen: Int? = null) {
|
||||
if (gen != null && gen != sessionGen) {
|
||||
Log.i(TAG, "ignoring a stop from session $gen; session $sessionGen is current")
|
||||
return
|
||||
@@ -2024,7 +2024,6 @@ 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,44 +73,7 @@ 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,7 +16,6 @@ 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
|
||||
@@ -25,36 +24,6 @@ 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 ->
|
||||
@@ -73,7 +42,6 @@ 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
|
||||
@@ -93,21 +61,21 @@ internal object GogContent {
|
||||
}
|
||||
|
||||
fun snapshot(old: GogInstallRecord, build: GogBuild, token: String,
|
||||
ownership: (String) -> Set<String>? = { ownedProductIds(it) },
|
||||
metadata: (String, String?) -> JSONObject = ::json,
|
||||
onManifest: (JSONObject) -> Unit = {}): GogInstallRecord {
|
||||
metadata: (String, String?) -> JSONObject = ::json): 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 products = installProducts(old.id, depots, old.products, token, ownership)
|
||||
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" }
|
||||
for (depot in depots.objects()) {
|
||||
if (depot.has("redist")) continue
|
||||
val product = depotProducts(depot, old.id).firstOrNull { it in products } ?: continue
|
||||
val ids = depot.optJSONArray("gameIDs")?.strings() ?: listOf(depot.optString("productId", old.id))
|
||||
val product = ids.firstOrNull { it in products } ?: continue
|
||||
val languages = depot.optJSONArray("languages")?.strings().orEmpty()
|
||||
if (!languageSelected(languages, resolvedLanguage)) continue
|
||||
val bitness = depot.optJSONArray("osBitness")?.strings().orEmpty()
|
||||
@@ -140,7 +108,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.toList(), verified = true)
|
||||
branch = build.branch, language = resolvedLanguage, manifest = sanitize(manifest), files = files, products = products, verified = true)
|
||||
}
|
||||
|
||||
fun languageSelected(list: List<String>, selected: String): Boolean = list.any { it.equals("Neutral", true) } || StoreLanguage.depotMatchesResolved(list.toTypedArray(), selected)
|
||||
|
||||
@@ -125,22 +125,211 @@ 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 {
|
||||
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";
|
||||
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;
|
||||
} catch (Exception e) {
|
||||
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
|
||||
return out;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
public static List<String> listLanguages(Context ctx, String gameId) {
|
||||
try {
|
||||
String token = GogAuth.validToken(ctx);
|
||||
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"));
|
||||
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"));
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -209,7 +398,7 @@ public final class GogDownloadManager {
|
||||
int threads = downloadThreads();
|
||||
if (GogNative.isAvailable()) {
|
||||
runNativeEngine(ctx, GogNative.KIND_GEN1_RANGES, "Resuming…", Collections.singletonList(manifestStr), installPath,
|
||||
threads, threads, false, "gog gen1=" + game.gameId,
|
||||
new AtomicReference<>(""), null, null, new AtomicInteger(0), 0, 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"));
|
||||
@@ -470,7 +659,8 @@ public final class GogDownloadManager {
|
||||
|
||||
|
||||
private static void runNativeEngine(Context ctx, int kind, String verifiedMsg, List<String> depotJsons, File installPath,
|
||||
int maxWorkers, int processWorkers,
|
||||
AtomicReference<String> cdnBaseRef, String secureLinkUrl, String token,
|
||||
AtomicInteger cdnRefreshCount, int maxCdnRefresh, 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) {
|
||||
@@ -483,8 +673,10 @@ 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);
|
||||
@@ -510,10 +702,12 @@ 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); errRef.set(error == null ? "" : error); latch.countDown();
|
||||
okRef.set(success); linkExpiryRef.set(linkExpiry); errRef.set(error == null ? "" : error); latch.countDown();
|
||||
}
|
||||
};
|
||||
long handle = GogNative.start(kind, manifests, new String[0], installPath.getAbsolutePath(), donePaths.toArray(new String[0]), "",
|
||||
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]), "",
|
||||
workers, process, sortLargestFirst, label + " run=" + run, listener);
|
||||
if (handle == 0L) { cb.onLog("gog: native start failed"); anyFailed.set(true); return; }
|
||||
boolean interrupted = false;
|
||||
@@ -527,13 +721,85 @@ public final class GogDownloadManager {
|
||||
}
|
||||
if (interrupted) { Thread.currentThread().interrupt(); anyFailed.set(true); return; }
|
||||
if (cancelled.get() || okRef.get()) return;
|
||||
cb.onLog("gog: engine failed: " + errRef.get());
|
||||
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);
|
||||
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;
|
||||
@@ -558,6 +824,18 @@ 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();
|
||||
@@ -673,6 +951,15 @@ 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");
|
||||
@@ -701,6 +988,58 @@ 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)) {
|
||||
@@ -950,6 +1289,30 @@ 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; }
|
||||
@@ -1019,11 +1382,7 @@ 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);
|
||||
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);
|
||||
if (depotSelected(depot, resolved)) total += depot.optLong("size", 0);
|
||||
}
|
||||
return total > 0 ? total : -1;
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -377,24 +377,16 @@ internal object GogMaintenance {
|
||||
val build = GogBuild.select(builds) ?: return null
|
||||
folder.mkdirs()
|
||||
val old = GogInstallRecord(game.gameId, "depot", language = language)
|
||||
var credentials: Pair<String, String>? = null
|
||||
val target = GogContent.snapshot(old, build, GogContent.token(context), onManifest = { manifest ->
|
||||
credentials = GogContent.clientCredentials(manifest)
|
||||
})
|
||||
val target = GogContent.snapshot(old, build, GogContent.token(context))
|
||||
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") }
|
||||
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" }
|
||||
check(!StoreInstallRoot.insufficientSpace(disk, 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)
|
||||
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 task = GogDownloadManager.pickTask(folder, game.gameId, null, game.title)
|
||||
val result = GogDownloadManager.Result(task?.path ?: "", build.id, disk)
|
||||
result.workDir = task?.workDir
|
||||
result.language = target.language
|
||||
@@ -403,6 +395,20 @@ 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,6 +6,8 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.agent.AgentHost
|
||||
|
||||
private object BubblePlace {
|
||||
var x by mutableStateOf(Float.NaN)
|
||||
var y by mutableStateOf(Float.NaN)
|
||||
var hidden by mutableStateOf(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's bubble over a session: there once the agent has been used, pulsing while it works,
|
||||
* marked when a reply or failure has not been seen. Drag it to either edge; a long press hides it
|
||||
* until the next session.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgentBubble(onOpen: () -> Unit) {
|
||||
if (!AgentHost.installed || BubblePlace.hidden || AgentHost.entries.isEmpty()) return
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val size = 56.dp
|
||||
val sizePx = with(density) { size.toPx() }
|
||||
val margin = with(density) { 12.dp.toPx() }
|
||||
val working = AgentHost.state == AgentHost.State.WORKING
|
||||
val pulse = androidx.compose.animation.core.rememberInfiniteTransition(label = "agentPulse")
|
||||
val ring by pulse.animateFloat(
|
||||
0.35f, 1f,
|
||||
androidx.compose.animation.core.infiniteRepeatable(androidx.compose.animation.core.tween(900), androidx.compose.animation.core.RepeatMode.Reverse),
|
||||
label = "agentRing",
|
||||
)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val width = constraints.maxWidth.toFloat()
|
||||
val height = constraints.maxHeight.toFloat()
|
||||
if (BubblePlace.x.isNaN()) {
|
||||
BubblePlace.x = width - sizePx - margin
|
||||
BubblePlace.y = height * 0.3f
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.offset { androidx.compose.ui.unit.IntOffset(BubblePlace.x.toInt(), BubblePlace.y.toInt()) }
|
||||
.size(size)
|
||||
.graphicsLayer { alpha = if (working || AgentHost.unseen) 1f else 0.7f }
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(colors.surface)
|
||||
.border(
|
||||
2.dp,
|
||||
if (working) colors.primary.copy(alpha = ring) else if (AgentHost.failed && AgentHost.unseen) colors.error else colors.outlineVariant,
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
)
|
||||
.pointerInput(width, height) {
|
||||
detectDragGestures(onDragEnd = {
|
||||
BubblePlace.x = if (BubblePlace.x + sizePx / 2 < width / 2) margin else width - sizePx - margin
|
||||
}) { change, drag ->
|
||||
change.consume()
|
||||
BubblePlace.x = (BubblePlace.x + drag.x).coerceIn(0f, width - sizePx)
|
||||
BubblePlace.y = (BubblePlace.y + drag.y).coerceIn(0f, height - sizePx)
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(onTap = { onOpen() }, onLongPress = { BubblePlace.hidden = true })
|
||||
}
|
||||
.testTag("agent-bubble"),
|
||||
) {
|
||||
Icon(androidx.compose.material.icons.Icons.Outlined.AutoAwesome, stringResource(R.string.rail_agent), tint = colors.primary)
|
||||
if (AgentHost.unseen && !working) Box(
|
||||
Modifier.align(Alignment.TopEnd).padding(6.dp).size(10.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(if (AgentHost.failed) colors.error else colors.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
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++])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.activity.compose.LocalActivityResultRegistryOwner
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Send
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.AttachFile
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.History
|
||||
import androidx.compose.material.icons.outlined.Memory
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Stop
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.agent.AgentAttachments
|
||||
import com.droiddeck.launcher.agent.AgentHost
|
||||
import com.droiddeck.launcher.agent.AgentHost.Entry
|
||||
import com.droiddeck.launcher.agent.AgentSetup
|
||||
|
||||
internal enum class AgentPanel { NONE, MODEL, HISTORY, PROCESSES }
|
||||
|
||||
/** Slash commands the app handles itself; others go to the CLI, which has its own. */
|
||||
private val LOCAL_COMMANDS = listOf("/new", "/model", "/plan", "/history", "/processes", "/stop")
|
||||
private val CLI_COMMANDS = listOf("/compact", "/cost", "/review", "/init")
|
||||
|
||||
@Composable
|
||||
internal fun AgentPage(modifier: Modifier, inputModifier: Modifier = Modifier, visible: Boolean = true) {
|
||||
val context = LocalContext.current
|
||||
remember { AgentHost.refresh(context) }
|
||||
LaunchedEffect(AgentHost.unseen, visible) { if (visible) AgentHost.seen() }
|
||||
remember { AgentSetup.check(context) }
|
||||
var setup by rememberSaveable { mutableStateOf(false) }
|
||||
if (setup || AgentHost.state == AgentHost.State.MISSING || !(AgentSetup.ready || AgentSetup.completed(context))) {
|
||||
AgentSetupView(modifier, onDone = { setup = false })
|
||||
return
|
||||
}
|
||||
var panel by rememberSaveable { mutableStateOf(AgentPanel.NONE) }
|
||||
Column(modifier.fillMaxSize()) {
|
||||
AgentHeader(panel, { panel = if (panel == it) AgentPanel.NONE else it }, onSetup = { setup = true })
|
||||
when (panel) {
|
||||
AgentPanel.HISTORY -> AgentHistory(Modifier.weight(1f)) { panel = AgentPanel.NONE }
|
||||
AgentPanel.PROCESSES -> AgentProcessList(Modifier.weight(1f))
|
||||
else -> {
|
||||
if (panel == AgentPanel.MODEL) AgentModelPicker()
|
||||
AgentConversation(Modifier.weight(1f))
|
||||
AgentStatus()
|
||||
AgentComposer(inputModifier) { panel = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentHeader(panel: AgentPanel, onPanel: (AgentPanel) -> Unit, onSetup: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val working = AgentHost.state == AgentHost.State.WORKING
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.rail_agent), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
|
||||
val model = AgentHost.models(context).firstOrNull { it.id == AgentHost.model }?.label ?: AgentHost.model ?: stringResource(R.string.agent_model_default)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clip(Shape12).clickable { onPanel(AgentPanel.MODEL) }.padding(vertical = 2.dp).testTag("agent-model"),
|
||||
) {
|
||||
val parts = listOfNotNull(AgentHost.harness.name, model, stringResource(R.string.agent_plan_short).takeIf { AgentHost.plan && AgentHost.harness.plans })
|
||||
Text(parts.joinToString(" · "), fontSize = 13.sp, color = if (panel == AgentPanel.MODEL) pal.primary else pal.onSurfaceVariant)
|
||||
Icon(Icons.Outlined.ExpandMore, null, tint = pal.onSurfaceVariant, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
HeaderAction(Icons.Outlined.History, stringResource(R.string.agent_history), selected = panel == AgentPanel.HISTORY, tag = "agent-history") { onPanel(AgentPanel.HISTORY) }
|
||||
HeaderAction(Icons.Outlined.Memory, stringResource(R.string.agent_processes), selected = panel == AgentPanel.PROCESSES, tag = "agent-processes") { onPanel(AgentPanel.PROCESSES) }
|
||||
HeaderAction(Icons.Outlined.Add, stringResource(R.string.agent_new), enabled = AgentHost.entries.isNotEmpty(), tag = "agent-new") {
|
||||
AgentHost.forget(context)
|
||||
if (panel != AgentPanel.NONE) onPanel(panel)
|
||||
}
|
||||
HeaderAction(Icons.Outlined.Settings, stringResource(R.string.agent_setup_title), enabled = !working, tag = "agent-settings", onClick = onSetup)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HeaderAction(icon: ImageVector, label: String, enabled: Boolean = true, selected: Boolean = false, tag: String? = null, onClick: () -> Unit) {
|
||||
val pal = LocalPalette.current
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(start = 8.dp).size(44.dp).clip(Shape12)
|
||||
.background(if (selected) pal.primary.copy(alpha = 0.2f) else pal.surfaceVariant)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
Icon(icon, label, tint = when {
|
||||
selected -> pal.primary
|
||||
enabled -> pal.onBackground
|
||||
else -> pal.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
}, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentConversation(modifier: Modifier) {
|
||||
val pal = LocalPalette.current
|
||||
val list = rememberLazyListState()
|
||||
val working = AgentHost.state == AgentHost.State.WORKING
|
||||
val atBottom by remember { derivedStateOf { !list.canScrollForward } }
|
||||
val items = AgentHost.entries.size + (if (working) 1 else 0)
|
||||
LaunchedEffect(items) { if (items > 0) list.animateScrollToItem(items - 1) }
|
||||
LaunchedEffect(AgentHost.live.length / 80, AgentHost.liveThinking.length / 80) {
|
||||
if (atBottom && items > 0) list.scrollToItem(items - 1, Int.MAX_VALUE / 2)
|
||||
}
|
||||
if (AgentHost.entries.isEmpty() && !working) {
|
||||
Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Icon(Icons.Outlined.AutoAwesome, null, tint = pal.primary, modifier = Modifier.size(40.dp))
|
||||
Text(stringResource(R.string.agent_empty), fontSize = 15.sp, color = pal.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center, modifier = Modifier.widthIn(max = 460.dp).padding(top = 12.dp))
|
||||
}
|
||||
return
|
||||
}
|
||||
val start = AgentHost.entries.indexOfLast { it is Entry.Asked }
|
||||
LazyColumn(modifier.fillMaxWidth(), state = list, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(AgentHost.entries.size) { i ->
|
||||
val entry = AgentHost.entries[i]
|
||||
AgentEntry(entry, running = working && i > start && entry is Entry.Used && entry.output == null, 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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.agent.AgentChats
|
||||
import com.droiddeck.launcher.agent.AgentHarness
|
||||
import com.droiddeck.launcher.agent.AgentHost
|
||||
import com.droiddeck.launcher.agent.AgentProcesses
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** The model and plan mode for the next turns; a conversation keeps going with whatever is chosen. */
|
||||
@Composable
|
||||
internal fun AgentModelPicker() {
|
||||
val context = LocalContext.current
|
||||
val pal = LocalPalette.current
|
||||
val models = remember(AgentHost.harness) { AgentHost.models(context) }
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.horizontalScroll(rememberScrollState())) {
|
||||
val choices = listOf(AgentHarness.Model("", stringResource(R.string.agent_model_default))) + models
|
||||
for (choice in choices) {
|
||||
val id = choice.id.ifEmpty { null }
|
||||
if (id == AgentHost.model) PrimaryButton(choice.label, compact = true) {}
|
||||
else SecondaryButton(choice.label, compact = true) { AgentHost.chooseModel(context, id) }
|
||||
}
|
||||
}
|
||||
if (AgentHost.harness.plans) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp)) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.agent_plan), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(stringResource(R.string.agent_plan_hint), fontSize = 12.5.sp, color = pal.onSurfaceVariant)
|
||||
}
|
||||
ToggleSwitch(AgentHost.plan, label = stringResource(R.string.agent_plan)) { AgentHost.choosePlan(context, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AgentHistory(modifier: Modifier, onOpened: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var version by remember { mutableIntStateOf(0) }
|
||||
val chats by produceState(emptyList<AgentChats.Chat>(), version, AgentHost.entries.size) {
|
||||
value = withContext(Dispatchers.IO) { AgentChats.list(context) }
|
||||
}
|
||||
if (chats.isEmpty()) {
|
||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.agent_history_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
|
||||
}
|
||||
return
|
||||
}
|
||||
val working = AgentHost.state == AgentHost.State.WORKING
|
||||
LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
items(chats, key = { it.id }) { chat ->
|
||||
var armed by remember(chat.id) { mutableStateOf(false) }
|
||||
val current = chat.id == AgentHost.chat
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 62.dp)
|
||||
.clickable(enabled = !working || current) {
|
||||
AgentHost.open(context, chat.id)
|
||||
onOpened()
|
||||
}
|
||||
.background(if (current) pal.primary.copy(alpha = 0.12f) else colors.surface)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-chat"),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(chat.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
if (armed) stringResource(R.string.agent_history_delete_warning)
|
||||
else AgentHarness.byId(chat.harness).name + " · " + when {
|
||||
System.currentTimeMillis() - chat.updated < 60_000 -> stringResource(R.string.agent_just_now)
|
||||
else -> DateUtils.getRelativeTimeSpanString(chat.updated, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
|
||||
},
|
||||
fontSize = 12.5.sp, color = if (armed) AttentionAmber else pal.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
SecondaryButton(
|
||||
stringResource(if (armed) R.string.agent_storage_confirm else R.string.agent_history_delete), compact = true,
|
||||
enabled = !(current && working),
|
||||
) {
|
||||
if (armed) {
|
||||
AgentHost.delete(context, chat.id)
|
||||
version++
|
||||
} else armed = true
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Processes in the Linux userland, the agent's own first, each with a way to end it. */
|
||||
@Composable
|
||||
internal fun AgentProcessList(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
var refresh by remember { mutableIntStateOf(0) }
|
||||
var procs by remember { mutableStateOf<List<AgentProcesses.Proc>?>(null) }
|
||||
LaunchedEffect(refresh) {
|
||||
while (true) {
|
||||
procs = withContext(Dispatchers.IO) { AgentProcesses.snapshot(AgentHost.turnPid) }
|
||||
delay(2_000)
|
||||
}
|
||||
}
|
||||
val list = procs ?: return
|
||||
if (list.isEmpty()) {
|
||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(stringResource(R.string.agent_processes_empty), fontSize = 15.sp, color = pal.onSurfaceVariant)
|
||||
}
|
||||
return
|
||||
}
|
||||
LazyColumn(modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
items(list, key = { it.pid }) { proc ->
|
||||
var open by remember(proc.pid) { mutableStateOf(false) }
|
||||
Column(Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp).testTag("agent-process")) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(proc.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
|
||||
if (proc.agent) Text(stringResource(R.string.agent_process_agent), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = pal.primary,
|
||||
modifier = Modifier.clip(Shape12).background(pal.primary.copy(alpha = 0.15f)).padding(horizontal = 6.dp, vertical = 1.dp))
|
||||
}
|
||||
Text(
|
||||
listOf("pid ${proc.pid}", clock(proc.seconds), Formatter.formatShortFileSize(context, proc.rssKb * 1024), "${proc.cpuPercent}% CPU").joinToString(" · "),
|
||||
fontSize = 12.5.sp, color = pal.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
SecondaryButton(stringResource(R.string.agent_process_stop), compact = true) {
|
||||
AgentProcesses.killTree(proc.pid)
|
||||
refresh++
|
||||
}
|
||||
}
|
||||
SelectionContainer {
|
||||
Text(proc.args, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = pal.onSurfaceVariant,
|
||||
maxLines = if (open) 40 else 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
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,7 +38,8 @@ object BuildWatermark {
|
||||
if (text.length <= TITLE_LIMIT) text else text.take(TITLE_LIMIT - 1).trimEnd() + "…"
|
||||
|
||||
fun install(app: Application) {
|
||||
val lines = lines() ?: return
|
||||
val lab = com.droiddeck.launcher.lab.LabOverlay.active?.let { "LAB | ${truncate(it.label)}" }
|
||||
val lines = (listOfNotNull(lab) + lines().orEmpty()).ifEmpty { return }
|
||||
app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
if (activity is QuestXrActivity) return
|
||||
|
||||
@@ -270,6 +270,10 @@ private fun Content(
|
||||
DesktopPage(s, a, modifier, onSelect)
|
||||
return
|
||||
}
|
||||
if (selected == "agent") {
|
||||
AgentPage(modifier.padding(horizontal = padH).padding(top = padV))
|
||||
return
|
||||
}
|
||||
if (selected == "android-apps" && s.isHomeApp) {
|
||||
AndroidAppsPage(s, modifier, onAndroidAppClick)
|
||||
return
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.LocalMall
|
||||
@@ -123,7 +124,9 @@ internal fun BottomBar(
|
||||
internal fun navigationKeys(s: FrontEndState) = buildList {
|
||||
add("steam"); add("games")
|
||||
if (s.gameStoresEnabled) add("stores")
|
||||
add("desktop"); add("components")
|
||||
add("desktop")
|
||||
if (com.droiddeck.launcher.agent.AgentHost.available) add("agent")
|
||||
add("components")
|
||||
if (s.isHomeApp) add("android-apps")
|
||||
add("setup"); add("updates")
|
||||
}
|
||||
@@ -156,7 +159,7 @@ internal fun SideRail(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
|
||||
) {
|
||||
val count = 6 + (if (s.gameStoresEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
|
||||
val count = navigationKeys(s).size
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||
val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
|
||||
val shown = Hops.railPreview ?: selected
|
||||
@@ -197,6 +200,7 @@ internal fun railLabelIcon(key: String): Pair<Int, ImageVector> = when (key) {
|
||||
"stores" -> R.string.rail_stores to Icons.Outlined.LocalMall
|
||||
"desktop" -> R.string.rail_desktop to Icons.Outlined.DesktopWindows
|
||||
"components" -> R.string.rail_components to Icons.Outlined.Layers
|
||||
"agent" -> R.string.rail_agent to Icons.Outlined.AutoAwesome
|
||||
"android-apps" -> R.string.rail_apps to Icons.Outlined.Apps
|
||||
"setup" -> R.string.rail_setup to Icons.Outlined.Tune
|
||||
else -> R.string.rail_updates to Icons.Outlined.SystemUpdate
|
||||
|
||||
@@ -84,7 +84,7 @@ import com.droiddeck.launcher.stores.download.StoreDownloadTier
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
|
||||
private enum class CheckState { OK, WARN, BUSY }
|
||||
internal enum class CheckState { OK, WARN, BUSY }
|
||||
|
||||
private const val STORAGE_TAB = 3
|
||||
private const val LAUNCHER_TAB = 2
|
||||
@@ -97,7 +97,7 @@ private const val GLASSES_TAB = 6
|
||||
private const val SYNC_TAB = 4
|
||||
|
||||
@Composable
|
||||
private fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
|
||||
internal fun CheckRow(state: CheckState, title: String, detail: String?, divider: Boolean = true, action: (@Composable () -> Unit)? = null) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val tint = when (state) {
|
||||
@@ -690,10 +690,12 @@ internal fun SetupPanel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, checks: @Composable () -> Unit) {
|
||||
internal fun ReadinessCard(
|
||||
passing: Int, total: Int, line: String, open: Boolean, onDetails: () -> Unit, title: String? = null, ready: Boolean = passing == total,
|
||||
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),
|
||||
@@ -709,7 +711,7 @@ private fun ReadinessCard(passing: Int, total: Int, line: String, open: Boolean,
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(if (ready) R.string.setup_ready else R.string.setup_not_ready),
|
||||
title ?: 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)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Code
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.WirelessAdbFix
|
||||
import com.droiddeck.launcher.lab.LabOverlay
|
||||
|
||||
/**
|
||||
* Lab builds on this device: the one in use, one waiting for a restart, and the kept ones.
|
||||
* [onRebuild] offers to build a kept one again for this install, when something can.
|
||||
*/
|
||||
@Composable
|
||||
internal fun LabBuilds(onRebuild: ((LabOverlay.Info) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
var version by remember { mutableIntStateOf(0) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
val current = remember(version) { LabOverlay.current(context) }
|
||||
val pending = remember(version) { LabOverlay.pending(context) }
|
||||
val saved = remember(version) { LabOverlay.saved(context) }
|
||||
if (current == null && pending == null && saved.isEmpty()) return
|
||||
val active = LabOverlay.active
|
||||
val paused = active == null && current != null && LabOverlay.skipped == LabOverlay.SKIPPED_APP_IMAGE
|
||||
val restartFailed = stringResource(R.string.lab_restart_failed)
|
||||
|
||||
/** Android may have given the APK an app image again, which a Lab build cannot load over. */
|
||||
fun restart() {
|
||||
busy = true
|
||||
error = null
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
if (LabOverlay.needsRecompile(context)) WirelessAdbFix.shell(context, LabOverlay.recompileCommand(context))
|
||||
}
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
busy = false
|
||||
if (result.isSuccess) LabOverlay.restart(context)
|
||||
else error = restartFailed.format(result.exceptionOrNull()?.message.orEmpty())
|
||||
}
|
||||
}, "lab-restart").start()
|
||||
}
|
||||
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Column(Modifier.fillMaxWidth().padding(top = 18.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.lab_builds).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp,
|
||||
color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp, bottom = 10.dp),
|
||||
)
|
||||
Column(Modifier.fillMaxWidth().clip(Shape14).background(colors.surface).border(1.dp, pal.line, Shape14)) {
|
||||
val rows = mutableListOf<@Composable () -> Unit>()
|
||||
if (current != null) rows += {
|
||||
BuildRow(
|
||||
current, true,
|
||||
when {
|
||||
active != null -> stringResource(R.string.lab_in_use)
|
||||
paused -> stringResource(R.string.lab_paused_row)
|
||||
else -> stringResource(R.string.lab_not_loaded, LabOverlay.skipped.orEmpty())
|
||||
},
|
||||
warn = active == null,
|
||||
) {
|
||||
if (paused) PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
|
||||
SecondaryButton(stringResource(R.string.lab_turn_off), compact = true, enabled = !busy) { LabOverlay.turnOff(context) }
|
||||
}
|
||||
}
|
||||
if (pending != null) rows += {
|
||||
BuildRow(pending, false, stringResource(R.string.lab_waiting), warn = false) {
|
||||
PrimaryButton(stringResource(R.string.lab_restart), compact = true, enabled = !busy) { restart() }
|
||||
}
|
||||
}
|
||||
for (build in saved) rows += {
|
||||
var armed by remember(build.dir) { mutableStateOf(false) }
|
||||
val info = build.info
|
||||
BuildRow(
|
||||
info, false,
|
||||
when {
|
||||
armed -> stringResource(R.string.lab_delete_warning)
|
||||
!build.usable -> stringResource(R.string.lab_other_install)
|
||||
build.reason == LabOverlay.Reason.DID_NOT_START -> stringResource(R.string.lab_off_did_not_start)
|
||||
else -> stringResource(R.string.lab_kept)
|
||||
},
|
||||
warn = armed || !build.usable || build.reason == LabOverlay.Reason.DID_NOT_START,
|
||||
) {
|
||||
if (!build.usable && !armed && info != null && onRebuild != null) SecondaryButton(stringResource(R.string.lab_rebuild), compact = true, enabled = !busy) {
|
||||
onRebuild(info)
|
||||
}
|
||||
if (build.usable && !armed) SecondaryButton(stringResource(R.string.lab_use), compact = true, enabled = !busy) {
|
||||
runCatching { LabOverlay.stage(context, build) }
|
||||
.onSuccess { restart() }
|
||||
.onFailure { error = it.message }
|
||||
}
|
||||
SecondaryButton(stringResource(if (armed) R.string.lab_delete_confirm else R.string.lab_delete), compact = true, enabled = !busy) {
|
||||
if (armed) {
|
||||
LabOverlay.delete(build)
|
||||
version++
|
||||
} else armed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.forEachIndexed { index, row ->
|
||||
row()
|
||||
if (index < rows.lastIndex) Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
}
|
||||
error?.let { Text(it, fontSize = 12.5.sp, color = AttentionAmber, modifier = Modifier.padding(start = 2.dp, top = 8.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BuildRow(info: LabOverlay.Info?, current: Boolean, status: String, warn: Boolean, actions: @Composable () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(34.dp).clip(Shape12)
|
||||
.background(if (current) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant)) {
|
||||
Icon(Icons.Outlined.Code, null, tint = if (current) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(info?.label ?: "?", fontSize = 14.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val built = info?.built?.takeIf { it > 0 }?.let { DateUtils.getRelativeTimeSpanString(it * 1000, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() }
|
||||
Text(listOfNotNull(status, built).joinToString(" · "), fontSize = 12.5.sp, color = if (warn) AttentionAmber else colors.onSurfaceVariant,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { actions() }
|
||||
}
|
||||
}
|
||||
@@ -224,8 +224,6 @@ 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,12 +9,16 @@ internal enum class SessionDrawerPage(val firstControl: String) {
|
||||
EFFECTS("look"),
|
||||
VR("steamvr"),
|
||||
GAMES("anisotropy"),
|
||||
AGENT("agent-input"),
|
||||
SESSION("suspend"),
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List<SessionDrawerPage> =
|
||||
internal fun sessionDrawerPages(
|
||||
hasSecondScreen: Boolean, hasGlasses: Boolean = false, hasAgent: Boolean = com.droiddeck.launcher.agent.AgentHost.installed,
|
||||
): List<SessionDrawerPage> =
|
||||
SessionDrawerPage.entries.filter {
|
||||
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES)
|
||||
(hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) &&
|
||||
(hasAgent || it != SessionDrawerPage.AGENT)
|
||||
}
|
||||
|
||||
internal fun List<SessionDrawerPage>.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage {
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
@@ -43,6 +44,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.material.icons.outlined.DesktopWindows
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.ScreenShare
|
||||
import androidx.compose.material.icons.outlined.Pause
|
||||
import androidx.compose.material.icons.outlined.PictureInPictureAlt
|
||||
@@ -172,6 +174,7 @@ private val SessionDrawerPage.title: Int get() = when (this) {
|
||||
SessionDrawerPage.VR -> R.string.vr_title
|
||||
SessionDrawerPage.EFFECTS -> R.string.drawer_page_effects
|
||||
SessionDrawerPage.GAMES -> R.string.game_settings_title
|
||||
SessionDrawerPage.AGENT -> R.string.rail_agent
|
||||
SessionDrawerPage.SESSION -> R.string.mode_session
|
||||
}
|
||||
|
||||
@@ -184,6 +187,7 @@ private val SessionDrawerPage.icon get() = when (this) {
|
||||
SessionDrawerPage.VR -> VrHeadsetIcon
|
||||
SessionDrawerPage.EFFECTS -> Icons.Outlined.AutoFixHigh
|
||||
SessionDrawerPage.GAMES -> Icons.Outlined.Layers
|
||||
SessionDrawerPage.AGENT -> Icons.Outlined.AutoAwesome
|
||||
SessionDrawerPage.SESSION -> Icons.Outlined.Settings
|
||||
}
|
||||
|
||||
@@ -762,6 +766,13 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
|
||||
track = { key -> focus.track(page, key) },
|
||||
)
|
||||
}
|
||||
SessionDrawerPage.AGENT -> AgentPage(
|
||||
Modifier.fillMaxWidth()
|
||||
.height((LocalConfiguration.current.screenHeightDp - 190).coerceAtLeast(240).dp)
|
||||
.padding(horizontal = 14.dp),
|
||||
inputModifier = focus.track(page, "agent-input"),
|
||||
visible = open,
|
||||
)
|
||||
SessionDrawerPage.SESSION -> {
|
||||
if (a.isHomeApp) SettingsGroup(stringResource(R.string.drawer_android_apps)) {
|
||||
SettingsRow(stringResource(R.string.drawer_launch_app), null) {
|
||||
|
||||
@@ -58,7 +58,6 @@ 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
|
||||
@@ -128,13 +127,6 @@ 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,6 +285,13 @@ private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
|
||||
tests.forEach { t -> TestRow(t, u.follow.pr == t.pr) { ua.onFollow(Follow(Channel.TEST, t.pr)) } }
|
||||
}
|
||||
}
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
LabBuilds(onRebuild = if (com.droiddeck.launcher.agent.AgentHost.installed) { info ->
|
||||
com.droiddeck.launcher.agent.AgentHost.send(context, context.getString(R.string.agent_rebuild_lab, info.label))
|
||||
context.startActivity(android.content.Intent(context, com.droiddeck.launcher.FrontEndActivity::class.java)
|
||||
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "agent")
|
||||
.addFlags(android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP or android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT))
|
||||
} else null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2976,8 +2976,6 @@
|
||||
<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>
|
||||
@@ -3811,7 +3809,4 @@
|
||||
<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,8 +2828,6 @@
|
||||
<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>
|
||||
@@ -3667,6 +3665,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2918,8 +2918,6 @@
|
||||
<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>
|
||||
@@ -3733,7 +3731,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2889,8 +2889,6 @@
|
||||
<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>
|
||||
@@ -3694,7 +3692,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2889,8 +2889,6 @@
|
||||
<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>
|
||||
@@ -3694,7 +3692,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2831,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3625,7 +3623,4 @@
|
||||
<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>
|
||||
@@ -2889,8 +2889,6 @@
|
||||
<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>
|
||||
@@ -3694,7 +3692,4 @@
|
||||
<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>
|
||||
@@ -2831,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3625,7 +3623,4 @@
|
||||
<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,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3625,7 +3623,4 @@
|
||||
<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,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3625,7 +3623,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2918,8 +2918,6 @@
|
||||
<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>
|
||||
@@ -3733,7 +3731,4 @@
|
||||
<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,8 +2887,6 @@
|
||||
<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>
|
||||
@@ -3694,7 +3692,4 @@
|
||||
<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>
|
||||
@@ -2887,8 +2887,6 @@
|
||||
<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>
|
||||
@@ -3694,7 +3692,4 @@
|
||||
<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>
|
||||
@@ -2889,8 +2889,6 @@
|
||||
<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>
|
||||
@@ -3694,7 +3692,4 @@
|
||||
<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>
|
||||
@@ -3001,8 +3001,6 @@
|
||||
<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>
|
||||
@@ -3816,7 +3814,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2831,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3625,7 +3623,4 @@
|
||||
<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>
|
||||
@@ -2860,8 +2860,6 @@
|
||||
<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>
|
||||
@@ -3655,7 +3653,4 @@
|
||||
<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>
|
||||
@@ -2918,8 +2918,6 @@
|
||||
<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>
|
||||
@@ -3733,7 +3731,4 @@
|
||||
<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,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3625,7 +3623,4 @@
|
||||
<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,8 +2835,6 @@
|
||||
<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>
|
||||
@@ -3629,7 +3627,4 @@
|
||||
<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,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3628,7 +3626,4 @@
|
||||
<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,8 +2831,6 @@
|
||||
<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>
|
||||
@@ -3628,7 +3626,4 @@
|
||||
<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>
|
||||
@@ -1118,6 +1118,123 @@
|
||||
<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>
|
||||
@@ -3106,8 +3223,6 @@
|
||||
<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>
|
||||
@@ -3816,7 +3931,4 @@
|
||||
<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>
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.droiddeck.launcher.agent
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import java.nio.file.Files
|
||||
|
||||
class AgentStorageTest {
|
||||
@Test fun removingAFolderKeepsWhatItsLinksPointAt() {
|
||||
val base = Files.createTempDirectory("agent-storage")
|
||||
val outside = Files.createDirectories(base.resolve("sdcard"))
|
||||
Files.write(outside.resolve("save.dat"), ByteArray(100))
|
||||
val item = Files.createDirectories(base.resolve("rootfs/android-sdk"))
|
||||
Files.write(item.resolve("a.jar"), ByteArray(10))
|
||||
val linked = runCatching { Files.createSymbolicLink(item.resolve("saves"), outside) }.isSuccess
|
||||
assumeTrue("symbolic links need privileges on this OS", linked)
|
||||
|
||||
assertTrue(AgentStorage.size(item) < 100)
|
||||
AgentStorage.delete(item)
|
||||
assertFalse(Files.exists(item))
|
||||
assertTrue(Files.exists(outside.resolve("save.dat")))
|
||||
}
|
||||
|
||||
@Test fun aMissingPathHasNoSize() {
|
||||
assertEquals(0L, AgentStorage.size(Files.createTempDirectory("agent-storage").resolve("gone")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.droiddeck.launcher.lab
|
||||
|
||||
import android.app.Application
|
||||
import com.droiddeck.launcher.BuildConfig
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33], manifest = Config.NONE)
|
||||
class LabOverlayTest {
|
||||
private lateinit var app: Application
|
||||
private lateinit var root: File
|
||||
private val injected = mutableListOf<File>()
|
||||
private val originalInject = LabOverlay.injectDex
|
||||
private val originalResources = LabOverlay.openResources
|
||||
|
||||
@Before fun prepare() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
app.applicationInfo.sourceDir = "/data/app/~~test==/com.droiddeck.launcher-test==/base.apk"
|
||||
root = LabOverlay.root(app).apply { deleteRecursively(); mkdirs() }
|
||||
LabOverlay.injectDex = { _, apk -> injected += apk }
|
||||
LabOverlay.openResources = { null }
|
||||
}
|
||||
|
||||
@After fun restore() {
|
||||
LabOverlay.injectDex = originalInject
|
||||
LabOverlay.openResources = originalResources
|
||||
root.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun stage(dir: String, commit: String = BuildConfig.SOURCE_COMMIT, apk: String = app.applicationInfo.sourceDir) =
|
||||
File(root, dir).apply {
|
||||
mkdirs()
|
||||
File(this, "overlay.apk").writeText("dex")
|
||||
File(this, "overlay.json").writeText("""{"label":"feat/x @ abc1234","baseCommit":"$commit","baseApk":"$apk"}""")
|
||||
}
|
||||
|
||||
@Test fun aBuildForThisInstallIsLoaded() {
|
||||
stage("active")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertEquals("feat/x @ abc1234", LabOverlay.active?.label)
|
||||
assertEquals(listOf(File(root, "active/overlay.apk")), injected)
|
||||
assertEquals(1, LabOverlay.failedStarts(root))
|
||||
}
|
||||
|
||||
@Test fun aStagedBuildIsPromotedAtStart() {
|
||||
stage("next")
|
||||
File(root, "starts").writeText("2")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertFalse(File(root, "next").exists())
|
||||
assertNotNull(LabOverlay.active)
|
||||
assertEquals(1, LabOverlay.failedStarts(root))
|
||||
}
|
||||
|
||||
@Test fun aBuildForAnotherInstallIsTurnedOff() {
|
||||
stage("active", commit = "0000000")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertNull(LabOverlay.active)
|
||||
assertTrue(injected.isEmpty())
|
||||
assertFalse(File(root, "active").exists())
|
||||
assertEquals("OTHER_INSTALL", File(root, "last-off-reason").readText())
|
||||
}
|
||||
|
||||
@Test fun aBuildForAnEarlierInstallOfTheSameCommitIsTurnedOff() {
|
||||
stage("active", apk = "/data/app/old/base.apk")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertNull(LabOverlay.active)
|
||||
assertEquals("OTHER_INSTALL", File(root, "last-off-reason").readText())
|
||||
}
|
||||
|
||||
@Test fun aBuildThatKeepsFailingToStartIsTurnedOff() {
|
||||
stage("active")
|
||||
File(root, "starts").writeText("2")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertNull(LabOverlay.active)
|
||||
assertEquals("DID_NOT_START", File(root, "last-off-reason").readText())
|
||||
}
|
||||
|
||||
@Test fun otherProcessesLoadWithoutCountingStarts() {
|
||||
stage("active")
|
||||
LabOverlay.load(app, mainProcess = false)
|
||||
assertNotNull(LabOverlay.active)
|
||||
assertEquals(0, LabOverlay.failedStarts(root))
|
||||
}
|
||||
|
||||
@Test fun noBuildMeansNothingIsLoaded() {
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertNull(LabOverlay.active)
|
||||
assertTrue(injected.isEmpty())
|
||||
assertFalse(LabOverlay.promoted)
|
||||
}
|
||||
|
||||
@Test fun aNewBuildKeepsTheOneItReplaces() {
|
||||
stage("active")
|
||||
stage("next")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertTrue(LabOverlay.promoted)
|
||||
val saved = LabOverlay.saved(app).single()
|
||||
assertEquals(LabOverlay.Reason.REPLACED, saved.reason)
|
||||
assertTrue(saved.usable)
|
||||
}
|
||||
|
||||
@Test fun aRefusedBuildIsKeptButNotUsable() {
|
||||
stage("active", commit = "0000000")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
val saved = LabOverlay.saved(app).single()
|
||||
assertEquals(LabOverlay.Reason.OTHER_INSTALL, saved.reason)
|
||||
assertFalse(saved.usable)
|
||||
}
|
||||
|
||||
@Test fun aKeptBuildCanBeStagedAgain() {
|
||||
stage("active", commit = "0000000")
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
stage("active")
|
||||
LabOverlay.archive(root, File(root, "active"), LabOverlay.Reason.TURNED_OFF)
|
||||
val usable = LabOverlay.saved(app).first { it.usable }
|
||||
LabOverlay.stage(app, usable)
|
||||
assertNotNull(LabOverlay.pending(app))
|
||||
assertEquals(1, LabOverlay.saved(app).size)
|
||||
LabOverlay.load(app, mainProcess = true)
|
||||
assertTrue(LabOverlay.promoted)
|
||||
assertNotNull(LabOverlay.active)
|
||||
}
|
||||
|
||||
@Test fun onlyTheNewestBuildsAreKept() {
|
||||
repeat(15) {
|
||||
stage("active")
|
||||
LabOverlay.archive(root, File(root, "active"), LabOverlay.Reason.TURNED_OFF)
|
||||
}
|
||||
assertEquals(12, LabOverlay.saved(app).size)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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,12 +15,16 @@ class GogCdnTest {
|
||||
{"endpoint_name":"lumen","url_format":"https://cdn.gog.com/{path}","parameters":{}}
|
||||
]}"""
|
||||
|
||||
@Test fun allHostsAreParsedInOrder() {
|
||||
@Test fun allHostsAreParsedInOrderAndTakenInTurn() {
|
||||
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,45 +26,6 @@ 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"},
|
||||
@@ -252,7 +213,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", metadata = { url, _ ->
|
||||
val snapshot = GogContent.snapshot(GogInstallRecord("1207658940", "depot"), build, "unused") { url, _ ->
|
||||
requested.add(url)
|
||||
when {
|
||||
url == build.link -> fixture("rayman-gen1-repository.json")
|
||||
@@ -260,7 +221,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())
|
||||
@@ -308,17 +269,6 @@ 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") }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AgentMarkdownTest {
|
||||
@Test fun blocksSplitHeadingsBulletsAndCode() {
|
||||
val blocks = markdownBlocks("## Done\n\n- one\n* two\n```kotlin\nval a = 1\n\nval b = 2\n```\nplain")
|
||||
assertEquals(
|
||||
listOf(
|
||||
MdBlock.Line("Done", heading = true),
|
||||
MdBlock.Line("• one", bullet = true),
|
||||
MdBlock.Line("• two", bullet = true),
|
||||
MdBlock.Code("val a = 1\n\nval b = 2"),
|
||||
MdBlock.Line("plain"),
|
||||
),
|
||||
blocks,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun inlineMarkersAreDroppedAndUnclosedOnesKept() {
|
||||
val text = inlineMarkdown("a **b** `c` d ** e ` f", Color.Red, Color.Black)
|
||||
assertEquals("a b c d ** e ` f", text.text)
|
||||
assertEquals(2, text.spanStyles.size)
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,11 @@ class SessionDrawerPageTest {
|
||||
assertEquals(SessionDrawerPage.SESSION, pages.step(SessionDrawerPage.GAMES, 1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun theAgentPageComesBeforeSessionOnlyWhenTheAgentIsInstalled() {
|
||||
val with = sessionDrawerPages(false, hasAgent = true)
|
||||
assertEquals(SessionDrawerPage.AGENT, with.step(SessionDrawerPage.GAMES, 1))
|
||||
assertEquals(SessionDrawerPage.SESSION, with.step(SessionDrawerPage.AGENT, 1))
|
||||
assertFalse(SessionDrawerPage.AGENT in sessionDrawerPages(false, hasAgent = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Opens a URL in Android's browser on the bottom screen.
|
||||
url=$1
|
||||
[ -n "$url" ] || exit 1
|
||||
serial=${ADB_SERIAL:-127.0.0.1:5555}
|
||||
adb connect "$serial" >/dev/null 2>&1
|
||||
quoted=$(printf "%s" "$url" | sed "s/'/'\\''/g")
|
||||
adb -s "$serial" shell "am start --display 2 -a android.intent.action.VIEW -d '$quoted'" >/dev/null 2>&1 ||
|
||||
echo "Open this on any device: $url" >&2
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Claude Code in the DroidDeck checkout, with sign-in links opened in Android's browser.
|
||||
export ADB_SERIAL=${ADB_SERIAL:-127.0.0.1:5555} PATH="$HOME/.local/bin:$PATH" BROWSER="$HOME/.local/bin/android-open"
|
||||
adb connect "$ADB_SERIAL" >/dev/null 2>&1
|
||||
cd "$HOME/DroidDeck" && exec claude "$@"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# GitHub sign-in through Android's browser on the bottom screen, with the one-time code typed in.
|
||||
serial=${ADB_SERIAL:-127.0.0.1:5555}
|
||||
adb connect "$serial" >/dev/null 2>&1
|
||||
coproc GH { BROWSER=true gh auth login --hostname github.com --git-protocol https --web 2>&1; }
|
||||
typed=
|
||||
while IFS= read -r line <&"${GH[0]}"; do
|
||||
printf '%s\n' "$line"
|
||||
code=$(grep -oE '[A-Z0-9]{4}-[A-Z0-9]{4}' <<<"$line" | head -1)
|
||||
if [ -n "$code" ] && [ -z "$typed" ]; then
|
||||
typed=1
|
||||
echo >&"${GH[1]}"
|
||||
adb -s "$serial" shell "am start --display 2 -a android.intent.action.VIEW -d https://github.com/login/device" >/dev/null 2>&1
|
||||
# Chrome drops characters from `input text` and from fast key events on this page.
|
||||
keys=
|
||||
for ((i = 0; i < ${#code}; i++)); do
|
||||
c=${code:i:1}
|
||||
[ "$c" = - ] || keys+="input -d 2 keyevent KEYCODE_$c; sleep 0.4; "
|
||||
done
|
||||
(sleep 7; adb -s "$serial" shell "$keys" >/dev/null 2>&1) &
|
||||
fi
|
||||
done
|
||||
wait "$GH_PID"
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/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
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
# Sets up the on-device agent inside DroidDeck's Linux userland: Claude Code, git and gh, loopback
|
||||
# adb for droiddeckctl, and the toolchain dd-lab-build uses to build Lab builds on the device.
|
||||
# Run it from the DroidDeck terminal, inside a checkout of the DroidDeck repository.
|
||||
set -euo pipefail
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
apt-get install -y --no-install-recommends git gh adb python3 zip unzip curl openjdk-17-jdk-headless qemu-user zipalign apksigner
|
||||
|
||||
command -v claude >/dev/null || [ -x "$HOME/.local/bin/claude" ] || curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
for tool in dd-agent dd-lab-build dd-gh-login android-open; do install -m755 "$here/$tool" "$HOME/.local/bin/$tool"; done
|
||||
grep -q 'droiddeck-agent-path' "$HOME/.bashrc" 2>/dev/null ||
|
||||
printf '\n# droiddeck-agent-path\nexport PATH="$HOME/.local/bin:$PATH" ADB_SERIAL=127.0.0.1:5555 BROWSER="$HOME/.local/bin/android-open"\n' >> "$HOME/.bashrc"
|
||||
|
||||
# Google publishes aapt2 for x86-64 Linux only; it runs here under qemu-user with Ubuntu's x86-64 runtime.
|
||||
agp=$(sed -n "s/.*com.android.tools.build:gradle:\([0-9.]*\).*/\1/p" build.gradle | head -1)
|
||||
aapt2=$(curl -fsSL "https://dl.google.com/android/maven2/com/android/tools/build/aapt2/maven-metadata.xml" |
|
||||
grep -o "<version>$agp-[0-9]*</version>" | tail -1 | sed 's/<[^>]*>//g')
|
||||
x86=$HOME/x86root
|
||||
mkdir -p "$HOME/aapt2/x86" "$x86/etc"
|
||||
if [ ! -x "$HOME/aapt2/x86/aapt2" ]; then
|
||||
curl -fsSL -o /tmp/aapt2.jar "https://dl.google.com/android/maven2/com/android/tools/build/aapt2/$aapt2/aapt2-$aapt2-linux.jar"
|
||||
unzip -o -q /tmp/aapt2.jar aapt2 -d "$HOME/aapt2/x86"
|
||||
chmod +x "$HOME/aapt2/x86/aapt2"
|
||||
fi
|
||||
if [ ! -e "$x86/lib64/ld-linux-x86-64.so.2" ]; then
|
||||
codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||
curl -fsSL "http://archive.ubuntu.com/ubuntu/dists/$codename/main/binary-amd64/Packages.gz" | gunzip > /tmp/amd64-packages
|
||||
for package in libc6 libgcc-s1 libstdc++6 zlib1g; do
|
||||
file=$(awk -v p="$package" '$0 == "Package: " p {found = 1} found && /^Filename:/ {print $2; exit}' /tmp/amd64-packages)
|
||||
curl -fsSL -o /tmp/$package.deb "http://archive.ubuntu.com/ubuntu/$file"
|
||||
dpkg-deb -x /tmp/$package.deb "$x86"
|
||||
done
|
||||
ln -sfn usr/lib64 "$x86/lib64"
|
||||
ln -sfn usr/lib "$x86/lib"
|
||||
: > "$x86/etc/ld.so.preload"
|
||||
fi
|
||||
# AGP only accepts a custom aapt2 whose file is named aapt2.
|
||||
printf '#!/bin/sh\nexec qemu-x86_64 -L %s %s/aapt2/x86/aapt2 "$@"\n' "$x86" "$HOME" > "$HOME/aapt2/aapt2"
|
||||
chmod +x "$HOME/aapt2/aapt2"
|
||||
"$HOME/aapt2/aapt2" version
|
||||
|
||||
sdk=$HOME/android-sdk
|
||||
if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
mkdir -p "$sdk/cmdline-tools"
|
||||
curl -fsSL -o /tmp/cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip
|
||||
unzip -q -o /tmp/cmdline-tools.zip -d "$sdk/cmdline-tools"
|
||||
rm -rf "$sdk/cmdline-tools/latest"
|
||||
mv "$sdk/cmdline-tools/cmdline-tools" "$sdk/cmdline-tools/latest"
|
||||
fi
|
||||
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
|
||||
yes | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null
|
||||
"$sdk/cmdline-tools/latest/bin/sdkmanager" "platforms;android-34" "build-tools;35.0.0" >/dev/null
|
||||
|
||||
adb connect 127.0.0.1:5555 || true
|
||||
# The first build compiles the whole app; doing it now makes the agent's Lab builds incremental.
|
||||
[ "${DD_SKIP_PREPARE:-}" = 1 ] || "$here/dd-lab-build" --prepare || echo "The first Lab build will compile the whole app"
|
||||
echo "Done. Approve the USB debugging prompt if one appears, then run dd-agent."
|
||||
+1
-6
@@ -263,15 +263,10 @@ 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": physical,
|
||||
"physicalId": devices.get(name) if name else None,
|
||||
"size": [int(size.group(1)), int(size.group(2))] if size else None,
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -927,33 +927,6 @@ 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()
|
||||
@@ -1288,19 +1261,6 @@ 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,7 +1,6 @@
|
||||
#!/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, proton=None, env=None):
|
||||
def win_components(compat, app_id):
|
||||
if not compat:
|
||||
return ""
|
||||
try:
|
||||
module = runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-wincomponents"))
|
||||
names, notes = module["apply"](compat.rstrip("/"), app_id, proton=proton, env=env or os.environ)
|
||||
names, notes = module["apply"](compat.rstrip("/"), app_id)
|
||||
for note in notes:
|
||||
print("droiddeck-game-env: " + note, file=sys.stderr)
|
||||
return module["overrides_value"](names)
|
||||
@@ -859,60 +859,6 @@ 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)
|
||||
@@ -1210,42 +1156,6 @@ 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)
|
||||
@@ -1254,6 +1164,8 @@ 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:
|
||||
@@ -1271,6 +1183,7 @@ 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
|
||||
@@ -1278,21 +1191,22 @@ def main():
|
||||
if dirt_prefix:
|
||||
own = fixes.get("WINEDLLOVERRIDES")
|
||||
fixes["WINEDLLOVERRIDES"] = own + ";ws2_32=n,b" if own else "ws2_32=n,b"
|
||||
if waiting:
|
||||
if launch_config_valid:
|
||||
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 "vblank_mode" in config.get("shared", {}):
|
||||
fixes.pop("vblank_mode", None)
|
||||
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, config, app_id, fixes)
|
||||
else:
|
||||
env = apply_config(env, sync_config(config, app_id), app_id)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr)
|
||||
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)
|
||||
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
Reference in new issue
Block a user