mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
Merge pull request #157 from Droid-Deck/game-launcher-shortcuts
This commit is contained in:
14 files changed
+474
-29
No files matched your search
@@ -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) }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
) &
|
||||
|
||||
Reference in new issue
Block a user