From 63b24ca4c0764d18be98d57e788c7372dc8624ee Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 24 Sep 2026 21:16:47 -0400 Subject: [PATCH 1/2] Add debug-only agent control bridge --- README.md | 4 + app/src/debug/AndroidManifest.xml | 12 + .../launcher/agent/AgentBridgeProvider.kt | 249 +++++++++++++++ .../com/droiddeck/launcher/SessionActivity.kt | 72 ++++- .../launcher/session/SessionArtifacts.kt | 3 + .../launcher/session/SessionEvents.kt | 117 +++++++ .../launcher/session/SessionPaths.kt | 5 +- .../launcher/session/SessionService.kt | 39 ++- .../launcher/session/SessionState.kt | 51 +++ docs/agent-control.md | 40 +++ tools/adb_device.py | 105 ++++++ tools/deploy_local.sh | 17 +- tools/droiddeckctl | 299 ++++++++++++++++++ 13 files changed, 991 insertions(+), 22 deletions(-) create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt create mode 100644 docs/agent-control.md create mode 100755 tools/adb_device.py create mode 100755 tools/droiddeckctl diff --git a/README.md b/README.md index a07db935c..8d61d04ea 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ On Android 12+, if Steam exits without a log, turn off **Restrict child processe Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`. +## Agent control + +Debug APKs expose a shell-protected ADB bridge and `tools/droiddeckctl`. See [agent control](docs/agent-control.md) for setup and commands. + ## Limits Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems. diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..a7d685f38 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt new file mode 100644 index 000000000..7b6b43979 --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -0,0 +1,249 @@ +package com.droiddeck.launcher.agent + +import android.Manifest +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.Cursor +import android.net.Uri +import android.os.Binder +import android.os.Bundle +import android.os.Process +import android.util.Base64 +import com.droiddeck.launcher.BuildConfig +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller +import com.droiddeck.launcher.session.SessionArtifacts +import com.droiddeck.launcher.session.SessionEvents +import com.droiddeck.launcher.session.SessionPhase +import com.droiddeck.launcher.session.SessionPaths +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** A shell-only control surface, installed only by debug builds. */ +class AgentBridgeProvider : ContentProvider() { + private val recoveryComplete = CountDownLatch(1) + + override fun onCreate(): Boolean { + val appContext = context?.applicationContext ?: return false + Thread({ + try { + SessionArtifacts.finishAbandoned(appContext) + } finally { + recoveryComplete.countDown() + } + }, "agent-recover-artifacts").start() + return true + } + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle { + val context = requireNotNull(context) + if (Binder.getCallingUid() != Process.myUid() && + context.checkCallingPermission(Manifest.permission.DUMP) != PackageManager.PERMISSION_GRANTED + ) { + throw SecurityException("android.permission.DUMP is required") + } + + val response = try { + when (method) { + "state" -> state(context).put("ok", true) + "start" -> start(context, arg.orEmpty(), extras) + "stop" -> stop(context) + "resume" -> resume(context) + else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'") + } + } catch (e: Exception) { + error("COMMAND_FAILED", e.message ?: e.javaClass.simpleName) + } + return Bundle().apply { putString(RESULT_JSON, response.toString()) } + } + + private fun state(context: Context): JSONObject { + val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context) + val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory() + val session = JSONObject() + .put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL) + .put("phase", SessionState.phase.name) + .put("running", SessionState.running) + .put("mode", SessionState.mode) + .put("program", SessionState.program ?: JSONObject.NULL) + .put("steamUi", SessionState.steamUi ?: JSONObject.NULL) + .put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL) + .put("suspended", SessionState.suspended) + .put("firstFrame", SessionState.firstFrameSeen) + .put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second)) + .put("refreshHz", SessionState.refreshHz.toDouble()) + .put("lastTransitionAt", SessionState.lastTransitionAt) + .put("guestPid", SessionState.guestPid.takeIf { it > 1 } ?: JSONObject.NULL) + .put("installing", SessionState.installing ?: JSONObject.NULL) + .put("logDir", dir?.absolutePath ?: JSONObject.NULL) + .put("eventsFile", dir?.let { File(it, "events.jsonl").absolutePath } ?: JSONObject.NULL) + .put("artifactsAvailable", dir?.isDirectory ?: false) + .put("artifactsComplete", dir?.let { File(it, ".complete").isFile } ?: false) + + val failure = if (SessionState.failureCode == null) JSONObject.NULL else JSONObject() + .put("code", SessionState.failureCode) + .put("message", SessionState.failureMessage ?: JSONObject.NULL) + .put("status", SessionState.failureStatus ?: JSONObject.NULL) + session.put("failure", failure) + + return JSONObject() + .put("schema", 1) + .put("build", BuildConfig.BUILD_LABEL) + .put("appVersion", context.packageManager.getPackageInfo(context.packageName, 0).versionName) + .put("runtime", JSONObject() + .put("installed", LinuxRuntime.isInstalled(context)) + .put("version", runtimeVersion ?: JSONObject.NULL)) + .put("session", session) + } + + private fun latestSessionDirectory(): File? { + val parent = LinuxRuntime.debugLogDir() + return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") } + ?.maxWithOrNull(compareBy { it.lastModified() }.thenBy { it.name }) + } + + private fun start(context: Context, requested: String, extras: Bundle?): JSONObject { + if (SessionState.running || SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) { + return error("SESSION_ACTIVE", "A session is already starting or running") + } + val request = request(extras) + val mode = request?.optString("mode")?.takeIf { it.isNotBlank() } ?: requested + val intent = Intent(context, SessionActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + val sessionMode: String + + when (mode) { + "steam" -> { + sessionMode = SessionService.MODE_STEAM + intent.putExtra(SessionService.EXTRA_MODE, sessionMode) + } + "desktop", SessionService.MODE_DESKTOP -> { + sessionMode = SessionService.MODE_DESKTOP + intent.putExtra(SessionService.EXTRA_MODE, sessionMode) + } + "run" -> { + sessionMode = SessionService.MODE_RUN + val program = request?.optString("program")?.takeIf { it.isNotBlank() } + ?: return error("PROGRAM_REQUIRED", "Run mode requires a program path") + val args = request?.optJSONArray("programArgs") ?: JSONArray() + val programArgs = Array(args.length()) { index -> + args.optString(index) + } + intent.putExtra(SessionService.EXTRA_MODE, sessionMode) + .putExtra(SessionService.EXTRA_PROGRAM, program) + .putExtra(SessionService.EXTRA_PROGRAM_ARGS, programArgs) + } + else -> return error("INVALID_MODE", "Mode must be steam, desktop, or run") + } + + request?.optString("steamUi")?.takeIf { it == "desktop" }?.let { + intent.putExtra(SessionService.EXTRA_STEAM_UI, it) + } + request?.optString("steamUrl")?.takeIf { it.startsWith("steam://") }?.let { + intent.putExtra(SessionService.EXTRA_STEAM_URL, it) + } + + awaitRecovery() + return try { + SessionEvents.begin(context, sessionMode) + SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) + SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() + SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI) + SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL) + SessionEvents.record("agent.start_requested", mapOf("mode" to sessionMode)) + context.startActivity(intent) + JSONObject().put("ok", true).put("command", "start").put("mode", mode) + } catch (e: Exception) { + val message = e.message ?: e.javaClass.simpleName + SessionEvents.fail("ACTIVITY_START_FAILED", message) + val dir = SessionPaths.take() + if (dir != null) { + val appContext = context.applicationContext + Thread({ + SessionArtifacts.collect(appContext, dir, "activity start failed") + SessionPaths.release(appContext, dir) + }, "agent-start-failure-artifacts").start() + } + error("ACTIVITY_START_FAILED", message) + } + } + + private fun stop(context: Context): JSONObject { + if (SessionState.running) { + SessionService.stop(context) + return JSONObject().put("ok", true).put("command", "stop") + } + if (SessionState.phase == SessionPhase.STOPPING) { + return JSONObject().put("ok", true).put("command", "stop") + } + if (SessionState.phase in STARTING_PHASES) { + SessionState.stopRequested = true + SessionEvents.transition(SessionPhase.STOPPING, "session.stop_requested") + return JSONObject().put("ok", true).put("command", "stop") + } + return error("NO_ACTIVE_SESSION", "There is no session to stop") + } + + private fun resume(context: Context): JSONObject { + if (!SessionState.running || !SessionState.suspended) { + return error("SESSION_NOT_SUSPENDED", "There is no suspended session to resume") + } + SessionService.resume(context) + return JSONObject().put("ok", true).put("command", "resume") + } + + private fun request(extras: Bundle?): JSONObject? { + val encoded = extras?.getString(REQUEST_EXTRA) ?: return null + return try { + val decoded = Base64.decode(encoded, Base64.DEFAULT) + JSONObject(String(decoded, Charsets.UTF_8)) + } catch (e: JSONException) { + throw IllegalArgumentException("Invalid request JSON", e) + } catch (e: IllegalArgumentException) { + throw IllegalArgumentException("Invalid base64 request", e) + } + } + + private fun awaitRecovery() { + try { + if (!recoveryComplete.await(90, TimeUnit.SECONDS)) { + throw IllegalStateException("Session artifact recovery did not finish") + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw IllegalStateException("Interrupted while recovering session artifacts", e) + } + } + + private fun error(code: String, message: String) = JSONObject() + .put("ok", false) + .put("error", JSONObject().put("code", code).put("message", message)) + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 + + companion object { + const val RESULT_JSON = "json" + const val REQUEST_EXTRA = "request" + private val STARTING_PHASES = setOf( + SessionPhase.PREPARING, + SessionPhase.INSTALLING_RUNTIME, + SessionPhase.STARTING_COMPOSITOR, + SessionPhase.STARTING_GUEST, + SessionPhase.STARTING_STEAM, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 2d0c3c243..f3a2bf9a6 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -48,6 +48,9 @@ import com.droiddeck.launcher.session.PerfHints import com.droiddeck.launcher.session.PerfHud import com.droiddeck.launcher.session.PerfMode import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionEvents +import com.droiddeck.launcher.session.SessionArtifacts +import com.droiddeck.launcher.session.SessionPhase import com.droiddeck.launcher.session.SessionPaths import com.droiddeck.launcher.wayland.HdrSupport import com.droiddeck.launcher.session.SessionService @@ -160,6 +163,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) { + SessionEvents.begin(this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + } + SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) + SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() + SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI) + SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // Game-tier power policy for the whole session: a sustained clock floor, the panel's // fastest mode, and the OS told it is in gameplay. Logged so a slow device says why. @@ -301,12 +311,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { updateOnScreenControls() WaylandCompositor.setFirstFrameListener { - SessionState.firstFrameSeen = true + SessionEvents.firstFrame() runOnUiThread { loading.visible = false hud.start() } } + SessionEvents.markReadyIfPossible() SessionState.endListener = endListener // A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord. // The single action waits out the double-press window so the two actions stay distinct. @@ -393,14 +404,46 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { * starts when they are in. Nothing else changes. */ private fun installThenStart(runtime: Boolean, desktop: Boolean) { + if (SessionState.stopRequested) return installingRuntime = true + SessionState.installing = if (runtime) "runtime" else "desktop" + SessionEvents.transition( + SessionPhase.INSTALLING_RUNTIME, + if (runtime) "runtime.installing" else "desktop.installing", + mapOf("component" to SessionState.installing), + ) loading.step = if (runtime) "downloading the Linux runtime" else "downloading the desktop" loading.percent = -1 Thread({ - val problem = (if (runtime) installRuntime() else null) ?: (if (desktop) installDesktop() else null) + var failedComponent: String? = null + var problem: String? = null + if (runtime) { + problem = installRuntime() + if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime" + } + if (problem == null && desktop && !SessionState.stopRequested) { + SessionState.installing = "desktop" + SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop")) + problem = installDesktop() + if (problem == null) SessionEvents.record("desktop.ready") else failedComponent = "desktop" + } uiHandler.post { installingRuntime = false - if (problem != null) { loading.showEnded(problem); return@post } + SessionState.installing = null + if (SessionState.stopRequested) { + SessionState.stopRequested = false + SessionEvents.transition(SessionPhase.IDLE, "session.cancelled") + collectStartArtifacts("session start cancelled") + finish() + return@post + } + if (problem != null) { + val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED" + SessionEvents.fail(code, problem) + collectStartArtifacts("session start failed") + loading.showEnded(problem) + return@post + } loading.percent = -1 loading.step = "Starting the session…" // The surface may have come and gone while the download ran; start on the live one. @@ -409,6 +452,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, "runtime-install").start() } + private fun collectStartArtifacts(reason: String) { + val dir = SessionPaths.take() ?: return + val appContext = applicationContext + Thread({ + SessionArtifacts.collect(appContext, dir, reason) + SessionPaths.release(appContext, dir) + }, "session-failure-artifacts").start() + } + /** Reports one package's download on the loading screen: " · 332 of 791 MB", checking, unpacking. */ private fun progressFor(what: String, mb: Long) = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, p -> uiHandler.post { @@ -441,7 +493,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun surfaceCreated(holder: SurfaceHolder) { - if (installingRuntime) return + if (installingRuntime || SessionState.stopRequested) return + if (!SessionState.running) { + SessionEvents.transition(SessionPhase.STARTING_COMPOSITOR, "compositor.starting") + } val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() } // The compositor hands this keymap to wl_keyboard clients, which is how the guest reads // the evdev codes we inject. @@ -505,6 +560,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), ) + if (!SessionState.running) SessionEvents.record("compositor.started") // ADPF: the compositor thread's frame intervals go to the power HAL against the panel's // period, so a long frame raises CPU clocks now rather than after the load averages up. PerfHints.arm(this, refreshHz()) @@ -514,6 +570,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // start nothing, and leave the loading panel counting up over a dead session. if (!SessionState.running) { CompositorHost.newSession() + SessionEvents.transition(SessionPhase.STARTING_GUEST, "guest.starting") SessionService.start( this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM), @@ -616,6 +673,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val poll = object : Runnable { override fun run() { if (!watching) return + if (SessionState.stopRequested && !SessionState.running && !installingRuntime) { + SessionState.stopRequested = false + SessionEvents.transition(SessionPhase.IDLE, "session.cancelled") + collectStartArtifacts("session start cancelled") + finish() + return + } if (loading.visible && !loading.ended) { if (!installingRuntime) loading.update(this@SessionActivity, SessionState.logFile) if (ticks++ % 2 == 0) loading.tick() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index 9dee67289..ddc65941a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -43,6 +43,7 @@ object SessionArtifacts { copySteamLogs(context, dir) // A session the system killed leaves its trace here and nowhere else. SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log")) + SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir) File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n") } catch (e: Exception) { Log.w(TAG, "collecting session artifacts", e) @@ -72,6 +73,7 @@ object SessionArtifacts { * older folder would be handed logs that are not its own. Runs on a worker thread at app * start; nothing here touches the session that is about to begin. */ + @Synchronized fun finishAbandoned(context: Context) { val parent = LinuxRuntime.debugLogDir() val abandoned = parent.listFiles { f -> @@ -97,6 +99,7 @@ object SessionArtifacts { copySteamLogs(context, dir) } SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log")) + SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir) File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n") Log.i(TAG, "finished the abandoned session folder $dir") } catch (e: Exception) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt new file mode 100644 index 000000000..cd7955a57 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt @@ -0,0 +1,117 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream + +/** Writes the machine-readable lifecycle stream beside the existing session artifacts. */ +object SessionEvents { + private const val TAG = "SessionEvents" + private val lock = Any() + + fun begin(context: Context, mode: String) { + // A runtime-install failure can leave the folder claimed before the service ever starts. + // Keep that folder, but give the next attempt its own session directory. + SessionPaths.take() + val dir = SessionPaths.beginOrCurrent(context) + synchronized(SessionState) { + SessionState.phase = SessionPhase.PREPARING + SessionState.mode = mode + SessionState.sessionId = dir.name + SessionState.lastTransitionAt = System.currentTimeMillis() + SessionState.failureCode = null + SessionState.failureMessage = null + SessionState.failureStatus = null + SessionState.logDirectory = dir + SessionState.logFile = File(dir, "session.log") + SessionState.eventsFile = File(dir, "events.jsonl") + SessionState.guestPid = -1 + SessionState.installing = null + SessionState.stopRequested = false + SessionState.program = null + SessionState.programArgs = emptyList() + SessionState.steamUi = null + SessionState.steamUrl = null + SessionState.firstFrameSeen = false + SessionState.suspended = false + } + record("session.created", mapOf("mode" to mode), dir) + } + + fun transition( + phase: SessionPhase, + event: String, + fields: Map = emptyMap(), + ) { + synchronized(SessionState) { + if (SessionState.phase != phase) { + SessionState.phase = phase + SessionState.lastTransitionAt = System.currentTimeMillis() + } + } + record(event, fields) + } + + fun record(event: String, fields: Map = emptyMap(), directory: File? = null) { + val dir = directory ?: SessionState.logDirectory ?: SessionPaths.current() ?: return + val target = if (directory == null) SessionState.eventsFile ?: File(dir, "events.jsonl") + else File(dir, "events.jsonl") + val payload = JSONObject() + .put("t", System.currentTimeMillis()) + .put("event", event) + .put("sessionId", dir.name) + fields.forEach { (key, value) -> payload.put(key, value ?: JSONObject.NULL) } + + try { + synchronized(lock) { + target.parentFile?.mkdirs() + FileOutputStream(target, true).use { stream -> + stream.write((payload.toString() + "\n").toByteArray(Charsets.UTF_8)) + } + } + } catch (e: Exception) { + Log.w(TAG, "writing ${target.name}", e) + } + } + + fun fail(code: String, message: String, status: Int? = null) { + synchronized(SessionState) { + SessionState.phase = SessionPhase.FAILED + SessionState.lastTransitionAt = System.currentTimeMillis() + SessionState.failureCode = code + SessionState.failureMessage = message + SessionState.failureStatus = status + } + record("session.failed", mapOf("code" to code, "message" to message, "status" to status)) + } + + fun firstFrame() { + SessionState.firstFrameSeen = true + record("frame.first") + markReadyIfPossible() + } + + fun guestStarted(pid: Int) { + SessionState.guestPid = pid + record("guest.started", mapOf("pid" to pid)) + if (SessionState.mode == SessionService.MODE_STEAM) { + transition(SessionPhase.STARTING_STEAM, "steam.starting") + } + markReadyIfPossible() + } + + fun markReadyIfPossible() { + if (!SessionState.running || SessionState.guestPid <= 1 || !SessionState.firstFrameSeen || SessionState.suspended) return + if (SessionState.phase != SessionPhase.READY) { + transition(SessionPhase.READY, "session.ready") + } + } + + fun resumePhase(): SessionPhase = when { + SessionState.firstFrameSeen && SessionState.guestPid > 1 -> SessionPhase.READY + SessionState.mode == SessionService.MODE_STEAM && SessionState.guestPid > 1 -> SessionPhase.STARTING_STEAM + else -> SessionPhase.STARTING_GUEST + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt index 0ae1ffb98..d49a4c1c9 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPaths.kt @@ -41,7 +41,10 @@ object SessionPaths { val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context) else File(context.cacheDir, "session-logs") - val made = File(parent, "session-$stamp") + val baseName = "session-$stamp" + var made = File(parent, baseName) + var suffix = 2 + while (made.exists()) made = File(parent, "$baseName-${suffix++}") if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made") dir = made Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)") diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index ec15c5c2c..e1c052d2d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -155,6 +155,11 @@ class SessionService : Service() { } startForeground(NOTIFICATION_ID, buildNotification()) if (SessionState.running) return START_NOT_STICKY + if (SessionState.stopRequested) { + SessionState.running = true + stopSession(0) + return START_NOT_STICKY + } SessionState.mode = intent?.getStringExtra(EXTRA_MODE) ?: MODE_STEAM suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) activityVisible = true @@ -168,11 +173,17 @@ class SessionService : Service() { SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI) SessionState.steamUrl = intent?.getStringExtra(EXTRA_STEAM_URL) + SessionState.running = true + if (SessionState.stopRequested) { + stopSession(0) + return START_NOT_STICKY + } // Another Steam client on the device signs ours out seconds after every login; the one that // does it here runs from boot without being opened. Only the Steam session signs in. if (SessionState.mode == MODE_STEAM) RivalClients.stopBeforeSession(this) - SessionState.running = true SessionState.firstFrameSeen = false + SessionState.guestPid = -1 + SessionEvents.transition(SessionPhase.STARTING_GUEST, "service.started", mapOf("mode" to SessionState.mode)) // This session's number, claimed here and not when its process starts: the session it // replaces can report its own exit in the gap between the two, and that exit must not // be taken as this one's. @@ -249,6 +260,10 @@ class SessionService : Service() { val sessionDir = SessionPaths.beginOrCurrent(this) val sessionLog = File(sessionDir, "session.log") SessionState.logFile = sessionLog + SessionState.logDirectory = sessionDir + SessionState.sessionId = sessionDir.name + SessionState.eventsFile = File(sessionDir, "events.jsonl") + SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path)) // Written first, so a session that dies in its first second still says what it ran on. DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode) NetworkReport.write(this, File(sessionDir, "network.txt")) @@ -590,6 +605,14 @@ class SessionService : Service() { stopSession(status ?: -1) }, null) Log.i(TAG, "session pid $sessionPid, log ${sessionLog.path}") + if (gen == sessionGen && SessionState.running && sessionPid > 1) { + SessionEvents.guestStarted(sessionPid) + } else if (gen == sessionGen && SessionState.running) { + SessionEvents.record("guest.start_failed", mapOf("pid" to sessionPid)) + SessionEvents.fail("GUEST_START_FAILED", "The guest process could not be started", sessionPid) + stopSession(-1) + return + } if (gen == sessionGen && SessionState.running) { suspendController = SessionSuspendController( sessionRoot = { sessionPid }, @@ -752,6 +775,7 @@ class SessionService : Service() { suspendOperationPending = false if (success) { SessionState.suspended = true + SessionEvents.transition(SessionPhase.SUSPENDED, "session.suspended") releaseLocks() refreshNotification() } else { @@ -769,6 +793,7 @@ class SessionService : Service() { suspendOperationPending = false if (success) { SessionState.suspended = false + SessionEvents.transition(SessionEvents.resumePhase(), "session.resumed") refreshNotification() } else { suspendAttemptFailed = true @@ -783,7 +808,16 @@ class SessionService : Service() { private fun finishSessionStop(status: Int) { mainHandler.post { SessionState.suspended = false + SessionState.guestPid = -1 releaseLocks() + if (status == 0) { + SessionEvents.transition(SessionPhase.IDLE, "session.stopped", mapOf("status" to status)) + } else { + val code = SessionState.failureCode ?: "GUEST_EXIT" + val message = SessionState.failureMessage ?: "Session guest exited with status $status" + val failureStatus = SessionState.failureStatus ?: status + SessionEvents.fail(code, message, failureStatus) + } SessionState.notifyEnded(status) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() @@ -793,6 +827,9 @@ class SessionService : Service() { private fun stopSession(status: Int) { if (!SessionState.running) return SessionState.running = false + SessionState.stopRequested = false + SessionEvents.record("guest.exited", mapOf("status" to status)) + SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status)) suspendOperationPending = false launchWatcher?.stopWatching() launchWatcher = null diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index d2d3349c5..21227ba51 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -11,14 +11,51 @@ import androidx.compose.runtime.setValue * either can outlive the other. */ object SessionState { + @Volatile + var phase = SessionPhase.IDLE + + @Volatile + var sessionId: String? = null + + @Volatile + var lastTransitionAt = 0L + + @Volatile + var failureCode: String? = null + + @Volatile + var failureMessage: String? = null + + @Volatile + var failureStatus: Int? = null + + @Volatile + var logDirectory: File? = null + + @Volatile + var eventsFile: File? = null + + @Volatile + var guestPid = -1 + + @Volatile + var installing: String? = null + + @Volatile + var stopRequested = false + @Volatile var running = false var suspended by mutableStateOf(false) /** MODE_RUN: the program inside the runtime the session was started for. */ + @Volatile var program: String? = null + @Volatile var programArgs: List = emptyList() /** MODE_STEAM: "desktop" for the client's desktop UI, else Big Picture; and a steam:// URL to hand it. */ + @Volatile var steamUi: String? = null + @Volatile var steamUrl: String? = null /** A session the guest asked for (the desktop's Steam launchers): started by the activity once this one has ended. */ var relaunch: android.content.Intent? = null @@ -57,3 +94,17 @@ object SessionState { endListener?.invoke(status) } } + +/** Stable lifecycle values returned by the debug agent bridge. */ +enum class SessionPhase { + IDLE, + PREPARING, + INSTALLING_RUNTIME, + STARTING_COMPOSITOR, + STARTING_GUEST, + STARTING_STEAM, + READY, + SUSPENDED, + STOPPING, + FAILED, +} diff --git a/docs/agent-control.md b/docs/agent-control.md new file mode 100644 index 000000000..06735c2c8 --- /dev/null +++ b/docs/agent-control.md @@ -0,0 +1,40 @@ +# Agent control + +The debug APK exposes a ContentProvider at `content://com.droiddeck.launcher.agent`. Android's shell-only `DUMP` permission protects it. The provider and its authority are absent from release APKs. + +Build and install the debug APK: + +```sh +./gradlew assembleDebug --console=plain +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +The provider can also be called directly: + +```sh +adb shell content call --uri content://com.droiddeck.launcher.agent --method state +adb shell content call --uri content://com.droiddeck.launcher.agent --method start --arg steam +adb shell content call --uri content://com.droiddeck.launcher.agent --method stop +``` + +The host CLI resolves one authorized device using `ADB_SERIAL` or `ANDROID_SERIAL` when set. Otherwise it deduplicates transports that report the same device serial and asks for an explicit serial if multiple devices remain. + +```sh +tools/droiddeckctl state --json +tools/droiddeckctl start steam +tools/droiddeckctl wait ready --timeout 90 +tools/droiddeckctl start desktop +tools/droiddeckctl run /usr/bin/foo -- arg1 arg2 +tools/droiddeckctl stop +tools/droiddeckctl resume +tools/droiddeckctl logs latest ./session-artifacts +tools/droiddeckctl screenshot ./screen.png +``` + +Every command writes JSON to stdout and reports its resolved ADB serial to stderr. Exit codes are 0 for success, 2 for invalid or rejected commands, 3 for ADB/device errors, 4 for session failures, 5 for timeouts, and 6 for artifact or file errors. Set `ADB` to select an adb executable or pass `--adb`; pass `--serial` before the command to select a device directly. + +`start` accepts `steam` or `desktop`. Steam starts in Big Picture by default; `--ui desktop` selects the client's desktop UI, and `--url steam://...` passes a client URL. Use `--wait` to wait for `READY` as part of `start`. `run` accepts a program path and optional guest arguments. + +The `state` response uses schema 1 and includes the build label, runtime version, session ID and phase, mode, requested program, suspend and first-frame state, output size, guest PID, failure details, and artifact paths. Phases are `IDLE`, `PREPARING`, `INSTALLING_RUNTIME`, `STARTING_COMPOSITOR`, `STARTING_GUEST`, `STARTING_STEAM`, `READY`, `SUSPENDED`, `STOPPING`, and `FAILED`. Each session also writes `events.jsonl` alongside its existing artifacts. + +`tools/deploy_local.sh` uses the same device resolver. If multiple distinct devices are connected, select one with `ADB_SERIAL` or `ANDROID_SERIAL`. diff --git a/tools/adb_device.py b/tools/adb_device.py new file mode 100755 index 000000000..5e2990399 --- /dev/null +++ b/tools/adb_device.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Resolve one authorized Android device, collapsing transports for the same device.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys + + +class DeviceResolutionError(RuntimeError): + pass + + +def _run(adb: str, *args: str, timeout: int = 15) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + [adb, *args], capture_output=True, text=True, check=False, timeout=timeout + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise DeviceResolutionError(str(exc)) from exc + + +def _device_list(adb: str) -> list[str]: + result = _run(adb, "devices", "-l") + if result.returncode != 0: + raise DeviceResolutionError(result.stderr.strip() or "adb devices failed") + devices = [] + for line in result.stdout.splitlines()[1:]: + fields = line.split() + if len(fields) >= 2 and fields[1] == "device": + devices.append(fields[0]) + return devices + + +def _identity(adb: str, serial: str) -> str | None: + for prop in ("ro.serialno", "ro.boot.serialno"): + result = _run(adb, "-s", serial, "shell", "getprop", prop) + value = result.stdout.strip() + if result.returncode == 0 and value and value.lower() not in {"unknown", "null", "0"}: + return value + return None + + +def _rank(serial: str) -> tuple[int, str]: + # Prefer a stable mDNS transport over a network address with an ephemeral port. + if "_adb-tls-connect._tcp" in serial: + return (0, serial) + if not re.search(r":\d+$", serial): + return (1, serial) + return (2, serial) + + +def resolve_serial(adb: str, explicit: str | None = None, *, log: bool = False) -> str: + selected = explicit or os.environ.get("ADB_SERIAL") or os.environ.get("ANDROID_SERIAL") + if selected: + result = _run(adb, "-s", selected, "get-state") + if result.returncode != 0 or result.stdout.strip() != "device": + raise DeviceResolutionError(f"ADB serial '{selected}' is not an authorized device") + serial = selected + else: + devices = _device_list(adb) + if not devices: + raise DeviceResolutionError("No authorized Android device found; set ADB_SERIAL or ANDROID_SERIAL") + identities = {device: _identity(adb, device) for device in devices} + groups: dict[str, list[str]] = {} + for device in devices: + identity = identities[device] + key = f"device:{identity}" if identity else f"transport:{device}" + groups.setdefault(key, []).append(device) + if len(groups) != 1: + detail = ", ".join( + f"{device}" + (f" (serial {identities[device]})" if identities[device] else "") + for device in devices + ) + raise DeviceResolutionError( + f"Multiple Android devices found ({detail}); set ADB_SERIAL or ANDROID_SERIAL" + ) + aliases = next(iter(groups.values())) + serial = min(aliases, key=_rank) + if log and len(aliases) > 1: + print(f"Collapsed {len(aliases)} ADB transports for one device.", file=sys.stderr) + + if log: + print(f"Resolved ADB serial: {serial}", file=sys.stderr) + return serial + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--adb", default=os.environ.get("ADB", "adb")) + parser.add_argument("--serial", help="Explicit ADB serial; defaults to ADB_SERIAL or ANDROID_SERIAL") + args = parser.parse_args() + try: + print(resolve_serial(args.adb, args.serial, log=True)) + return 0 + except DeviceResolutionError as exc: + print(f"adb-device: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/deploy_local.sh b/tools/deploy_local.sh index 9ee303db0..94b3692d4 100755 --- a/tools/deploy_local.sh +++ b/tools/deploy_local.sh @@ -15,22 +15,7 @@ if [[ ! -f "${apk}" ]]; then exit 1 fi -if [[ -n "${ADB_SERIAL:-}" ]]; then - serial=${ADB_SERIAL} -else - devices=$("${adb_bin}" devices | awk '$2 == "device" {print $1}') - device_count=$(printf '%s\n' "${devices}" | sed '/^$/d' | wc -l | tr -d ' ') - if [[ "${device_count}" == 0 ]]; then - echo "No authorized Android device found. Connect one or set ADB_SERIAL." >&2 - exit 2 - fi - if [[ "${device_count}" != 1 ]]; then - echo "Multiple Android devices found; set ADB_SERIAL explicitly." >&2 - printf '%s\n' "${devices}" >&2 - exit 2 - fi - serial=${devices} -fi +serial=$(python3 "${repo_root}/tools/adb_device.py" --adb "${adb_bin}") "${adb_bin}" -s "${serial}" install -r --no-incremental "${apk}" installed_path=$("${adb_bin}" -s "${serial}" shell pm path com.droiddeck.launcher | tr -d '\r') diff --git a/tools/droiddeckctl b/tools/droiddeckctl new file mode 100755 index 000000000..91eb9e561 --- /dev/null +++ b/tools/droiddeckctl @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Control a debug DroidDeck build over ADB.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + +from adb_device import DeviceResolutionError, resolve_serial + + +AUTHORITY = "content://com.droiddeck.launcher.agent" + + +class ControlError(RuntimeError): + def __init__(self, message: str, code: str = "COMMAND_FAILED") -> None: + super().__init__(message) + self.code = code + + +def adb_path(configured: str | None) -> str: + if configured: + return configured + found = shutil.which("adb") + if found: + return found + sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if sdk: + candidate = Path(sdk) / "platform-tools" / "adb" + if candidate.is_file(): + return str(candidate) + home_dir = Path.home() + candidate = home_dir / "Library" / "Android" / "sdk" / "platform-tools" / "adb" + if candidate.is_file(): + return str(candidate) + raise ControlError("adb not found; set ADB, ANDROID_HOME, or ANDROID_SDK_ROOT", "ADB_NOT_FOUND") + + +def run_adb(adb: str, serial: str, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[bytes]: + try: + return subprocess.run( + [adb, "-s", serial, *args], capture_output=True, check=False, timeout=timeout + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ControlError(str(exc), "ADB_FAILED") from exc + + +def _content_result(output: str) -> dict: + marker = "json=" + start = output.find(marker) + if start < 0: + detail = output.strip() or "The debug agent provider returned no result" + raise ControlError(detail, "INVALID_PROVIDER_RESPONSE") + raw = output[start + len(marker):].lstrip() + try: + response, _ = json.JSONDecoder().raw_decode(raw) + if not isinstance(response, dict): + raise ValueError("expected an object") + return response + except (json.JSONDecodeError, ValueError) as exc: + raise ControlError("Could not decode the agent provider response", "INVALID_PROVIDER_RESPONSE") from exc + + +def provider_call(adb: str, serial: str, method: str, arg: str | None = None, request: dict | None = None) -> dict: + command = ["shell", "content", "call", "--uri", AUTHORITY, "--method", method] + if arg is not None: + command.extend(["--arg", arg]) + if request is not None: + encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode() + command.extend(["--extra", f"request:s:{encoded}"]) + result = run_adb(adb, serial, *command) + output = (result.stdout + result.stderr).decode(errors="replace") + response = _content_result(output) + if result.returncode != 0 and response.get("ok") is not False: + raise ControlError(output.strip() or "ADB content call failed", "ADB_FAILED") + return response + + +def get_state(adb: str, serial: str) -> dict: + response = provider_call(adb, serial, "state") + if response.get("ok") is False: + error = response.get("error", {}) + raise ControlError(error.get("message", "state failed"), error.get("code", "STATE_FAILED")) + return response + + +def wait_for_phase(adb: str, serial: str, wanted: str, timeout: float) -> dict: + deadline = time.monotonic() + timeout + last_state: dict | None = None + while True: + last_state = get_state(adb, serial) + session = last_state.get("session", {}) + phase = session.get("phase") + if phase == wanted: + return last_state + if phase == "FAILED" and wanted != "FAILED": + failure = session.get("failure") or {} + raise ControlError( + f"Session failed ({failure.get('code', 'FAILED')}): {failure.get('message', 'unknown error')}", + failure.get("code", "SESSION_FAILED"), + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ControlError( + f"Timed out waiting for {wanted}; last phase was {phase}", "TIMEOUT" + ) + time.sleep(min(1.0, remaining)) + + +def wait_for_artifacts(adb: str, serial: str, state: dict, timeout: float) -> dict: + deadline = time.monotonic() + timeout + while True: + session = state.get("session", {}) + if session.get("artifactsComplete") or not session.get("artifactsAvailable"): + return state + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ControlError("Session stopped, but artifact collection did not complete", "ARTIFACT_TIMEOUT") + time.sleep(min(1.0, remaining)) + state = get_state(adb, serial) + + +def add_serial(response: dict, serial: str) -> dict: + response["deviceSerial"] = serial + return response + + +def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: + if args.command == "state": + return add_serial(get_state(adb, serial), serial) + + if args.command == "start": + current = get_state(adb, serial) + if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"): + raise ControlError("A session is already starting or running", "SESSION_ACTIVE") + request = {"mode": args.mode} + if args.mode == "steam": + if args.ui == "desktop": + request["steamUi"] = "desktop" + if args.url: + if not args.url.startswith("steam://"): + raise ControlError("Steam URL must start with steam://", "INVALID_STEAM_URL") + request["steamUrl"] = args.url + response = provider_call(adb, serial, "start", args.mode, request) + if response.get("ok") is False: + error = response.get("error", {}) + raise ControlError(error.get("message", "start failed"), error.get("code", "START_FAILED")) + if args.wait: + return add_serial(wait_for_phase(adb, serial, "READY", args.timeout), serial) + return add_serial(response, serial) + + if args.command == "run": + current = get_state(adb, serial) + if current["session"].get("phase") not in {"IDLE", "FAILED"} or current["session"].get("running"): + raise ControlError("A session is already starting or running", "SESSION_ACTIVE") + program_args = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments + request = {"mode": "run", "program": args.program, "programArgs": program_args} + response = provider_call(adb, serial, "start", "run", request) + if response.get("ok") is False: + error = response.get("error", {}) + raise ControlError(error.get("message", "run failed"), error.get("code", "START_FAILED")) + return add_serial(response, serial) + + if args.command == "wait": + phases = {"ready": "READY", "idle": "IDLE", "failed": "FAILED"} + return add_serial(wait_for_phase(adb, serial, phases[args.phase], args.timeout), serial) + + if args.command == "stop": + current = get_state(adb, serial) + if current["session"].get("phase") in {"IDLE", "FAILED"}: + return add_serial(wait_for_artifacts(adb, serial, current, args.timeout), serial) + response = provider_call(adb, serial, "stop") + if response.get("ok") is False: + error = response.get("error", {}) + raise ControlError(error.get("message", "stop failed"), error.get("code", "STOP_FAILED")) + state = wait_for_phase(adb, serial, "IDLE", args.timeout) + return add_serial(wait_for_artifacts(adb, serial, state, args.timeout), serial) + + if args.command == "resume": + response = provider_call(adb, serial, "resume") + if response.get("ok") is False: + error = response.get("error", {}) + raise ControlError(error.get("message", "resume failed"), error.get("code", "RESUME_FAILED")) + return add_serial(response, serial) + + if args.command == "logs": + state = get_state(adb, serial) + session = state.get("session", {}) + if not session.get("running") and session.get("artifactsAvailable") and not session.get("artifactsComplete"): + state = wait_for_artifacts(adb, serial, state, args.timeout) + session = state.get("session", {}) + remote_dir = session.get("logDir") + if not remote_dir or not session.get("id") or not session.get("artifactsAvailable"): + raise ControlError("No session artifact directory is available", "NO_SESSION_LOGS") + destination = Path(args.destination or session["id"]).expanduser().resolve() + result = run_adb(adb, serial, "pull", remote_dir, str(destination), timeout=120) + if result.returncode != 0: + detail = (result.stderr or result.stdout).decode(errors="replace").strip() + raise ControlError(detail or "adb pull failed", "LOG_PULL_FAILED") + return add_serial({"ok": True, "command": "logs", "sessionId": session["id"], "path": str(destination)}, serial) + + if args.command == "screenshot": + destination = Path(args.path).expanduser().resolve() + result = run_adb(adb, serial, "exec-out", "screencap", "-p", timeout=30) + if result.returncode != 0: + detail = result.stderr.decode(errors="replace").strip() + raise ControlError(detail or "screencap failed", "SCREENSHOT_FAILED") + try: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(result.stdout) + except OSError as exc: + raise ControlError(str(exc), "FILE_IO") from exc + return add_serial({"ok": True, "command": "screenshot", "path": str(destination)}, serial) + + raise ControlError(f"Unknown command: {args.command}", "UNKNOWN_COMMAND") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--adb", help="adb executable (defaults to ADB, PATH, or the Android SDK)") + parser.add_argument("--serial", help="ADB serial; defaults to ADB_SERIAL or ANDROID_SERIAL") + commands = parser.add_subparsers(dest="command", required=True) + + state = commands.add_parser("state", help="print machine-readable session state") + state.add_argument("--json", action="store_true", help="accepted for explicit machine-readable usage") + + start = commands.add_parser("start", help="start Steam or the desktop") + start.add_argument("mode", choices=["steam", "desktop"]) + start.add_argument("--ui", choices=["bigpicture", "desktop"], default="bigpicture") + start.add_argument("--url", help="steam:// URL to pass to the client") + start.add_argument("--wait", action="store_true", help="wait for READY after starting") + start.add_argument("--timeout", type=float, default=90) + + run = commands.add_parser("run", help="run a guest program under Gamescope") + run.add_argument("program") + run.add_argument("arguments", nargs=argparse.REMAINDER) + + wait = commands.add_parser("wait", help="wait for a session phase") + wait.add_argument("phase", choices=["ready", "idle", "failed"]) + wait.add_argument("--timeout", type=float, default=90) + + stop = commands.add_parser("stop", help="stop the session and wait for IDLE") + stop.add_argument("--timeout", type=float, default=90) + commands.add_parser("resume", help="resume a suspended session") + + logs = commands.add_parser("logs", help="pull the latest session artifact directory") + logs.add_argument("which", choices=["latest"]) + logs.add_argument("destination", nargs="?") + logs.add_argument("--timeout", type=float, default=90) + + screenshot = commands.add_parser("screenshot", help="save a device screenshot") + screenshot.add_argument("path") + return parser + + +def exit_code(exc: Exception) -> int: + if isinstance(exc, DeviceResolutionError): + return 3 + code = getattr(exc, "code", "") + if code in {"ADB_NOT_FOUND", "ADB_FAILED", "INVALID_PROVIDER_RESPONSE"}: + return 3 + if code in {"SESSION_ACTIVE", "NO_ACTIVE_SESSION", "SESSION_NOT_SUSPENDED", "SESSION_FAILED", + "GUEST_EXIT", "GUEST_START_FAILED", "RUNTIME_INSTALL_FAILED", "DESKTOP_INSTALL_FAILED", + "ACTIVITY_START_FAILED"}: + return 4 + if code in {"TIMEOUT", "ARTIFACT_TIMEOUT"}: + return 5 + if code in {"NO_SESSION_LOGS", "LOG_PULL_FAILED", "SCREENSHOT_FAILED", "FILE_IO"}: + return 6 + return 2 + + +def main() -> int: + args = build_parser().parse_args() + serial = None + try: + adb = adb_path(args.adb) + serial = resolve_serial(adb, args.serial, log=True) + result = execute(args, adb, serial) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + except (ControlError, DeviceResolutionError) as exc: + error = {"code": getattr(exc, "code", "DEVICE_RESOLUTION_FAILED"), "message": str(exc)} + response = {"ok": False, "error": error} + if serial: + response["deviceSerial"] = serial + print(json.dumps(response, indent=2, sort_keys=True)) + return exit_code(exc) + + +if __name__ == "__main__": + raise SystemExit(main()) From 1006688914db41a675e724acf264b834cf9be630 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 24 Sep 2026 22:15:47 -0400 Subject: [PATCH 2/2] fix(agent-bridge): launch sessions through debug activity --- README.md | 4 - app/src/debug/AndroidManifest.xml | 7 ++ .../launcher/agent/AgentBridgeProvider.kt | 111 +++--------------- .../launcher/agent/AgentStartActivity.kt | 99 ++++++++++++++++ .../com/droiddeck/launcher/SessionActivity.kt | 12 ++ .../launcher/session/SessionService.kt | 1 + docs/agent-control.md | 9 +- tools/droiddeckctl | 72 ++++++++++-- 8 files changed, 200 insertions(+), 115 deletions(-) create mode 100644 app/src/debug/java/com/droiddeck/launcher/agent/AgentStartActivity.kt diff --git a/README.md b/README.md index 8d61d04ea..a07db935c 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,6 @@ On Android 12+, if Steam exits without a log, turn off **Restrict child processe Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`. -## Agent control - -Debug APKs expose a shell-protected ADB bridge and `tools/droiddeckctl`. See [agent control](docs/agent-control.md) for setup and commands. - ## Limits Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems. diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index a7d685f38..70db082b2 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -2,6 +2,13 @@ + state(context).put("ok", true) - "start" -> start(context, arg.orEmpty(), extras) + "start" -> error("USE_DROIDDECKCTL", "Start sessions with tools/droiddeckctl so Android launches a visible Activity") "stop" -> stop(context) "resume" -> resume(context) else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'") @@ -112,72 +106,6 @@ class AgentBridgeProvider : ContentProvider() { ?.maxWithOrNull(compareBy { it.lastModified() }.thenBy { it.name }) } - private fun start(context: Context, requested: String, extras: Bundle?): JSONObject { - if (SessionState.running || SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) { - return error("SESSION_ACTIVE", "A session is already starting or running") - } - val request = request(extras) - val mode = request?.optString("mode")?.takeIf { it.isNotBlank() } ?: requested - val intent = Intent(context, SessionActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - val sessionMode: String - - when (mode) { - "steam" -> { - sessionMode = SessionService.MODE_STEAM - intent.putExtra(SessionService.EXTRA_MODE, sessionMode) - } - "desktop", SessionService.MODE_DESKTOP -> { - sessionMode = SessionService.MODE_DESKTOP - intent.putExtra(SessionService.EXTRA_MODE, sessionMode) - } - "run" -> { - sessionMode = SessionService.MODE_RUN - val program = request?.optString("program")?.takeIf { it.isNotBlank() } - ?: return error("PROGRAM_REQUIRED", "Run mode requires a program path") - val args = request?.optJSONArray("programArgs") ?: JSONArray() - val programArgs = Array(args.length()) { index -> - args.optString(index) - } - intent.putExtra(SessionService.EXTRA_MODE, sessionMode) - .putExtra(SessionService.EXTRA_PROGRAM, program) - .putExtra(SessionService.EXTRA_PROGRAM_ARGS, programArgs) - } - else -> return error("INVALID_MODE", "Mode must be steam, desktop, or run") - } - - request?.optString("steamUi")?.takeIf { it == "desktop" }?.let { - intent.putExtra(SessionService.EXTRA_STEAM_UI, it) - } - request?.optString("steamUrl")?.takeIf { it.startsWith("steam://") }?.let { - intent.putExtra(SessionService.EXTRA_STEAM_URL, it) - } - - awaitRecovery() - return try { - SessionEvents.begin(context, sessionMode) - SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM) - SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() - SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI) - SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL) - SessionEvents.record("agent.start_requested", mapOf("mode" to sessionMode)) - context.startActivity(intent) - JSONObject().put("ok", true).put("command", "start").put("mode", mode) - } catch (e: Exception) { - val message = e.message ?: e.javaClass.simpleName - SessionEvents.fail("ACTIVITY_START_FAILED", message) - val dir = SessionPaths.take() - if (dir != null) { - val appContext = context.applicationContext - Thread({ - SessionArtifacts.collect(appContext, dir, "activity start failed") - SessionPaths.release(appContext, dir) - }, "agent-start-failure-artifacts").start() - } - error("ACTIVITY_START_FAILED", message) - } - } - private fun stop(context: Context): JSONObject { if (SessionState.running) { SessionService.stop(context) @@ -202,29 +130,6 @@ class AgentBridgeProvider : ContentProvider() { return JSONObject().put("ok", true).put("command", "resume") } - private fun request(extras: Bundle?): JSONObject? { - val encoded = extras?.getString(REQUEST_EXTRA) ?: return null - return try { - val decoded = Base64.decode(encoded, Base64.DEFAULT) - JSONObject(String(decoded, Charsets.UTF_8)) - } catch (e: JSONException) { - throw IllegalArgumentException("Invalid request JSON", e) - } catch (e: IllegalArgumentException) { - throw IllegalArgumentException("Invalid base64 request", e) - } - } - - private fun awaitRecovery() { - try { - if (!recoveryComplete.await(90, TimeUnit.SECONDS)) { - throw IllegalStateException("Session artifact recovery did not finish") - } - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - throw IllegalStateException("Interrupted while recovering session artifacts", e) - } - } - private fun error(code: String, message: String) = JSONObject() .put("ok", false) .put("error", JSONObject().put("code", code).put("message", message)) @@ -237,7 +142,19 @@ class AgentBridgeProvider : ContentProvider() { companion object { const val RESULT_JSON = "json" - const val REQUEST_EXTRA = "request" + private val recoveryComplete = CountDownLatch(1) + + fun awaitRecovery() { + try { + if (!recoveryComplete.await(90, TimeUnit.SECONDS)) { + throw IllegalStateException("Session artifact recovery did not finish") + } + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw IllegalStateException("Interrupted while recovering session artifacts", e) + } + } + private val STARTING_PHASES = setOf( SessionPhase.PREPARING, SessionPhase.INSTALLING_RUNTIME, diff --git a/app/src/debug/java/com/droiddeck/launcher/agent/AgentStartActivity.kt b/app/src/debug/java/com/droiddeck/launcher/agent/AgentStartActivity.kt new file mode 100644 index 000000000..9e35ca46c --- /dev/null +++ b/app/src/debug/java/com/droiddeck/launcher/agent/AgentStartActivity.kt @@ -0,0 +1,99 @@ +package com.droiddeck.launcher.agent + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Base64 +import android.util.Log +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.SessionService +import org.json.JSONArray +import org.json.JSONObject + +/** Shell-launched trampoline: Android permits the shell to start this protected debug Activity. */ +class AgentStartActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val request = decodeRequest(intent.getStringExtra(EXTRA_REQUEST)) + if (request == null) { + finish() + return + } + + Thread({ + try { + AgentBridgeProvider.awaitRecovery() + runOnUiThread { + if (!isFinishing) startSession(request) + } + } catch (e: Exception) { + Log.e(TAG, "waiting for agent recovery", e) + runOnUiThread { finish() } + } + }, "agent-start-recovery").start() + } + + private fun decodeRequest(encoded: String?): JSONObject? { + if (encoded.isNullOrBlank()) { + Log.e(TAG, "missing agent start request") + return null + } + return try { + JSONObject(String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8)) + } catch (e: Exception) { + Log.e(TAG, "invalid agent start request", e) + null + } + } + + private fun startSession(request: JSONObject) { + val requestedMode = request.optString("mode") + val sessionMode = when (requestedMode) { + "steam" -> SessionService.MODE_STEAM + "desktop" -> SessionService.MODE_DESKTOP + "run" -> SessionService.MODE_RUN + else -> { + Log.e(TAG, "invalid agent session mode: $requestedMode") + finish() + return + } + } + + val program = if (sessionMode == SessionService.MODE_RUN) { + request.optString("program").takeIf { it.isNotBlank() } + } else { + null + } + if (sessionMode == SessionService.MODE_RUN && program == null) { + Log.e(TAG, "run mode requires a program path") + finish() + return + } + + val sessionIntent = Intent(this, SessionActivity::class.java).apply { + action = SessionService.ACTION_AGENT_START + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + putExtra(SessionService.EXTRA_MODE, sessionMode) + if (program != null) { + val args = request.optJSONArray("programArgs") ?: JSONArray() + putExtra(SessionService.EXTRA_PROGRAM, program) + putExtra(SessionService.EXTRA_PROGRAM_ARGS, Array(args.length()) { index -> args.optString(index) }) + } + if (sessionMode == SessionService.MODE_STEAM) { + request.optString("steamUi").takeIf { it == "desktop" }?.let { + putExtra(SessionService.EXTRA_STEAM_UI, it) + } + request.optString("steamUrl").takeIf { it.startsWith("steam://") }?.let { + putExtra(SessionService.EXTRA_STEAM_URL, it) + } + } + } + startActivity(sessionIntent) + finish() + } + + companion object { + const val EXTRA_REQUEST = "request" + private const val TAG = "AgentStartActivity" + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index f3a2bf9a6..103e5e393 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -170,6 +170,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI) SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL) + if (intent.action == SessionService.ACTION_AGENT_START) { + SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode)) + } window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // Game-tier power policy for the whole session: a sustained clock floor, the panel's // fastest mode, and the OS told it is in gameplay. Logged so a slow device says why. @@ -1192,6 +1195,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) + if (intent.action == SessionService.ACTION_AGENT_START) { + if (SessionState.running || SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) { + Log.w(TAG, "ignoring agent start while session phase is ${SessionState.phase}") + return + } + setIntent(intent) + recreate() + return + } setIntent(intent) if (intent.action == SessionService.ACTION_HOME_GUIDE) { handleHomeGuideIntent(intent) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 6b9f4f216..47bf4c22e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -1011,6 +1011,7 @@ class SessionService : Service() { const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION" const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION" const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE" + const val ACTION_AGENT_START = "com.droiddeck.launcher.AGENT_START" private const val ACTION_ACTIVITY_VISIBLE = "com.droiddeck.launcher.ACTIVITY_VISIBLE" private const val ACTION_ACTIVITY_HIDDEN = "com.droiddeck.launcher.ACTIVITY_HIDDEN" private const val ACTION_TRACK_AUXILIARY = "com.droiddeck.launcher.TRACK_AUXILIARY" diff --git a/docs/agent-control.md b/docs/agent-control.md index 06735c2c8..77e4ce583 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -1,6 +1,6 @@ # Agent control -The debug APK exposes a ContentProvider at `content://com.droiddeck.launcher.agent`. Android's shell-only `DUMP` permission protects it. The provider and its authority are absent from release APKs. +The debug APK exposes a ContentProvider at `content://com.droiddeck.launcher.agent` and an Activity for starting sessions. Android's shell-only `DUMP` permission protects both. They are absent from release APKs. Build and install the debug APK: @@ -9,14 +9,15 @@ Build and install the debug APK: adb install -r app/build/outputs/apk/debug/app-debug.apk ``` -The provider can also be called directly: +The provider can also be called directly for state, stop, and resume: ```sh adb shell content call --uri content://com.droiddeck.launcher.agent --method state -adb shell content call --uri content://com.droiddeck.launcher.agent --method start --arg steam adb shell content call --uri content://com.droiddeck.launcher.agent --method stop ``` +Start requests go through `tools/droiddeckctl`, which launches the protected debug Activity from the ADB shell. Calling the provider's `start` method directly returns an error because a background provider cannot reliably open the session screen. + The host CLI resolves one authorized device using `ADB_SERIAL` or `ANDROID_SERIAL` when set. Otherwise it deduplicates transports that report the same device serial and asks for an explicit serial if multiple devices remain. ```sh @@ -31,7 +32,7 @@ tools/droiddeckctl logs latest ./session-artifacts tools/droiddeckctl screenshot ./screen.png ``` -Every command writes JSON to stdout and reports its resolved ADB serial to stderr. Exit codes are 0 for success, 2 for invalid or rejected commands, 3 for ADB/device errors, 4 for session failures, 5 for timeouts, and 6 for artifact or file errors. Set `ADB` to select an adb executable or pass `--adb`; pass `--serial` before the command to select a device directly. +Every command writes JSON to stdout and reports its resolved ADB serial to stderr. Exit codes are 0 for success, 2 for invalid or rejected commands, 3 for ADB/device errors, 4 for session failures, 5 for timeouts, and 6 for artifact or file errors. Set `ADB` to select an adb executable (or pass `--adb`); pass `--serial` before the command to select a device directly. `start` accepts `steam` or `desktop`. Steam starts in Big Picture by default; `--ui desktop` selects the client's desktop UI, and `--url steam://...` passes a client URL. Use `--wait` to wait for `READY` as part of `start`. `run` accepts a program path and optional guest arguments. diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 91eb9e561..b73c5ac29 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -28,6 +28,9 @@ class ControlError(RuntimeError): def adb_path(configured: str | None) -> str: if configured: return configured + environment_adb = os.environ.get("ADB") + if environment_adb: + return environment_adb found = shutil.which("adb") if found: return found @@ -83,6 +86,45 @@ def provider_call(adb: str, serial: str, method: str, arg: str | None = None, re return response +def start_agent_activity(adb: str, serial: str, request: dict) -> None: + encoded = base64.b64encode(json.dumps(request, separators=(",", ":")).encode()).decode() + result = run_adb( + adb, + serial, + "shell", + "am", + "start", + "-W", + "-n", + "com.droiddeck.launcher/.agent.AgentStartActivity", + "--es", + "request", + encoded, + timeout=90, + ) + output = (result.stdout + result.stderr).decode(errors="replace") + if result.returncode != 0 or "status: ok" not in output.lower(): + raise ControlError(output.strip() or "The agent start Activity did not launch", "ACTIVITY_START_FAILED") + + +def confirm_start(adb: str, serial: str, request: dict, previous: dict) -> dict: + deadline = time.monotonic() + 95 + expected_mode = {"steam": "steam", "desktop": "lxqt", "run": "run"}[request["mode"]] + while True: + state = get_state(adb, serial) + session = state.get("session", {}) + phase = session.get("phase") + if session.get("mode") == expected_mode and session.get("id") != previous.get("id"): + return state + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ControlError( + f"The agent Activity launched but no new session appeared; phase is {phase}", + "ACTIVITY_START_FAILED", + ) + time.sleep(min(0.25, remaining)) + + def get_state(adb: str, serial: str) -> dict: response = provider_call(adb, serial, "state") if response.get("ok") is False: @@ -148,13 +190,18 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: if not args.url.startswith("steam://"): raise ControlError("Steam URL must start with steam://", "INVALID_STEAM_URL") request["steamUrl"] = args.url - response = provider_call(adb, serial, "start", args.mode, request) - if response.get("ok") is False: - error = response.get("error", {}) - raise ControlError(error.get("message", "start failed"), error.get("code", "START_FAILED")) + start_agent_activity(adb, serial, request) + started = confirm_start(adb, serial, request, current["session"]) if args.wait: return add_serial(wait_for_phase(adb, serial, "READY", args.timeout), serial) - return add_serial(response, serial) + session = started["session"] + return add_serial({ + "ok": True, + "command": "start", + "mode": args.mode, + "sessionId": session.get("id"), + "phase": session.get("phase"), + }, serial) if args.command == "run": current = get_state(adb, serial) @@ -162,11 +209,16 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: raise ControlError("A session is already starting or running", "SESSION_ACTIVE") program_args = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments request = {"mode": "run", "program": args.program, "programArgs": program_args} - response = provider_call(adb, serial, "start", "run", request) - if response.get("ok") is False: - error = response.get("error", {}) - raise ControlError(error.get("message", "run failed"), error.get("code", "START_FAILED")) - return add_serial(response, serial) + start_agent_activity(adb, serial, request) + started = confirm_start(adb, serial, request, current["session"]) + session = started["session"] + return add_serial({ + "ok": True, + "command": "start", + "mode": "run", + "sessionId": session.get("id"), + "phase": session.get("phase"), + }, serial) if args.command == "wait": phases = {"ready": "READY", "idle": "IDLE", "failed": "FAILED"}