Use plain-ID .steam files for frontend exports (#277)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-05 13:48:18 -04:00
1 parent 46cdc32cf1
commit d9bb5e15b0
7 files changed
+217 -31

No files matched your search

+4
View File
@@ -71,6 +71,10 @@
<action android:name="android.intent.action.CREATE_SHORTCUT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
@@ -65,6 +65,9 @@ import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
import com.droiddeck.launcher.frontend.CoverArt
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.frontend.GameFiles
import com.droiddeck.launcher.frontend.GameFileSync
import com.droiddeck.launcher.frontend.GameLaunchIntent
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.RomsDialog
import com.droiddeck.launcher.files.InAppFilePicker
@@ -399,10 +402,15 @@ class MainActivity : ComponentActivity() {
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
saveAction(getString(R.string.game_frontend_files)) {
if (game != null) com.droiddeck.launcher.frontend.GameFiles.export(folder, game)
else com.droiddeck.launcher.frontend.GameFileSync.enable(this, folder)
ui.post { gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this) }
getString(R.string.game_file_exported, folder.path)
val saved = if (game != null) GameFiles.export(folder, game)
else {
GameFileSync.enable(this, folder)
folder
}
ui.post {
gameSyncFolder = GameFileSync.folder(this)
}
getString(R.string.game_file_exported, saved.path)
}
}
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
@@ -416,10 +424,10 @@ class MainActivity : ComponentActivity() {
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = null
if (request.action == Intent.ACTION_VIEW) {
if (GameLaunchIntent.accepts(request.action)) {
val copy = Intent(request)
Thread({
val id = com.droiddeck.launcher.frontend.GameFiles.readIntent(this, copy)
val id = GameLaunchIntent.read(this, copy)
ui.post {
if (intent === request && !isDestroyed) {
pendingGameLink = id
@@ -462,10 +470,14 @@ class MainActivity : ComponentActivity() {
if (game == null) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
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
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
}
}
@@ -477,7 +489,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
readGameIntent(intent)
gameSyncFolder = com.droiddeck.launcher.frontend.GameFileSync.folder(this)
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
@@ -556,7 +568,7 @@ class MainActivity : ComponentActivity() {
onSyncGameFiles = { pickGameExport(null) },
onStopGameFileSync = {
Thread({
com.droiddeck.launcher.frontend.GameFileSync.disable(this)
GameFileSync.disable(this)
ui.post { gameSyncFolder = null }
}, "game-file-stop-sync").start()
},
@@ -12,7 +12,7 @@ import java.io.InputStream
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
object GameFiles {
const val EXTENSION = "droiddeck"
const val EXTENSION = "steam"
private const val MAX_BYTES = 128
private const val INVENTORY = ".droiddeck-sync.json"
private const val FORMAT = "droiddeck-game-files-v1"
@@ -46,35 +46,49 @@ object GameFiles {
}
if (count > MAX_BYTES) return null
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
return GameLaunchLink.parse(text) ?: text.takeIf(GameLaunchLink::validId)
return text.takeIf(GameLaunchLink::validId)
}
fun filename(game: Library.SteamGame): String {
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
.trim().trim('.').ifBlank { "Game" }
val points = sanitized.codePoints().limit(40).toArray()
val title = String(points, 0, points.size)
return "$title (${game.gameIdString}).$EXTENSION"
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
return "$title$suffix.$EXTENSION"
}
private fun contents(id: String) = GameLaunchLink.uri(id) + "\n"
private fun contents(id: String): String {
require(GameLaunchLink.validId(id))
return id
}
@Synchronized fun export(folder: File, game: Library.SteamGame): File {
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
exportNamed(folder, game, filename(game))
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
require(folder.isDirectory) { "Folder is unavailable" }
val target = child(folder, filename(game))
val text = contents(game.gameIdString)
val target = exportTarget(folder, game, name)
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
if (!target.exists()) write(target, text)
exportArt(folder, game)
return target
}
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
val target = child(folder, name)
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
child(folder, filename(game, disambiguate = true))
} else target
}
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
private fun exportArt(folder: File, game: Library.SteamGame) {
val art = listOfNotNull(game.icon, game.art).firstOrNull {
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
} ?: return
val title = filename(game).substringBeforeLast(" (${game.gameIdString})")
val title = filename(game).substringBeforeLast(".")
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
@@ -95,12 +109,20 @@ object GameFiles {
record.getJSONObject("games")
} else JSONObject()
val next = JSONObject()
val exports = games.distinctBy { it.gameId }.map { game ->
val target = child(folder, filename(game))
val unique = games.distinctBy { it.gameId }
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
val exports = unique.map { game ->
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
val preferred = child(folder, name)
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
else exportTarget(folder, game, name)
val text = contents(game.gameIdString)
val existed = target.exists()
// Keep an edited export without letting it block the rest of the library sync.
val file = if (previous.optString(target.name) == game.gameIdString && target.exists() &&
!matches(target, contents(game.gameIdString))) target else export(folder, game)
next.put(file.name, game.gameIdString)
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
val file = if (owned && target.exists() &&
!matches(target, text)) target else exportNamed(folder, game, target.name)
if (!existed || owned) next.put(file.name, game.gameIdString)
file
}
// Write new exports before pruning; a failed write must not remove old launch files.
@@ -119,6 +141,16 @@ object GameFiles {
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
}
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
if (record.optString(file.name) == id) return true
if (!file.exists()) return false
return record.keys().asSequence().any { name ->
record.optString(name) == id && child(folder, name).let { old ->
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
}
}
}
private fun child(folder: File, name: String): File {
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
val file = File(folder, name)
@@ -0,0 +1,32 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.content.Intent
object GameLaunchIntent {
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
const val EXTRA_APP_ID = "app_id"
const val EXTRA_GAME_SOURCE = "game_source"
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
fun read(context: Context, intent: Intent): String? = when (intent.action) {
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
ACTION, GAMENATIVE_ACTION -> readId(intent)
else -> null
}
@Suppress("DEPRECATION")
internal fun readId(intent: Intent): String? = runCatching {
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
is Int -> value.takeIf { it > 0 }?.toString()
is String -> value.takeIf(GameLaunchLink::validId)
else -> null
}
id
}.getOrNull()
}
@@ -27,6 +27,7 @@ class GameFileSyncTest {
}
val folder = File(context.filesDir, "exports").apply { mkdirs() }
GameFileSync.enable(context, folder)
assertEquals("8400", File(folder, "Geometry Wars.steam").readText())
assertEquals(folder.path, GameFileSync.folder(context))
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
// An unavailable source must leave the existing export intact.
@@ -35,6 +36,8 @@ class GameFileSyncTest {
assertThrows(IllegalStateException::class.java) { GameFileSync.syncConfigured(context) }
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
assertTrue(unavailable.renameTo(steamapps))
GameFileSync.syncConfigured(context)
assertTrue(File(folder, "Geometry Wars.steam").exists())
// A genuine uninstall removes the owned export at the next tick.
assertTrue(manifest.delete())
GameFileSync.syncConfigured(context)
@@ -44,6 +47,7 @@ class GameFileSyncTest {
manifest.writeText("\"name\" \"Geometry Wars\"\n\"StateFlags\" \"4\"")
GameFileSync.syncConfigured(context)
assertNull(GameFileSync.folder(context))
assertFalse(File(folder, "Geometry Wars.steam").exists())
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
}
}
@@ -28,8 +28,8 @@ class GameFilesTest {
@Test fun exportsPortableFilesAndAcceptsFileUriRawPathAndContentUri() {
val folder = tmp.newFolder()
val file = GameFiles.export(folder, game("18446744073709551615", "A/B: C"))
assertEquals("A_B_ C (18446744073709551615).droiddeck", file.name)
assertEquals("droiddeck://game/18446744073709551615\n", file.readText())
assertEquals("A_B_ C.steam", file.name)
assertEquals("18446744073709551615", file.readText())
val context = RuntimeEnvironment.getApplication()
listOf(Uri.fromFile(file), Uri.parse(file.path)).forEach { uri ->
assertEquals("18446744073709551615", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, uri)))
@@ -44,15 +44,15 @@ class GameFilesTest {
@Test fun rejectsCommandsMalformedIdsLargeFilesAndUnsupportedIntents() {
listOf("", "0", "01", "-1", "18446744073709551616", "steam://rungameid/8400",
"8400\n8401", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
"8400\n8401", "droiddeck://game/8400", "droiddeck://game/8400?command=quit", "x".repeat(129)).forEach { value ->
assertNull(value, GameFiles.read(ByteArrayInputStream(value.toByteArray())))
}
assertEquals("8400", GameFiles.read(ByteArrayInputStream("8400\n".toByteArray())))
val context = RuntimeEnvironment.getApplication()
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_SEND, Uri.parse("droiddeck://game/8400"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.droiddeck"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.droiddeck"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.droiddeck")))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com/8400.steam"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("file://remote/game.steam"))))
assertNull(GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.fromFile(File(tmp.root, "missing.steam")))))
assertEquals("8400", GameFiles.readIntent(context, Intent(Intent.ACTION_VIEW, Uri.parse("droiddeck://game/8400"))))
}
@@ -71,7 +71,8 @@ class GameFilesTest {
assertEquals("custom artwork", image.readText())
GameFiles.sync(folder, emptyList())
assertEquals("custom artwork", image.readText())
assertEquals(0, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
assertEquals("8400", File(folder, GameFiles.filename(game)).readText())
assertEquals(1, folder.listFiles { f -> f.extension == GameFiles.EXTENSION }!!.size)
icon.delete()
val fallback = tmp.newFolder()
GameFiles.export(fallback, game)
@@ -80,7 +81,7 @@ class GameFilesTest {
@Test fun syncAddsRenamesAndRemovesOwnedFilesAndPreservesUnrelatedAndEditedFiles() {
val folder = tmp.newFolder()
val unrelated = File(folder, "manual.droiddeck").apply { writeText("droiddeck://game/1\n") }
val unrelated = File(folder, "manual.steam").apply { writeText("1") }
val note = File(folder, "notes.txt").apply { writeText("Keep me") }
val first = game()
val second = game("620", "Portal 2")
@@ -110,10 +111,12 @@ class GameFilesTest {
GameFiles.sync(folder, listOf(first))
val second = game("620", "Portal 2")
val collision = File(folder, GameFiles.filename(second)).apply { writeText("personal file") }
val fallback = File(folder, GameFiles.filename(second, disambiguate = true)).apply { writeText("another personal file") }
assertThrows(IllegalArgumentException::class.java) { GameFiles.sync(folder, listOf(second)) }
assertEquals("personal file", collision.readText())
assertTrue(File(folder, GameFiles.filename(first)).isFile)
collision.delete()
fallback.delete()
GameFiles.sync(folder, listOf(second))
assertFalse(File(folder, GameFiles.filename(first)).exists())
assertTrue(collision.isFile)
@@ -132,10 +135,10 @@ class GameFilesTest {
@Test fun refusesSymlinksLeavingTheExportFolder() {
val folder = tmp.newFolder()
val outside = tmp.newFile()
outside.writeText("droiddeck://game/8400\n")
outside.writeText("8400")
java.nio.file.Files.createSymbolicLink(File(folder, GameFiles.filename(game())).toPath(), outside.toPath())
assertThrows(IllegalArgumentException::class.java) { GameFiles.export(folder, game()) }
assertEquals("droiddeck://game/8400\n", outside.readText())
assertEquals("8400", outside.readText())
}
@Test fun caseOnlyRenameKeepsTheLiveExportOnCaseInsensitiveStorage() {
@@ -148,6 +151,8 @@ class GameFilesTest {
assertTrue(file.isFile)
assertEquals("8400", file.inputStream().use(GameFiles::read))
assertEquals(1, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
GameFiles.sync(folder, emptyList())
assertFalse(file.exists())
}
@Test fun unrelatedInventoryIsPreserved() {
@@ -158,6 +163,52 @@ class GameFilesTest {
assertEquals(0, folder.listFiles()!!.count { it.extension == GameFiles.EXTENSION })
}
@Test fun steamExportContainsOnlyIdForSteamAndAddedGames() {
val folder = tmp.newFolder()
val file = GameFiles.export(folder, game("620", "Portal 2"))
assertEquals("Portal 2.steam", file.name)
assertEquals("620", file.readText())
assertEquals("620", GameFiles.readIntent(RuntimeEnvironment.getApplication(), Intent(Intent.ACTION_VIEW, Uri.fromFile(file))))
val added = Library.SteamGame(1, "Added Game", null, Library.ADDED, -1L)
val addedFile = GameFiles.export(folder, added)
assertEquals("Added Game.steam", addedFile.name)
assertEquals("18446744073709551615", addedFile.readText())
}
@Test fun steamSyncDisambiguatesTitlesAndPreservesEditedAndForeignFiles() {
val folder = tmp.newFolder()
val first = game("620", "A/B")
val second = game("8400", "A:B")
val foreign = File(folder, "Existing.steam").apply { writeText("1") }
val existing = game("1", "Existing")
GameFiles.sync(folder, listOf(first, second, existing))
val owned = File(folder, "A_B (620).steam")
assertEquals("620", owned.readText())
assertEquals("8400", File(folder, "A_B (8400).steam").readText())
owned.writeText("user edit")
GameFiles.sync(folder, listOf(first, second, existing))
assertEquals("user edit", owned.readText())
GameFiles.sync(folder, emptyList())
assertEquals("user edit", owned.readText())
assertEquals("1", foreign.readText())
assertFalse(File(folder, "A_B (8400).steam").exists())
}
@Test fun steamCollisionFallbackDoesNotClaimAnExistingFile() {
val folder = tmp.newFolder()
val collision = File(folder, "Geometry Wars.steam").apply { writeText("other file") }
val existing = File(folder, "Geometry Wars (8400).steam").apply { writeText("8400") }
GameFiles.sync(folder, listOf(game()))
GameFiles.sync(folder, emptyList())
assertEquals("other file", collision.readText())
assertEquals("8400", existing.readText())
existing.writeText("another file")
assertThrows(IllegalArgumentException::class.java) {
GameFiles.sync(folder, listOf(game()))
}
assertEquals("another file", existing.readText())
}
private class LaunchFileProvider(private val file: File) : ContentProvider() {
override fun onCreate() = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.frontend
import android.content.Intent
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class GameLaunchIntentTest {
@Test fun acceptsNativeAndExplicitlyRetargetedGameNativeProfiles() {
listOf(GameLaunchIntent.ACTION, "app.gamenative.LAUNCH_GAME").forEach { action ->
val request = Intent(action).putExtra("app_id", 620).putExtra("game_source", "STEAM")
assertEquals("620", GameLaunchIntent.read(RuntimeEnvironment.getApplication(), request))
assertEquals("8400", GameLaunchIntent.readId(Intent(action).putExtra("app_id", "8400")))
assertEquals("620", GameLaunchIntent.readId(request.putExtra("game_source", "steam")))
}
}
@Test fun stringIdsPreserveUnsignedShortcutIds() {
listOf("2147483648", "18446744073709551615").forEach { id ->
assertEquals(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
}
}
@Test fun rejectsUnsupportedSourcesTypesAndIdsWithoutFallingBackToData() {
listOf("", "0", "01", "+1", "-1", "18446744073709551616", "620\n", "steam://rungameid/620").forEach { id ->
assertNull(id, GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", id)))
}
listOf("EPIC", "GOG", "CUSTOM_GAME", "", "STEAM ").forEach { source ->
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", source)))
}
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620L)))
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", -1)))
assertNull(GameLaunchIntent.readId(Intent(GameLaunchIntent.ACTION).putExtra("app_id", 620).putExtra("game_source", 1)))
assertNull(GameLaunchIntent.readId(Intent(Intent.ACTION_MAIN).putExtra("app_id", 620)))
assertNull(GameLaunchIntent.read(RuntimeEnvironment.getApplication(), Intent(GameLaunchIntent.ACTION,
android.net.Uri.parse("droiddeck://game/620"))))
}
@Test fun deepLinksStillAcceptUnsignedShortcutIds() {
val context = RuntimeEnvironment.getApplication()
assertEquals("18446744073709551615", GameLaunchIntent.read(context,
Intent(Intent.ACTION_VIEW, android.net.Uri.parse("droiddeck://game/18446744073709551615"))))
assertFalse(GameLaunchIntent.accepts(Intent.ACTION_SEND))
assertTrue(GameLaunchIntent.accepts(GameLaunchIntent.ACTION))
}
}