fix(agent-bridge): launch sessions through debug activity

This commit is contained in:
xXJSONDeruloXx committed 2026-09-24 22:16:28 -04:00
1 parent c2d6d272a3
commit a419c8097f
8 files changed
+200 -115

No files matched your search

-4
View File
@@ -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.
+7
View File
@@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug builds only. Android grants DUMP to adb shell; release APKs have no bridge. -->
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@style/SessionTheme" />
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
@@ -4,16 +4,13 @@ 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
@@ -23,7 +20,6 @@ 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
@@ -31,8 +27,6 @@ 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({
@@ -56,7 +50,7 @@ class AgentBridgeProvider : ContentProvider() {
val response = try {
when (method) {
"state" -> 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<File> { 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,
@@ -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"
}
}
@@ -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)
@@ -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"
+5 -4
View File
@@ -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.
+62 -10
View File
@@ -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"}