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"}