Merge pull request #157 from Droid-Deck/game-launcher-shortcuts

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-03 12:04:11 -04:00
commit f3600fa618
14 files changed
+474 -29

No files matched your search

+10
View File
@@ -61,6 +61,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="droiddeck" android:host="game" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CREATE_SHORTCUT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
@@ -375,13 +375,75 @@ class MainActivity : ComponentActivity() {
overridePendingTransition(0, 0)
}
private var shortcutPicker by mutableStateOf(false)
private var shortcutLibraryScanning by mutableStateOf(false)
@Volatile private var libraryScanGeneration = 0
private var pendingGameLink: String? = null
private fun readGameIntent(request: Intent) {
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
if (shortcutPicker) {
// The shortcut picker must only offer games from the scan started for this request.
steamGames = emptyList()
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = if (request.action == Intent.ACTION_VIEW)
com.droiddeck.launcher.frontend.GameLaunchLink.parse(request.dataString) else null
if (request.action == Intent.ACTION_VIEW && pendingGameLink == null) {
android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show()
}
}
private fun launchGame(game: Library.SteamGame): Boolean {
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (SessionState.running) {
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
return true
}
if (!SessionService.launchGame(this, game.gameIdString)) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
return startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
}
private fun handleGameLink(games: List<Library.SteamGame>) {
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
val id = pendingGameLink ?: return
val game = games.firstOrNull { it.gameIdString == id }
if (game == null) {
pendingGameLink = null
intent.data = null
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game)) {
pendingGameLink = null
intent.data = null
}
}
private fun chooseGameShortcut(game: Library.SteamGame) {
setResult(RESULT_OK, com.droiddeck.launcher.frontend.GameShortcuts.result(this, game))
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
readGameIntent(intent)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = com.droiddeck.launcher.frontend.LibraryCache.load(this)
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
storeEnabled = SessionPrefs.storeEnabled(this)
@@ -403,6 +465,8 @@ class MainActivity : ComponentActivity() {
FrontEndScreen(
FrontEndState(
installed = installed, ready = ready, available = available?.version,
shortcutPicker = shortcutPicker,
shortcutLibraryScanning = shortcutLibraryScanning,
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
@@ -442,9 +506,12 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
onSteamGame = { g ->
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}"), steamSession = true)
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onCopyGameLink = { g ->
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText(g.name, com.droiddeck.launcher.frontend.GameLaunchLink.uri(g.gameIdString)))
android.widget.Toast.makeText(this, R.string.game_link_copied, android.widget.Toast.LENGTH_SHORT).show()
},
onDesktop = {
startSession(Intent(this, SessionActivity::class.java)
@@ -678,10 +745,13 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
readGameIntent(intent)
refresh()
startSteamAtStartupIfEnabled()
}
private fun startSteamAtStartupIfEnabled() {
if (intent.action != Intent.ACTION_MAIN) return
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
@@ -1271,6 +1341,8 @@ class MainActivity : ComponentActivity() {
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// One update with the whole list: the wall places games by their position in it, so a
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
@@ -1287,7 +1359,14 @@ class MainActivity : ComponentActivity() {
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
ui.post { steamGames = all; emulatorList = emus }
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
emulatorList = emus
shortcutLibraryScanning = false
handleGameLink(all)
}
}
// Box art for the games that have none, fetched after the list is up; the list is
// rebuilt once if any was found.
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
@@ -1317,15 +1396,16 @@ class MainActivity : ComponentActivity() {
}
/** Starts a session; with no runtime on a non-Adreno, the same warning Setup gives comes first, before any download. */
private fun startSession(intent: Intent, steamSession: Boolean = false) {
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
refreshPhantomStatus()
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
showPhantomGate = true
return
return false
}
val warn = installed == null && !com.droiddeck.launcher.core.DeviceSupport.adreno()
if (warn && available != null) showNonAdreno = available
else startActivity(intent)
if (warn && available != null) { showNonAdreno = available; return false }
startActivity(intent)
return true
}
private fun refreshPhantomStatus() {
@@ -268,10 +268,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
}
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)
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
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)
}
if (intent.action == SessionService.ACTION_AGENT_START) {
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
}
@@ -0,0 +1,13 @@
package com.droiddeck.launcher.frontend
/** External callers can select a game, never a guest path or arbitrary Steam command. */
object GameLaunchLink {
private val idPattern = Regex("[1-9][0-9]{0,19}")
private val linkPattern = Regex("droiddeck://game/([1-9][0-9]{0,19})")
fun validId(id: String): Boolean = idPattern.matches(id) && id.toULongOrNull() != null
fun parse(link: String?): String? = link?.let { linkPattern.matchEntire(it)?.groupValues?.get(1) }
?.takeIf(::validId)
fun uri(id: String): String { require(validId(id)); return "droiddeck://game/$id" }
fun steamUrl(id: String): String { require(validId(id)); return "steam://rungameid/$id" }
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.frontend
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
object GameShortcuts {
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
Uri.parse(GameLaunchLink.uri(gameId)), context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = game.art?.takeIf { it.isFile }?.let { file -> runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
fun pin(activity: Activity, game: Library.SteamGame) {
val manager = activity.getSystemService(ShortcutManager::class.java)
val requested = runCatching { manager.isRequestPinShortcutSupported &&
manager.requestPinShortcut(info(activity, game), null) }.getOrDefault(false)
if (!requested) Toast.makeText(activity, R.string.game_shortcut_unsupported, Toast.LENGTH_LONG).show()
}
/** Standard shortcut picker result, understood by launchers that import Android shortcuts. */
fun result(activity: Activity, game: Library.SteamGame): Intent =
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
}
@@ -23,7 +23,10 @@ object Library {
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
const val ADDED = "added"
@@ -33,6 +33,7 @@ import com.droiddeck.launcher.core.NetworkReport
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.frontend.GameLaunchLink
import com.droiddeck.launcher.input.FakeInputWriter
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
import com.droiddeck.launcher.runtime.LinuxRuntime
@@ -78,6 +79,12 @@ class SessionService : Service() {
private var steamSleepToken: String? = null
private var suspendOperationPending = false
private var suspendAttemptFailed = false
/** One validated game request waiting for this Steam session to reach a usable state. */
private var pendingSteamGameId: String? = null
private var pendingSteamGameGeneration = -1
private val flushSteamGame = object : Runnable {
override fun run() = flushQueuedSteamGame()
}
private var screenReceiverRegistered = false
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -107,6 +114,10 @@ class SessionService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_LAUNCH_GAME -> {
queueSteamGame(intent.getStringExtra(EXTRA_GAME_ID))
return START_NOT_STICKY
}
ACTION_TRACK_AUXILIARY -> {
val pid = intent.getIntExtra(EXTRA_AUXILIARY_PID, -1)
val started = if (pid > 1) readStat(pid)?.second else null
@@ -161,12 +172,7 @@ class SessionService : Service() {
return START_NOT_STICKY
}
ACTION_RESUME -> {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
completeSteamSleep()
suspendAttemptFailed = false
updateSuspendPolicy()
resumeSession()
return START_NOT_STICKY
}
}
@@ -207,6 +213,8 @@ class SessionService : Service() {
// replaces can report its own exit in the gap between the two, and that exit must not
// be taken as this one's.
val gen = ++sessionGen
cancelQueuedSteamGame(clearMailbox = false)
clearSteamGameMailbox()
acquireLocks()
Thread({
// A tree the last session left behind (the app was killed or crashed, so its teardown
@@ -884,6 +892,79 @@ class SessionService : Service() {
return !File("/proc/$pid").exists()
}
private fun queueSteamGame(gameId: String?) {
if (!GameLaunchLink.validId(gameId.orEmpty()) || !SessionState.running ||
SessionState.mode != MODE_STEAM || SessionState.stopRequested) {
Log.w(TAG, "ignored game launch outside a running Steam session")
return
}
val accepted = gameId!!
if (pendingSteamGameId != null) Log.i(TAG, "replacing a queued game launch with $accepted")
pendingSteamGameId = accepted
pendingSteamGameGeneration = sessionGen
if (SessionState.suspended) resumeSession()
mainHandler.removeCallbacks(flushSteamGame)
mainHandler.post(flushSteamGame)
}
private fun flushQueuedSteamGame() {
val gameId = pendingSteamGameId ?: return
val gen = pendingSteamGameGeneration
if (gen != sessionGen || !SessionState.running || SessionState.stopRequested ||
SessionState.mode != MODE_STEAM || !GameLaunchLink.validId(gameId)) {
cancelQueuedSteamGame()
return
}
if (SessionState.suspended) {
resumeSession()
mainHandler.postDelayed(flushSteamGame, GAME_LAUNCH_RETRY_MS)
return
}
if (SessionState.phase != SessionPhase.READY) {
mainHandler.postDelayed(flushSteamGame, GAME_LAUNCH_RETRY_MS)
return
}
val directory = LinuxRuntime.sessionRoot(this)
val staged = File(directory, STEAM_GAME_REQUEST + ".tmp")
val request = File(directory, STEAM_GAME_REQUEST)
val written = runCatching {
directory.mkdirs()
staged.writeText("$gameId\n")
check(staged.renameTo(request)) { "could not publish Steam game request" }
}.onFailure { Log.w(TAG, "could not queue Steam game $gameId", it) }.isSuccess
if (!written) {
mainHandler.postDelayed(flushSteamGame, GAME_LAUNCH_RETRY_MS)
return
}
pendingSteamGameId = null
pendingSteamGameGeneration = -1
SessionEvents.record("steam.game_launch_requested", mapOf("gameId" to gameId, "generation" to gen))
Log.i(TAG, "queued Steam game $gameId for the running client")
}
private fun resumeSession() {
activityVisible = true
screenOn = (getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: screenOn
manualPauseRequested = false
completeSteamSleep()
suspendAttemptFailed = false
updateSuspendPolicy()
}
private fun cancelQueuedSteamGame(clearMailbox: Boolean = true) {
mainHandler.removeCallbacks(flushSteamGame)
pendingSteamGameId = null
pendingSteamGameGeneration = -1
if (clearMailbox) clearSteamGameMailbox()
}
private fun clearSteamGameMailbox() {
val directory = LinuxRuntime.sessionRoot(this)
File(directory, STEAM_GAME_REQUEST).delete()
File(directory, STEAM_GAME_REQUEST + ".tmp").delete()
}
/**
* proot's --kill-on-exit takes its tracees down, but a session that died from the inside
* (the client asserting, Xwayland going) leaves gamescopereaper and the session script
@@ -1119,6 +1200,7 @@ class SessionService : Service() {
}
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
@@ -1325,6 +1407,7 @@ class SessionService : Service() {
private const val NOTIFICATION_ID = 1001
const val ACTION_STOP = "com.droiddeck.launcher.STOP_SESSION"
const val ACTION_RESUME = "com.droiddeck.launcher.RESUME_SESSION"
const val ACTION_LAUNCH_GAME = "com.droiddeck.launcher.LAUNCH_STEAM_GAME"
const val ACTION_HOME_GUIDE = "com.droiddeck.launcher.HOME_GUIDE"
const val ACTION_AGENT_START = "com.droiddeck.launcher.AGENT_START"
private const val ACTION_SUSPEND_POLICY_CHANGED = "com.droiddeck.launcher.SUSPEND_POLICY_CHANGED"
@@ -1341,6 +1424,8 @@ class SessionService : Service() {
private const val GRACE_MS = 1200L
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val GAME_LAUNCH_RETRY_MS = 250L
private const val STEAM_GAME_REQUEST = "steam-game"
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
@@ -1359,6 +1444,7 @@ class SessionService : Service() {
* MODE_DESKTOP: "desktop" = open Steam's desktop client in the desktop once it is up. */
const val EXTRA_STEAM_UI = "steamUi"
const val EXTRA_STEAM_URL = "steamUrl"
const val EXTRA_GAME_ID = "gameId"
fun start(
context: Context, mode: String = MODE_STEAM, program: String? = null,
@@ -1392,6 +1478,20 @@ class SessionService : Service() {
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
}
/** Route a validated decimal Steam game id through the current guest session. */
fun launchGame(context: Context, gameId: String): Boolean {
if (!GameLaunchLink.validId(gameId) || !SessionState.running ||
SessionState.mode != MODE_STEAM || SessionState.stopRequested) return false
return runCatching {
context.startService(
Intent(context, SessionService::class.java)
.setAction(ACTION_LAUNCH_GAME)
.putExtra(EXTRA_GAME_ID, gameId),
)
true
}.getOrDefault(false)
}
fun suspendPolicyChanged(context: Context) {
if (!SessionState.running) return
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_SUSPEND_POLICY_CHANGED))
@@ -69,8 +69,8 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
if (current == null) {
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) { PageHeader(stringResource(R.string.content_games)) }
Rise(1) { Note(stringResource(R.string.games_empty)) }
Rise(2) {
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
if (!s.shortcutPicker) Rise(2) {
Actions { PrimaryButton(stringResource(R.string.games_play_steam), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow, modifier = Modifier.padding(top = 12.dp), onClick = a.onPlay) }
}
}
@@ -116,7 +116,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
@Composable
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions) {
Actions {
PrimaryButton(stringResource(R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { a.onSteamGame(g) }
g.gameFiles?.takeIf { it.isDirectory }?.let { dir ->
SecondaryButton(stringResource(R.string.games_files), compact = true) { a.onBrowseFiles(dir) }
}
@@ -125,10 +125,27 @@ private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActio
// Only games added to the library; Steam titles keep their saves with Steam Cloud.
if (g.library == Library.ADDED) ManageSaves(g, dir, a)
}
if (!s.shortcutPicker) GameShortcutMenu(g, a)
BusyChip(s)
}
}
@Composable
private fun GameShortcutMenu(g: Library.SteamGame, a: FrontEndActions) {
var open by remember(g.gameId) { androidx.compose.runtime.mutableStateOf(false) }
Box {
SecondaryButton(stringResource(R.string.game_shortcut), compact = true) { open = !open }
AnchoredMenu(open, onDismiss = { open = false }, title = stringResource(R.string.game_shortcut)) { first ->
MenuItem(stringResource(R.string.game_shortcut_add), checked = false, focusRequester = first) {
open = false; a.onGameShortcut(g)
}
MenuItem(stringResource(R.string.game_link_copy), checked = false) {
open = false; a.onCopyGameLink(g)
}
}
}
}
/**
* Manage saves: import a GameHub or Winlator save zip into this game's prefix, export its saves as
* either, or open the save folder found for it. The saves are looked for when the menu opens.
@@ -96,6 +96,8 @@ class FrontEndState(
val running: String?,
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
val lossless: Lossless.State = Lossless.State.NONE,
val shortcutPicker: Boolean = false,
val shortcutLibraryScanning: Boolean = false,
val pageKey: String? = null,
val theme: String = Themes.GRAPHITE,
val isHomeApp: Boolean = false,
@@ -126,6 +128,8 @@ class FrontEndActions(
val onPlay: () -> Unit,
val onPlayDesktopUi: () -> Unit,
val onSteamGame: (Library.SteamGame) -> Unit,
val onGameShortcut: (Library.SteamGame) -> Unit = {},
val onCopyGameLink: (Library.SteamGame) -> Unit = {},
val onDesktop: () -> Unit,
val onEmulator: (Library.Emulator) -> Unit,
/** A Flatpak app by id and name, from the Store. */
@@ -331,7 +335,8 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () -
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Composable () -> Unit)?, frontFocus: FrontFocus) {
var selected by rememberSaveable { mutableStateOf("steam") }
var selected by rememberSaveable { mutableStateOf(if (s.shortcutPicker) "games" else "steam") }
LaunchedEffect(s.shortcutPicker) { if (s.shortcutPicker) selected = "games" }
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) }
var wirelessAdbDesiredEnabled by rememberSaveable { mutableStateOf(false) }
+11
View File
@@ -909,4 +909,15 @@
<string name="game_env_change_title">Change variable</string>
<string name="game_env_restore_default">Back to default</string>
<string name="game_env_eyebrow">Proton games</string>
<string name="game_shortcut">Shortcut</string>
<string name="game_shortcut_add">Add to home screen</string>
<string name="game_shortcut_choose">Choose game</string>
<string name="game_shortcut_scanning">Scanning installed games…</string>
<string name="game_link_copy">Copy launch link</string>
<string name="game_link_copied">Launch link copied</string>
<string name="game_shortcut_unsupported">This launcher cannot add shortcuts. Use Copy launch link instead.</string>
<string name="game_link_invalid">Invalid game launch link.</string>
<string name="game_link_missing">This game is no longer in your DroidDeck library.</string>
<string name="game_link_session_busy">Stop the current session before launching a Steam game.</string>
<string name="game_link_failed">Could not send the game to Steam. Try launching it again.</string>
</resources>
@@ -0,0 +1,83 @@
package com.droiddeck.launcher.frontend
import android.content.Intent
import android.net.Uri
import android.app.Activity
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.frontend.Library
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
class GameLaunchLinkTest {
@Test fun acceptsCanonicalUnsignedDecimalIdsIncludingFullWidthShortcuts() {
val ids = listOf("1", "17410", "9223372036854775808", "18446744073709551615")
ids.forEach { id ->
assertTrue(GameLaunchLink.validId(id))
assertEquals(id, GameLaunchLink.parse(GameLaunchLink.uri(id)))
assertEquals("steam://rungameid/$id", GameLaunchLink.steamUrl(id))
}
}
@Test fun rejectsAnythingOtherThanOneCanonicalGamePath() {
listOf(
null, "", "droiddeck://game/0", "droiddeck://game/01", "droiddeck://game/+1",
"droiddeck://game/-1", "droiddeck://game/18446744073709551616",
"droiddeck://game/1?url=steam://rungameid/2", "droiddeck://game/1/../2",
"droiddeck://games/1", "droiddeck://game/1/", "DROIDDECK://game/1",
"droiddeck://game:4/1", "file:///game/1", "steam://rungameid/1",
).forEach { assertNull("should reject $it", GameLaunchLink.parse(it)) }
}
@Test fun convertsSignedStorageRepresentationToUnsignedShortcutUri() {
assertEquals("18446744073709551615", Library.SteamGame(1, "Added", null, Library.ADDED, -1L).gameIdString)
assertEquals("9223372036854775808", Library.SteamGame(1, "Added", null, Library.ADDED, Long.MIN_VALUE).gameIdString)
assertEquals("droiddeck://game/18446744073709551615", GameLaunchLink.uri(Library.SteamGame(1, "Added", null, Library.ADDED, -1L).gameIdString))
}
}
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GameShortcutIntentTest {
@Test fun shortcutLaunchIsExplicitAndReturnsThroughTheExistingMainActivity() {
val context = RuntimeEnvironment.getApplication()
val id = "18446744073709551615"
val intent = GameShortcuts.launchIntent(context, id)
assertEquals(Intent.ACTION_VIEW, intent.action)
assertEquals(Uri.parse("droiddeck://game/$id"), intent.data)
assertEquals(MainActivity::class.java.name, intent.component?.className)
assertEquals(android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP, intent.flags)
}
@Test fun pinnedShortcutHasStableIdAndCanonicalExplicitLaunchIntent() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)
GameShortcuts.pin(activity, game)
val shortcut = activity.getSystemService(android.content.pm.ShortcutManager::class.java)
.pinnedShortcuts.single()
val launch = requireNotNull(shortcut.intent)
assertEquals("game:8400", shortcut.id)
assertEquals("Geometry Wars: Retro Evolved", shortcut.shortLabel.toString())
assertEquals(MainActivity::class.java.name, shortcut.activity?.className)
assertEquals(Intent.ACTION_VIEW, launch.action)
assertEquals(Uri.parse("droiddeck://game/8400"), launch.data)
assertEquals(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, launch.flags)
}
@Test fun actionCreateShortcutUsesAndroidsShortcutResultIntentApi() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val game = Library.SteamGame(8400, "Geometry Wars: Retro Evolved", null, "steam", 8400L)
assertNotNull(GameShortcuts.result(activity, game))
}
}
+49
View File
@@ -0,0 +1,49 @@
# Game launcher shortcuts
DroidDeck exposes installed Steam titles and AddedGames through a canonical launch link:
```text
droiddeck://game/<unsigned-decimal-game-id>
```
The ID is the value accepted by `steam://rungameid/`, including an AddedGames shortcut ID that occupies the unsigned 64-bit range. The app accepts only the exact URI form and only after a fresh library scan finds that game. It does not accept paths, Steam URLs, extra parameters, leading zeroes, or IDs outside the unsigned 64-bit range.
Each game page's **Shortcut** menu offers **Add to home screen** through Android's pinned-shortcut request and **Copy launch link**. When a launcher invokes `ACTION_CREATE_SHORTCUT`, DroidDeck opens the Games page and returns Android's standard shortcut result after the user chooses a game.
With the debug app installed, exercise link routing with an installed game ID from the current library:
```sh
adb shell am start -n com.droiddeck.launcher/.MainActivity \
-a android.intent.action.VIEW -d 'droiddeck://game/620'
```
Open the standard shortcut picker with:
```sh
adb shell am start -n com.droiddeck.launcher/.MainActivity \
-a android.intent.action.CREATE_SHORTCUT
```
A cold link starts the normal Steam session with `steam://rungameid/<id>` as its startup URL. A warm link is accepted only during a running Steam session. The service waits for READY, resumes a suspended session, then writes a decimal-only request for the existing Steam client to consume. Desktop and program sessions remain active and report that the game cannot be launched until the current session ends.
Build a full local debug APK, including the native/audio bundle, with:
```sh
DROIDDECK_BUILD_VARIANT=debug tools/build_local.sh
```
The focused JVM suite is `./gradlew app:testDebugUnitTest`. See the task report and session logs when validating cold launch, warm launch, resume, queueing, and rejection paths on a device.
## Thor acceptance run
Validated on the AYN Thor (Android 13 / API 33, serial `adb-d234a848-fv2FDl (2)._adb-tls-connect._tcp`). The full debug APK at `app/build/outputs/apk/debug/app-debug.apk` installed with `adb install -r --no-incremental`, without uninstalling or clearing app data.
- Pinned **Geometry Wars: Retro Evolved** from its game page, confirmed `game:8400` in Android's pinned shortcut database, and tapped that actual home-screen icon. The cold session reached READY with first frame; `Download/DroidDeck/2026-10-02-27-steam/session.log` records `steam://rungameid/8400`, Steam game ID 8400, `GeometryWars.exe`, and Gamescope focus on app 8400.
- Copied the link from the game menu. Android's clipboard preview showed `droiddeck://game/8400`. An ACTION_VIEW of that URI reused session `2026-10-02-27-steam`, guest PID 14740, and gamescope PID 14762; its event log recorded `steam.game_launch_requested` without a new compositor or guest process.
- Sent another game link while the generic Steam session was in `STARTING_STEAM`. Session `2026-10-02-28-steam` reached READY before recording the queued launch, and its guest log says it launched game 8400 through the running client.
- Pressing Home suspended the warm Steam session. Tapping the same pinned shortcut resumed session `2026-10-02-27-steam` with guest PID 14740 and dispatched the game request after `session.resumed`.
- Leading-zero, overflow-with-query, and unknown-ID links left the agent IDLE with no guest. A MODE_RUN session using `/usr/bin/sleep 300` remained the same session and program after opening the game link; no Steam launch was requested.
- ACTION_CREATE_SHORTCUT opened the Games picker on a fresh scan; choosing the title finished the picker. A shell-started activity has no caller to inspect the returned payload, so the end-to-end result extras were not directly captured on-device. Robolectric covers the result API call and shortcut intent metadata.
- The existing Game files and Proton prefix controls still opened Geometry Wars' installed folder and `compatdata/8400/pfx` in the file manager.
The device logs remain under `/sdcard/Download/DroidDeck/2026-10-02-27-steam` and `.../2026-10-02-28-steam`. Screenshots from the run are in `/tmp/game-launcher-thor-*.png`, `/tmp/files.png`, and `/tmp/prefix.png`. The only shortcut added for this run was `game:8400`; it was removed after validation.
+10 -4
View File
@@ -14,6 +14,12 @@ fi
sdk_dir=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-"${HOME}/Library/Android/sdk"}}
java_dir=${JAVA_HOME:-"/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"}
image_name=${DROIDDECK_BUILD_IMAGE:-droiddeck-local-cross:24.04-v2}
build_variant=${DROIDDECK_BUILD_VARIANT:-release}
case "$build_variant" in
debug) gradle_task=assembleDebug ;;
release) gradle_task=assembleRelease ;;
*) echo "DROIDDECK_BUILD_VARIANT must be debug or release" >&2; exit 1 ;;
esac
if [[ ! -x "${sdk_dir}/platform-tools/adb" ]]; then
echo "Android SDK not found at ${sdk_dir}; set ANDROID_HOME or ANDROID_SDK_ROOT." >&2
@@ -285,11 +291,11 @@ bundle_replaced=1
mv "${staging_dir}/pulseaudio.tzst" "${bundle_asset}"
cd "${repo_root}"
./gradlew assembleRelease --console=plain -PndkVersion="${ndk_version}"
./gradlew "${gradle_task}" --console=plain -PndkVersion="${ndk_version}"
cp -p "${bundle_backup}" "${bundle_asset}"
bundle_replaced=0
apk="${repo_root}/app/build/outputs/apk/release/app-release.apk"
apk="${repo_root}/app/build/outputs/apk/${build_variant}/app-${build_variant}.apk"
audio_check="${staging_dir}/audio-check"
mkdir -p "${audio_check}"
unzip -p "${apk}" assets/pulseaudio.tzst | zstd -dc | tar -xf - -C "${audio_check}"
@@ -303,10 +309,10 @@ for audio_file in \
fi
done
docker run --rm --platform linux/amd64 -v "${repo_root}:/src:ro" -w /src "${image_name}" \
docker run --rm --platform linux/amd64 -e build_variant="${build_variant}" -v "${repo_root}:/src:ro" -w /src "${image_name}" \
bash -lc '
set -euo pipefail
apk=app/build/outputs/apk/release/app-release.apk
apk=app/build/outputs/apk/${build_variant}/app-${build_variant}.apk
work=$(mktemp -d)
unzip -q "$apk" "lib/arm64-v8a/*" -d "$work"
cd "$work/lib/arm64-v8a"
@@ -704,7 +704,7 @@ BUSCONF
steam_updates=0
rm -f "$HOME/.bl-stop-requested"
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -d "$BL_LAUNCH_DIR" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop"
rm -f "$BL_LAUNCH_DIR/steam-stop" "$BL_LAUNCH_DIR/steam-game" "$BL_LAUNCH_DIR/steam-game.tmp"
(
while :; do
if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then
@@ -714,6 +714,24 @@ BUSCONF
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
exit 0
fi
if [ -f "$BL_LAUNCH_DIR/steam-game" ]; then
game_id=$(cat "$BL_LAUNCH_DIR/steam-game" 2>/dev/null)
rm -f "$BL_LAUNCH_DIR/steam-game"
case $game_id in
''|0*|*[!0-9]*) echo "== steam: ignored invalid game launch request" ;;
*)
game_id_length=${#game_id}
if [ "$game_id_length" -lt 20 ] || {
[ "$game_id_length" -eq 20 ] && [[ "$game_id" < "18446744073709551616" ]];
}; then
echo "== steam: launching requested game $game_id in the running client"
"$steam_root/steamrtarm64/steam" "steam://rungameid/$game_id" >/dev/null 2>&1 &
else
echo "== steam: ignored out-of-range game launch request"
fi
;;
esac
fi
nap 0.5
done
) &