fix: Xbox search results open with their metadata and Game Pass installs (#1279)

This commit is contained in:
Moroni Granja authored and GitHub committed 2026-10-21 22:43:51 -04:00
1 parent af37432251
commit b68bb054a9
9 files changed
+145 -17

No files matched your search

@@ -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 {
@@ -22,8 +22,11 @@ data class CatalogItem(
val extra: Map<String, String> = 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<CatalogItem>, previous: List<Catalog
return previous.map { byKey.getValue(it.key) }
}
/** A detail page opens for search results too; the library wins when it already knows the product. */
fun detailCatalogItem(
store: Store,
id: String,
library: List<CatalogItem>,
found: List<CatalogItem>,
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<CatalogItem>?, new: List<CatalogItem>): List<CatalogItem> {
if (old.isNullOrEmpty()) return new
val before = old.associateBy { it.key }
@@ -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
@@ -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<String, String> {
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"),
@@ -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<String>, timeout: Long = 90, credentialGeneration: String? = null): JSONObject {
@@ -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()
@@ -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))
}
}
@@ -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")))
+6
View File
@@ -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