From b68bb054a901aa4a705de9d24587dcd20a46d4bd Mon Sep 17 00:00:00 2001 From: Moroni Granja Date: Wed, 21 Oct 2026 23:43:51 -0300 Subject: [PATCH] fix: Xbox search results open with their metadata and Game Pass installs (#1279) --- .../runtime/LinuxNetworkLinkComponent.kt | 10 ++++-- .../droiddeck/launcher/stores/StoreCatalog.kt | 15 ++++++++ .../droiddeck/launcher/stores/StoresState.kt | 4 +++ .../launcher/stores/xbox/XboxCatalog.kt | 15 ++++++-- .../droiddeck/launcher/stores/xbox/Xodus.kt | 17 +++++++-- .../com/droiddeck/launcher/ui/StoresDetail.kt | 31 +++++++++++----- .../launcher/stores/DetailCatalogItemTest.kt | 29 +++++++++++++++ .../launcher/stores/xbox/XboxCatalogTest.kt | 35 ++++++++++++++++++- docs/development/xbox-store.md | 6 ++++ 9 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/DetailCatalogItemTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt index 3bf43fc34..ac87bd0aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -104,7 +104,6 @@ class LinuxNetworkLinkComponent( synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) } } } - wifiReceiver = receiver appContext.registerReceiver(receiver, IntentFilter().apply { addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) addAction(WifiManager.RSSI_CHANGED_ACTION) @@ -112,6 +111,7 @@ class LinuxNetworkLinkComponent( addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION) addAction(LocationManager.MODE_CHANGED_ACTION) }) + wifiReceiver = receiver scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified() scanHandler.post(scanRequests) requestWifiScan() @@ -119,7 +119,13 @@ class LinuxNetworkLinkComponent( override fun stop() { scanHandler.removeCallbacks(scanRequests) - wifiReceiver?.let { appContext.unregisterReceiver(it) } + wifiReceiver?.let { receiver -> + try { + appContext.unregisterReceiver(receiver) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Wi-Fi receiver was already gone", e) + } + } wifiReceiver = null val registered = synchronized(lock) { callback.also { callback = null } } ?: return try { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt index 6f4678c7f..80e091dab 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreCatalog.kt @@ -22,8 +22,11 @@ data class CatalogItem( val extra: Map = emptyMap(), val mature: Boolean = false, val subscription: Boolean = false, + val includedWith: String = "", ) { val accessible: Boolean get() = owned || subscription + /** Licensed now, or a supported package the account's subscription can install. */ + val installable: Boolean get() = accessible || includedWith.isNotBlank() val isDiscounted: Boolean get() = discountPercent > 0 && originalPrice.isNotBlank() val key: String get() = "${store.id}:$id" } @@ -86,6 +89,18 @@ internal fun retainCatalogOrder(items: List, previous: List, + found: List, + shelves: StoreShelves?, +): CatalogItem? = + library.firstOrNull { it.store == store && it.id == id } + ?: found.firstOrNull { it.store == store && it.id == id } + ?: shelves?.all?.firstOrNull { it.store == store && it.id == id } + fun mergeItems(old: List?, new: List): List { if (old.isNullOrEmpty()) return new val before = old.associateBy { it.key } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt index dbeb94e23..b7b20e434 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt @@ -128,6 +128,10 @@ object StoresState { fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id } + fun detailItem(store: Store, id: String): CatalogItem? = detailCatalogItem( + store, id, library[store].orEmpty(), found[store].orEmpty(), shelves[store], + ) + fun open(context: Context, store: Store, force: Boolean = false) { val backend = backends[store] ?: return val app = context.applicationContext diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt index fa7a6b5fa..100371b06 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt @@ -10,6 +10,14 @@ import java.time.Instant internal object XboxCatalog { private val productId = Regex("[A-Z0-9]{12}") private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD") + /** Plans that include PC games; the retired console-only plan must not offer a Windows install. */ + private val pcPlans = mapOf( + "CFQ7TTC0KGQ8" to "PC Game Pass", + "CFQ7TTC0KHS0" to "Game Pass Ultimate", + "CFQ7TTC0P85B" to "Xbox Game Pass Premium", + "CFQ7TTC0K5DJ" to "Xbox Game Pass Essential", + "CFQ7TTC10QFD" to "Xbox Game Pass (Starter Edition)", + ) fun access(json: JSONObject, account: String, market: String, now: Instant = Instant.now(), report: (String) -> Unit = {}): Map { require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market) @@ -53,7 +61,7 @@ internal object XboxCatalog { val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" } if (parent == null) return if (id in plans) null else "owned" if (parent in blocked) return null - if (parent in plans) return if (activePlan(parent)) "gamepass" else null + if (parent in plans) return if (parent in pcPlans && activePlan(parent)) "gamepass" else null return kind(parent, visited + id) } val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap() @@ -110,9 +118,12 @@ internal object XboxCatalog { } } val kind = access[id] + val includedWith = objects(localized.optJSONObject("EligibilityProperties")?.optJSONArray("Affirmations")) + .mapNotNull { pcPlans[it.optString("AffirmationProductId")] } + .firstOrNull().orEmpty() return CatalogItem(Store.XBOX, id, title, image("SuperHeroArt", "Hero", "BoxArt"), tallImageUrl = image("TitledPoster", "Poster", "BoxArt"), - owned = kind == "owned", subscription = kind == "gamepass", + owned = kind == "owned", subscription = kind == "gamepass", includedWith = includedWith, storeUrl = "https://www.xbox.com/games/store/$id", publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"), description = localized.optString("ProductDescription"), diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt index 8eb237d02..6e7c30df8 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/Xodus.kt @@ -3,6 +3,7 @@ package com.droiddeck.launcher.stores.xbox import android.content.Context import android.os.Environment import com.droiddeck.launcher.core.AddonPack +import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.SessionPrefs import org.json.JSONObject @@ -14,7 +15,8 @@ internal object Xodus { const val DIR = "/usr/local/lib/droiddeck-xbox" fun market(): String = java.util.Locale.getDefault().country.uppercase().takeIf { it.matches(Regex("[A-Z]{2}")) } ?: "US" - class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null) : Closeable { + class Child(val process: Process, private val credentials: XboxCredentials, private val runtimeDirectory: File? = null, private val launchDirectory: File? = null, + private val networkLink: LinuxNetworkLinkComponent) : Closeable { @Synchronized fun send(frame: JSONObject) { process.outputStream.write((frame.toString() + "\n").toByteArray()) process.outputStream.flush() @@ -34,6 +36,7 @@ internal object Xodus { } } } finally { + networkLink.stop() runtimeDirectory?.let { directory -> File(directory, "xodus.sock").delete() directory.delete() @@ -69,8 +72,12 @@ internal object Xodus { val pack = stage(app) val binary = "$DIR/${if (service) "xodus-service" else "xodus-cli"}" check(File(LinuxRuntime.rootDir(app), binary.removePrefix("/")).isFile) { "The Xbox backend is missing. Update the Linux runtime." } + val networkLink = LinuxNetworkLinkComponent(app, LinuxRuntime.rootDir(app)) val credentials = XboxCredentials(app, credentialGeneration) try { + networkLink.attach(app) + networkLink.publish() + networkLink.start() val runtime = (runtimeDirectory ?: File(app.filesDir, "xbox-runtime")).apply { mkdirs() } val command = LinuxRuntime.command(app, null, null, Environment.getExternalStorageDirectory(), listOf( "/usr/bin/env", "HOME=/root", "XODUS_LOG=off", "RUST_LOG=off", @@ -92,8 +99,12 @@ internal object Xodus { } val process = builder.start() credentials.bind(process, File(LinuxRuntime.rootDir(app), binary.removePrefix("/")), digest.digest()) - return Child(process, credentials, runtimeDirectory, launchDirectory) - } catch (e: Exception) { credentials.close(); throw e } + return Child(process, credentials, runtimeDirectory, launchDirectory, networkLink) + } catch (e: Exception) { + runCatching { networkLink.stop() } + credentials.close() + throw e + } } fun run(app: Context, args: List, timeout: Long = 90, credentialGeneration: String? = null): JSONObject { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index 3baa0054a..a00a5a46e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.droiddeck.launcher.R -import com.droiddeck.launcher.stores.CatalogItem import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreAchievements import com.droiddeck.launcher.stores.StoresState @@ -68,7 +67,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro val narrow = LocalNarrowPane.current val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700 val id = key.substringAfter(':') - val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } + val item = StoresState.detailItem(store, id) val installed = StoresState.installedGame(store, id) val rawDownload = StoresState.download("${store.id}:$id") val download = rawDownload?.takeIf { it.isActive } @@ -93,11 +92,14 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp)) } Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) { + val passName = item?.includedWith.orEmpty() val state = when { installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder) download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location - item?.subscription == true -> "PC Game Pass" + item?.owned == true -> stringResource(R.string.stores_eyebrow_library) + item?.subscription == true -> passName.ifBlank { "PC Game Pass" } + passName.isNotBlank() -> "Available with $passName" item?.accessible == true -> stringResource(R.string.stores_eyebrow_library) else -> stringResource(R.string.stores_eyebrow_store) } @@ -153,14 +155,25 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key) } } - item?.accessible == true && StoresState.isUnfinished(item) -> { + item?.installable == true && StoresState.isUnfinished(item) -> { InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) } ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) } } - item?.accessible == true -> InstallButton( - if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), - leadOn, - ) { StoresState.requestInstall(ctx, item) } + item?.installable == true -> { + InstallButton( + if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), + leadOn, + ) { StoresState.requestInstall(ctx, item) } + if (!item.accessible) { + SecondaryButton( + when { + item.isFree -> stringResource(R.string.stores_get_free) + item.hasPrice && item.finalPrice.isNotBlank() -> stringResource(R.string.stores_buy_price, item.finalPrice) + else -> stringResource(R.string.stores_view_on, store.shortLabel) + }, + ) { openStoreUrl(ctx, item) } + } + } item != null && item.isFree -> PrimaryButton(stringResource(R.string.stores_get_free), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } item != null -> PrimaryButton(if (item.hasPrice && item.finalPrice.isNotBlank()) stringResource(R.string.stores_buy_price, item.finalPrice) else stringResource(R.string.stores_view_on, store.shortLabel), main = true, modifier = leadOn) { openStoreUrl(ctx, item) } } @@ -171,7 +184,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro } if (StoresMotion.heroLands) hero() else Rise(1) { hero() } if (scores != null) Rise(2) { - val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id } + val listed = (StoresState.found[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id } val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty() val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty() val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty() diff --git a/app/src/test/java/com/droiddeck/launcher/stores/DetailCatalogItemTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/DetailCatalogItemTest.kt new file mode 100644 index 000000000..dc5512171 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/DetailCatalogItemTest.kt @@ -0,0 +1,29 @@ +package com.droiddeck.launcher.stores + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DetailCatalogItemTest { + private fun item(store: Store, id: String, title: String) = CatalogItem(store, id, title, imageUrl = null) + private val shelves = StoreShelves(trending = listOf(item(Store.XBOX, "9SHELF000001", "Shelf game"))) + + @Test fun aSearchOnlyResultKeepsItsMetadataOnTheDetailPage() { + val found = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver")) + assertEquals("Echo Weaver", detailCatalogItem(Store.XBOX, "9SEARCH00001", emptyList(), found, shelves)?.title) + } + + @Test fun theLibraryWinsOverSearchAndShelvesForTheSameProduct() { + val library = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver").copy(owned = true, sizeBytes = 42)) + val found = listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver").copy(owned = false)) + val resolved = detailCatalogItem(Store.XBOX, "9SEARCH00001", library, found, shelves)!! + assertEquals(true, resolved.owned) + assertEquals(42L, resolved.sizeBytes) + } + + @Test fun shelvesRemainAFallbackAndTheItemStaysInItsStore() { + assertEquals("Shelf game", detailCatalogItem(Store.XBOX, "9SHELF000001", emptyList(), emptyList(), shelves)?.title) + assertNull(detailCatalogItem(Store.GOG, "9SEARCH00001", emptyList(), listOf(item(Store.XBOX, "9SEARCH00001", "Echo Weaver")), shelves)) + assertNull(detailCatalogItem(Store.XBOX, "9UNKNOWN0001", emptyList(), emptyList(), shelves)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt index c5e6927e7..c4f95c091 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt @@ -19,7 +19,7 @@ class XboxCatalogTest { private fun envelope(vararg rows: JSONObject) = JSONObject().put("schema", 1).put("account", "test@example.test") .put("market", "US").put("checkedAt", now.toString()).put("entitlements", JSONArray(rows.toList())) private fun access(vararg rows: JSONObject) = XboxCatalog.access(envelope(*rows), "test@example.test", "US", now) - private fun membership() = JSONObject().put("productId", plan).put("status", "Active").put("endDate", "2099-01-01T00:00:00Z") + private fun membership(id: String = plan) = JSONObject().put("productId", id).put("status", "Active").put("endDate", "2099-01-01T00:00:00Z") private fun withMembership(json: JSONObject, vararg plans: JSONObject) = json.put("subscriptions", JSONArray(plans.toList())) @Test fun separateMembershipRestoresSubscriptionChildrenWithoutInventingOwnership() { @@ -83,6 +83,16 @@ class XboxCatalogTest { assertEquals(mapOf(game to "gamepass", parent to "gamepass"), access(row(game, parent), row(parent, plan), row(plan))) } + @Test fun onlyPcPlansGrantSubscriptionAccess() { + for (pc in listOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC10QFD")) { + assertEquals(mapOf(game to "gamepass"), access(row(game, pc), row(pc))) + } + val console = "CFQ7TTC0K6L8" + assertTrue(access(row(game, console), row(console)).isEmpty()) + assertTrue(XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console)), + "test@example.test", "US", now).isEmpty()) + } + @Test fun backendRfc3339OffsetsAndNanosecondsAreAccepted() { val checked = "2026-10-19T19:02:38.173609086+00:00" val response = envelope(row(game).put("endDate", "2099-01-01T01:00:00.000000001+01:00")) @@ -120,6 +130,29 @@ class XboxCatalogTest { assertFalse(card.owned); assertTrue(card.subscription) } + @Test fun pcSubscriptionAffirmationsOfferAnInstallWithoutClaimingOwnership() { + fun offer(vararg affirmations: String) = JSONObject().put("ProductId", game).put("ProductType", "Game") + .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A Pass game") + .put("EligibilityProperties", JSONObject().put("Affirmations", JSONArray(affirmations.map { + JSONObject().put("AffirmationProductId", it) + }))))) + .put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject() + .put("Packages", JSONArray().put(JSONObject().put("PackageFormat", "MSIXVC").put("PlatformDependencies", + JSONArray().put(JSONObject().put("PlatformName", "Windows.Desktop"))))))))) + + val pc = XboxCatalog.product(offer("CFQ7TTC0KGQ8"), emptyMap())!! + assertEquals("PC Game Pass", pc.includedWith) + assertFalse(pc.accessible); assertTrue(pc.installable) + + assertEquals("Game Pass Ultimate", XboxCatalog.product(offer("CFQ7TTC0KHS0"), emptyMap())!!.includedWith) + assertEquals("Xbox Game Pass Premium", XboxCatalog.product(offer("CFQ7TTC0P85B"), emptyMap())!!.includedWith) + assertEquals("Xbox Game Pass Essential", XboxCatalog.product(offer("CFQ7TTC0K5DJ"), emptyMap())!!.includedWith) + assertEquals("Xbox Game Pass (Starter Edition)", XboxCatalog.product(offer("CFQ7TTC10QFD"), emptyMap())!!.includedWith) + val console = XboxCatalog.product(offer("CFQ7TTC0K6L8"), emptyMap())!! + assertEquals("", console.includedWith); assertFalse(console.installable) + assertFalse(XboxCatalog.product(offer(), emptyMap())!!.installable) + } + @Test fun consoleAndUwpOffersAreFilteredOut() { fun product(platform: String, format: String) = JSONObject().put("ProductId", game).put("ProductType", "Game") .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A PC game"))) diff --git a/docs/development/xbox-store.md b/docs/development/xbox-store.md index 47ccb769e..bc4aecf66 100644 --- a/docs/development/xbox-store.md +++ b/docs/development/xbox-store.md @@ -18,6 +18,12 @@ not Xbox console emulation or UWP/APPX packages. scoped to account and market; on later starts, snapshots older than 15 minutes refresh quietly in the background. Refresh checks immediately. Downloading and launching still perform Microsoft licensing. + A detail page can be opened from a store search result that is not in the + library; it keeps the catalog metadata and, when the product's eligibility + affirmations name a plan that includes PC games (PC Game Pass, Ultimate, + Premium, Essential or Starter Edition), offers Install even before the title + is in the entitlement list. The retired console-only plan does not offer a + Windows install, and Microsoft licensing still decides at download time. 3. Start with a small licensed title such as LIMBO. Search `LIMBO` if needed; the verified US Product ID is `9PNG83MDD6SH`. Install through the normal store download queue. If an offer contains several PC base packages, choose the