From 3ccf8ca01abe49cfeadd53e9ad2a51a30c656fc4 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:12:58 -0400 Subject: [PATCH] fix: guard maintenance queue admission --- .../droiddeck/launcher/stores/StoreLaunch.kt | 48 +++++--- .../stores/amazon/AmazonMaintenance.kt | 18 +-- .../stores/battlenet/BattleNetInstall.kt | 27 ----- .../stores/battlenet/BattleNetMaintenance.kt | 65 ++--------- .../launcher/stores/download/DownloadQueue.kt | 26 ++++- .../launcher/stores/epic/EpicMaintenance.kt | 18 +-- .../launcher/stores/itch/ItchMaintenance.kt | 12 +- .../stores/maintenance/StoreMaintenance.kt | 46 +++++++- .../launcher/stores/xbox/XboxBackend.kt | 7 +- .../launcher/stores/xbox/XboxMaintenance.kt | 6 +- .../launcher/ui/StoreMaintenanceUi.kt | 5 +- .../launcher/stores/StoreLaunchTest.kt | 51 +++++++- .../battlenet/BattleNetMaintenanceTest.kt | 18 +++ .../stores/download/DownloadQueueRetryTest.kt | 37 ++++++ .../maintenance/StoreMaintenanceTest.kt | 110 +++++++++++++++++- docs/development/store-maintenance.md | 9 +- 16 files changed, 362 insertions(+), 141 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenanceTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt index 75c00b62c..d264e8cbd 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreLaunch.kt @@ -64,10 +64,21 @@ object StoreLaunch { private const val CODE_VAR = "DD_X" private const val CLIENT_VAR = "DD_A" + internal fun matchingInstalls(context: Context, store: Store, id: String): List> = + StoreInstallRoot.gameFolders(context.applicationContext).mapNotNull { folder -> + StoreGameSidecar.read(folder)?.takeIf { it.store == store && it.id == id }?.let { folder to it } + } + + internal fun findInstalled(context: Context, store: Store, id: String): Pair? = + matchingInstalls(context, store, id).firstOrNull { it.second.isInstalled } + + private fun preferredInstall(context: Context, store: Store, id: String): Pair? { + val matches = matchingInstalls(context, store, id) + return matches.firstOrNull { it.second.isInstalled } ?: matches.firstOrNull() + } + fun installedGame(context: Context, store: Store, id: String): Library.SteamGame? { - val folder = StoreInstallRoot.gameFolders(context).firstOrNull { folder -> - StoreGameSidecar.read(folder)?.let { it.store == store && it.id == id && it.isInstalled } == true - } ?: return null + val folder = findInstalled(context, store, id)?.first ?: return null val game = AddedGames.single(context, folder.path) ?: return null return Library.addedGame(context, game) } @@ -84,17 +95,19 @@ object StoreLaunch { fun prepare(context: Context, store: Store, id: String, onReady: () -> Unit) { val app = context.applicationContext - val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f -> - StoreGameSidecar.read(f)?.let { it.store == store && it.id == id } == true - } - val sidecar = folder?.let { StoreGameSidecar.read(it) } - if (folder != null && sidecar != null && com.droiddeck.launcher.stores.maintenance.StoreMaintenance.launchExcluded(InstalledStoreGame(sidecar, folder))) { - Log.i(TAG, "launch blocked while ${store.label} maintenance is active: $id") - return - } - if (store != Store.EPIC) { onReady(); return } Thread({ - epicCode(app, id) + val selected = try { + preferredInstall(app, store, id) + } catch (e: Exception) { + Log.w(TAG, "could not inspect installed ${store.label} launch records for $id", e) + return@Thread + } + if (selected != null && com.droiddeck.launcher.stores.maintenance.StoreMaintenance.launchExcluded( + InstalledStoreGame(selected.second, selected.first))) { + Log.i(TAG, "launch blocked while ${store.label} maintenance is active: $id") + return@Thread + } + if (store == Store.EPIC) epicCode(app, id) StoresState.post(onReady) }, "stores-launch-prep").start() } @@ -104,9 +117,10 @@ object StoreLaunch { fun epicCode(context: Context, id: String): CodeResult { val app = context.applicationContext val result = try { - val folder = StoreInstallRoot.gameFolders(app).firstOrNull { f -> StoreGameSidecar.read(f)?.let { it.store == Store.EPIC && it.id == id } == true } - val sidecar = folder?.let { StoreGameSidecar.read(it) } - sidecar?.let { refreshLauncher(folder, it) } + val selected = preferredInstall(app, Store.EPIC, id) + val folder = selected?.first + val sidecar = selected?.second + selected?.let { (installedFolder, installedSidecar) -> refreshLauncher(installedFolder, installedSidecar) } val options = sidecar?.epic if (folder == null) CodeResult(false, "not-installed") else if (options != null && !options.wantsCode) { @@ -135,7 +149,7 @@ object StoreLaunch { fun prepare(context: Context, game: Library.SteamGame, onReady: () -> Unit) { val store = Store.byId(game.source) val id = game.storeId - if (store == null || id == null) { onReady(); return } + if (store == null || id == null) { StoresState.post(onReady); return } prepare(context, store, id, onReady) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt index 6436664b4..c000fa4e7 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/amazon/AmazonMaintenance.kt @@ -91,18 +91,19 @@ internal object AmazonMaintenanceAdapter : StoreMaintenanceAdapter { ) } - override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) { + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { check(proposal.operation == MaintenanceAction.UPDATE || proposal.operation == MaintenanceAction.REPAIR) - check(!DownloadQueue.isActive(game.key)) { "Amazon maintenance is already queued" } val target = validateTarget(game, proposal.target) - enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation) + return enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation) } override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) { require(operation.optString("phase") in setOf("queued", "download", "applying")) { "The Amazon operation needs attention before it can resume" } val target = validateTarget(game, operation.getJSONObject("target")) val bytes = operation.optLong("downloadBytes", 0L).coerceAtLeast(0L) - enqueueJob(context, game, target, bytes, MaintenanceAction.valueOf(operation.getString("operation"))) + check(enqueueJob(context, game, target, bytes, MaintenanceAction.valueOf(operation.getString("operation")))) { + "Amazon maintenance is already queued" + } } override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery { @@ -126,15 +127,16 @@ internal object AmazonMaintenanceAdapter : StoreMaintenanceAdapter { return JSONObject(target.toString()).put("manifestPath", path.absolutePath) } - private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction) { + private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction): Boolean { val app = context.applicationContext val sidecar = game.sidecar val item = CatalogItem(Store.AMAZON, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover, extra = mapOf("entitlementId" to sidecar.extra["entitlementId"].orEmpty(), "sku" to sidecar.extra["sku"].orEmpty(), StoreMaintenance.OPERATION_KEY to action.name, StoreMaintenance.FOLDER_KEY to game.folder.absolutePath)) - SavedQueue.remember(app, item, null) - DownloadQueue.enqueue(app, DownloadEntry(Store.AMAZON, item.id, item.title, cover = item.tallImageUrl, - bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder))) { + return DownloadQueue.enqueueIfIdle(app, DownloadEntry(Store.AMAZON, item.id, item.title, cover = item.tallImageUrl, + bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder)), onAccepted = { + SavedQueue.remember(app, item, null) + }) { AmazonInstaller(app, item, game.folder, target, game) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt index 5decaec58..6d22fa157 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetInstall.kt @@ -21,7 +21,6 @@ import com.droiddeck.launcher.stores.download.DownloadQueue import com.droiddeck.launcher.stores.download.DownloadStage import com.droiddeck.launcher.stores.maintenance.MaintenanceCheck import com.droiddeck.launcher.stores.maintenance.MaintenanceCheckState -import com.droiddeck.launcher.stores.maintenance.StoreMaintenance import org.json.JSONObject import java.io.File import java.net.HttpURLConnection @@ -106,13 +105,6 @@ object BattleNetInstall { DownloadQueue.enqueue(app, entry) { BattleNetInstaller(app, item, folder, update) } } - fun enqueueMaintenance(context: Context, item: CatalogItem, game: InstalledStoreGame) { - val app = context.applicationContext - val entry = DownloadEntry(Store.BATTLENET, item.id, item.title, cover = item.tallImageUrl ?: item.imageUrl, - location = StoreInstallRoot.labelFor(app, game.folder)) - DownloadQueue.enqueue(app, entry) { BattleNetInstaller(app, item, game.folder, update = true, maintenanceGame = game) } - } - fun update(context: Context, game: InstalledStoreGame) { val app = context.applicationContext Thread({ @@ -261,28 +253,23 @@ internal class BattleNetInstaller( private val item: CatalogItem, private val folder: File, private val update: Boolean, - private val maintenanceGame: InstalledStoreGame? = null, ) : DownloadQueue.DownloadJob { private val cancelled = AtomicBoolean(false) private val started = AtomicBoolean(false) private val ended = java.util.concurrent.CountDownLatch(1) private var before = 0L private var kept = -1L - @Volatile private var applyLease: AutoCloseable? = null override fun run(handle: DownloadQueue.JobHandle): String? { started.set(true) try { return if (cancelled.get()) null else work(handle) } finally { - runCatching { applyLease?.close() } - applyLease = null ended.countDown() } } private fun work(handle: DownloadQueue.JobHandle): String? { - val maintenance = maintenanceGame before = DownloadQueue.entry(handle.key)?.bytesTotal ?: 0L handle.stage(DownloadStage.MANIFEST, app.getString(R.string.bnet_dl_starting)) if (!BattleNet.installed(app)) throw IllegalStateException(app.getString(R.string.bnet_dl_client_first)) @@ -296,17 +283,10 @@ internal class BattleNetInstaller( else listOf("agent-install", uid, product, guest, BattleNetInstall.localeTag(app), country, region) var done: JSONObject? = null var reason: String? = null - var leaseFailure: String? = null val code = BattleNetInstall.run(app, args, cancelled) { line -> when { line.startsWith(BattleNetInstall.PROGRESS) -> runCatching { JSONObject(line.removePrefix(BattleNetInstall.PROGRESS)) }.getOrNull()?.let { progress(handle, it) - if (maintenance != null && applyLease == null && - BattleNetInstall.step(it.optInt("state", 0), it.optInt("phase", 0), it.optLong("download_current", 0L), it.optLong("download_total", 0L)) == BattleNetInstall.Step.APPLYING) { - runCatching { StoreMaintenance.beginApplyLease(maintenance) } - .onSuccess { applyLease = it } - .onFailure { leaseFailure = it.message ?: "Close the game before maintenance"; cancelled.set(true) } - } } line == BattleNetInstall.STATE + "waiting" -> handle.stage(DownloadStage.MANIFEST, app.getString(R.string.bnet_dl_waiting)) line == BattleNetInstall.STATE + "queued" -> handle.stage(DownloadStage.MANIFEST, app.getString(R.string.bnet_dl_queued)) @@ -315,16 +295,9 @@ internal class BattleNetInstaller( line.startsWith(BattleNetInstall.ERROR) -> reason = line.removePrefix(BattleNetInstall.ERROR) } } - leaseFailure?.let { throw IllegalStateException(it) } if (cancelled.get()) return null if (done == null) throw IllegalStateException(BattleNetInstall.failure(app, reason, code)) handle.log("Battle.net: ${item.title} ${if (update) "updated" else "installed"} ${done?.optString("version").orEmpty()}".trimEnd()) - if (maintenance != null) { - if (applyLease == null) applyLease = StoreMaintenance.beginApplyLease(maintenance) - val installedBuild = runCatching { BattleNetInstall.buildKey(File(folder, ".build.info").readText())?.second }.getOrNull() - ?: throw IllegalStateException("Battle.net completed, but the installed build could not be verified") - StoreMaintenance.complete(app, maintenance, installedBuild) - } StoresState.post { BattleNetInstall.updates.remove(item.id) StoresState.notifyLibraryChanged(app) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt index d17bdc824..26a025d57 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenance.kt @@ -1,87 +1,40 @@ package com.droiddeck.launcher.stores.battlenet import android.content.Context -import com.droiddeck.launcher.stores.CatalogItem import com.droiddeck.launcher.stores.InstalledStoreGame import com.droiddeck.launcher.stores.Store -import com.droiddeck.launcher.stores.download.SavedQueue import com.droiddeck.launcher.stores.maintenance.MaintenanceAction import com.droiddeck.launcher.stores.maintenance.MaintenanceCheck -import com.droiddeck.launcher.stores.maintenance.MaintenanceCheckState import com.droiddeck.launcher.stores.maintenance.MaintenanceProposal import com.droiddeck.launcher.stores.maintenance.MaintenanceRecovery import com.droiddeck.launcher.stores.maintenance.RecoveryState import com.droiddeck.launcher.stores.maintenance.StoreMaintenance import com.droiddeck.launcher.stores.maintenance.StoreMaintenanceAdapter import org.json.JSONObject -import java.io.File internal object BattleNetMaintenanceAdapter : StoreMaintenanceAdapter { override val store = Store.BATTLENET - private const val TARGET_SCHEMA = 1 - override fun capabilities(game: InstalledStoreGame): Set = buildSet { - if (BattleNetInstall.product(game.sidecar.id) != null) add(MaintenanceAction.UPDATE) - add(MaintenanceAction.OPEN_CLIENT) - } + override fun capabilities(game: InstalledStoreGame): Set = setOf(MaintenanceAction.OPEN_CLIENT) override fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck = BattleNetInstall.maintenanceCheck(context, game) - override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal { - require(action == MaintenanceAction.UPDATE) { "Battle.net maintenance is delegated to Agent and its client" } - val check = BattleNetInstall.maintenanceCheck(context, game) - require(check.state != MaintenanceCheckState.CURRENT) { "Battle.net reports this installed build is current" } - val local = BattleNetInstall.buildKey(File(game.folder, ".build.info").readText()) - ?: error("The installed Battle.net build identity is unknown") - val target = JSONObject().put("schema", TARGET_SCHEMA).put("installUid", BattleNetInstall.installUid(context, game)) - .put("region", local.first) - .put("installedBuild", local.second) - .put("targetBuild", check.version) - require(target.optString("installUid").isNotBlank() && target.optString("region").isNotBlank()) { - "Battle.net install identity is incomplete" - } - return MaintenanceProposal(game.folder.canonicalFile, game.sidecar, check.version.ifBlank { "latest Battle.net build" }, action, - description = if (check.state == MaintenanceCheckState.AVAILABLE) "Battle.net Agent applies the selected regional update in its existing client environment." - else "The target build could not be checked; Battle.net Agent selects the current regional build.", target = target) - } + override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal = + error("Battle.net updates must use its Agent/client workflow") - override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) { - require(proposal.operation == MaintenanceAction.UPDATE) - enqueuePinned(context, game, proposal.target) - } + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean = + error("Battle.net updates must use its Agent/client workflow") - override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) { - val target = operation.optJSONObject("target") ?: error("Battle.net maintenance target is missing") - require(operation.optString("phase") in setOf("queued", "download", "applying")) - enqueuePinned(context, game, target) - } + override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject): Unit = + error("Resume this Battle.net operation through its Agent/client workflow") override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery { - val operation = StoreMaintenance.readOperation(game.folder) ?: return MaintenanceRecovery() - return when (operation.optString("phase")) { - "queued", "download", "applying" -> MaintenanceRecovery(RecoveryState.RESUMABLE, "Resume with Battle.net Agent") - else -> MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, "Inspect the Battle.net client before resuming") - } + if (StoreMaintenance.readOperation(game.folder) == null) return MaintenanceRecovery() + return MaintenanceRecovery(RecoveryState.NEEDS_ATTENTION, "Inspect this update in Battle.net before launching the game") } override fun openClient(context: Context, game: InstalledStoreGame) = BattleNetClient.open(emptyList()) override fun openClientLabel(game: InstalledStoreGame) = "Open Battle.net for Scan and Repair" - - private fun enqueuePinned(context: Context, game: InstalledStoreGame, target: JSONObject) { - require(target.optInt("schema") == TARGET_SCHEMA && target.optString("installUid").isNotBlank()) { - "The saved Battle.net target is unsupported" - } - require(target.optString("region").matches(Regex("[a-z]{2}")) && target.optString("installedBuild").matches(Regex("[0-9a-f]{32}"))) { - "The saved Battle.net build identity is invalid" - } - val item = CatalogItem(Store.BATTLENET, game.sidecar.id, game.sidecar.title, game.sidecar.hero, - tallImageUrl = game.sidecar.cover, owned = true, - extra = game.sidecar.extra + mapOf(BattleNetInstall.INSTALL_UID to target.getString("installUid"), - StoreMaintenance.OPERATION_KEY to MaintenanceAction.UPDATE.name, StoreMaintenance.FOLDER_KEY to game.folder.canonicalPath)) - val root = game.folder.parentFile?.parentFile ?: error("Battle.net install root is missing") - SavedQueue.remember(context.applicationContext, item, root) - BattleNetInstall.enqueueMaintenance(context, item, game) - } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt index baae64b3c..17b0d3abb 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt @@ -129,9 +129,32 @@ object DownloadQueue { fun isActive(key: String): Boolean = entry(key)?.isActive == true fun enqueue(context: Context, entry: DownloadEntry, factory: () -> DownloadJob) { + enqueueInternal(context, entry, onAccepted = {}, factory = factory) + } + + /** Enqueues only if the key is idle, running [onAccepted] atomically before the accepted row is saved. */ + fun enqueueIfIdle( + context: Context, + entry: DownloadEntry, + onAccepted: () -> Unit, + factory: () -> DownloadJob, + ): Boolean = enqueueInternal(context, entry, onAccepted = onAccepted, factory = factory) + + private fun enqueueInternal( + context: Context, + entry: DownloadEntry, + onAccepted: () -> Unit, + factory: () -> DownloadJob, + ): Boolean { val app = context.applicationContext synchronized(lock) { - items[entry.key]?.let { if (it.entry.isActive) { Log.i(TAG, "${entry.key}: already queued"); return } } + items[entry.key]?.let { + if (it.entry.isActive) { + Log.i(TAG, "${entry.key}: already queued") + return false + } + } + onAccepted() items.remove(entry.key) val state = if (heldPaused.remove(entry.key)) DownloadState.PAUSED else DownloadState.QUEUED items[entry.key] = Item(entry.copy(state = state, stage = DownloadStage.MANIFEST, startedAt = System.currentTimeMillis()), factory) @@ -140,6 +163,7 @@ object DownloadQueue { StoresState.logLine("${entry.store.label}: queued \"${entry.name}\"" + if (entry.bytesTotal > 0) " (${com.droiddeck.launcher.stores.formatBytes(entry.bytesTotal)})" else "") publish(app) advance(app) + return true } /** Immediately records a failed download (e.g. from pre-flight storage check). */ diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt index 0a630f6b6..4b1a9dff2 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicMaintenance.kt @@ -103,18 +103,17 @@ internal object EpicMaintenanceAdapter : StoreMaintenanceAdapter { ) } - override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) { + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { check(proposal.operation == MaintenanceAction.UPDATE || proposal.operation == MaintenanceAction.REPAIR) - check(!DownloadQueue.isActive(game.key)) { "Epic maintenance is already queued" } val target = validateTarget(game, proposal.target) - enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation) + return enqueueJob(context, game, target, proposal.downloadBytes, proposal.operation) } override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) { require(operation.optString("phase") in setOf("queued", "download", "applying")) { "The Epic operation needs attention before it can resume" } val target = validateTarget(game, operation.getJSONObject("target")) - enqueueJob(context, game, target, operation.optLong("downloadBytes", 0L).coerceAtLeast(0L), - MaintenanceAction.valueOf(operation.getString("operation"))) + check(enqueueJob(context, game, target, operation.optLong("downloadBytes", 0L).coerceAtLeast(0L), + MaintenanceAction.valueOf(operation.getString("operation")))) { "Epic maintenance is already queued" } } override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery { @@ -155,15 +154,16 @@ internal object EpicMaintenanceAdapter : StoreMaintenanceAdapter { EpicInstallTags.tagsFor(StoreLanguage.localeFor(game.sidecar.extra[StoreLanguage.EXTRA], context)) } - private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction) { + private fun enqueueJob(context: Context, game: InstalledStoreGame, target: JSONObject, bytes: Long, action: MaintenanceAction): Boolean { val app = context.applicationContext val sidecar = game.sidecar val extra = LinkedHashMap(sidecar.extra) val item = CatalogItem(Store.EPIC, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover, extra = extra + mapOf(StoreMaintenance.OPERATION_KEY to action.name, StoreMaintenance.FOLDER_KEY to game.folder.absolutePath)) - SavedQueue.remember(app, item, null) - DownloadQueue.enqueue(app, DownloadEntry(Store.EPIC, item.id, item.title, cover = item.tallImageUrl, - bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder))) { + return DownloadQueue.enqueueIfIdle(app, DownloadEntry(Store.EPIC, item.id, item.title, cover = item.tallImageUrl, + bytesTotal = bytes, location = StoreInstallRoot.labelFor(app, game.folder)), onAccepted = { + SavedQueue.remember(app, item, null) + }) { EpicInstaller(app, item, game.folder, target, game) } } diff --git a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt index 4b087c51e..1f06e0350 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/itch/ItchMaintenance.kt @@ -91,11 +91,12 @@ internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter { "Downloads one pinned archive. Extracted size and required free space are unknown until download; DroidDeck checks the measured extracted payload before replacing files.", target) } - override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) = enqueuePinned(context, game, proposal.target) + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean = + enqueuePinned(context, game, proposal.target) override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) { val target = operation.optJSONObject("target") ?: throw IllegalStateException("itch.io maintenance target is missing") - enqueuePinned(context, game, target) + check(enqueuePinned(context, game, target)) { "itch.io maintenance is already queued" } } override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery { @@ -106,7 +107,7 @@ internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter { else MaintenanceRecovery() } - private fun enqueuePinned(context: Context, game: InstalledStoreGame, target: JSONObject) { + private fun enqueuePinned(context: Context, game: InstalledStoreGame, target: JSONObject): Boolean { val item = CatalogItem( Store.ITCH, game.sidecar.id, game.sidecar.title, game.sidecar.hero, tallImageUrl = game.sidecar.cover, isFree = game.sidecar.extra["downloadKeyId"].isNullOrBlank(), owned = true, @@ -114,10 +115,11 @@ internal object ItchMaintenanceAdapter : StoreMaintenanceAdapter { StoreMaintenance.OPERATION_KEY to (StoreMaintenance.readOperation(game.folder)?.optString("operation") ?: MaintenanceAction.REFRESH.name), StoreMaintenance.FOLDER_KEY to game.folder.absolutePath), ) - SavedQueue.remember(context.applicationContext, item, null) val entry = DownloadEntry(Store.ITCH, game.sidecar.id, game.sidecar.title, bytesTotal = target.optLong("size"), location = StoreInstallRoot.labelFor(context, game.folder)) - DownloadQueue.enqueue(context, entry) { ItchInstaller(context.applicationContext, item, game.folder, target) } + return DownloadQueue.enqueueIfIdle(context.applicationContext, entry, onAccepted = { + SavedQueue.remember(context.applicationContext, item, null) + }) { ItchInstaller(context.applicationContext, item, game.folder, target) } } private fun targetJson(game: InstalledStoreGame, upload: JSONObject): JSONObject = JSONObject() diff --git a/app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt index 58f13ba37..994d31b74 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenance.kt @@ -53,7 +53,7 @@ interface StoreMaintenanceAdapter { fun capabilities(game: InstalledStoreGame): Set fun check(context: Context, game: InstalledStoreGame): MaintenanceCheck fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction): MaintenanceProposal - fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) + fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery = MaintenanceRecovery() fun openClient(context: Context, game: InstalledStoreGame) = Unit @@ -71,6 +71,7 @@ object StoreMaintenance { private val inFlight = ConcurrentHashMap() private val revisions = ConcurrentHashMap() private val accounts = ConcurrentHashMap() + private val operationLocks = ConcurrentHashMap() val checks = mutableStateMapOf() fun register(adapter: StoreMaintenanceAdapter) { @@ -129,8 +130,25 @@ object StoreMaintenance { require(game.folder.canonicalFile == proposal.folder.canonicalFile) require(proposal.operation != MaintenanceAction.OPEN_CLIENT && proposal.operation in capabilities(game)) val adapter = adapters[game.sidecar.store] ?: error("${game.sidecar.store.label} maintenance is unavailable") - writeOperation(proposal, game) - adapter.enqueue(context.applicationContext, game, proposal) + val folder = game.folder.canonicalFile + synchronized(operationLock(folder)) { + require(!com.droiddeck.launcher.stores.download.DownloadQueue.isActive(game.key)) { + "${game.sidecar.store.label} already has an active operation" + } + val enqueueId = java.util.UUID.randomUUID().toString() + writeOperation(proposal, game, enqueueId) + val accepted = try { + adapter.enqueue(context.applicationContext, game, proposal) + } catch (error: Throwable) { + runCatching { clearNewQueuedOperation(game, enqueueId, preserveActive = true) } + .onFailure(error::addSuppressed) + throw error + } + if (!accepted) { + clearNewQueuedOperation(game, enqueueId, preserveActive = false) + error("${game.sidecar.store.label} already has an active operation") + } + } } fun resume(context: Context, game: InstalledStoreGame) { @@ -192,6 +210,10 @@ object StoreMaintenance { fun beginApplyLease(game: InstalledStoreGame): AutoCloseable { val folder = game.folder.canonicalFile + synchronized(operationLock(folder)) { return beginApplyLeaseLocked(game, folder) } + } + + private fun beginApplyLeaseLocked(game: InstalledStoreGame, folder: File): AutoCloseable { require(folder == game.folder.absoluteFile.canonicalFile) val operation = readOperation(folder) ?: error("${game.sidecar.store.label} operation journal is missing") require(operation.optString("folder") == folder.path && operation.optString("store") == game.sidecar.store.id && operation.optString("id") == game.sidecar.id) @@ -219,6 +241,20 @@ object StoreMaintenance { return AutoCloseable { lock.close(); lease.close() } } + private fun operationLock(folder: File): Any = operationLocks.computeIfAbsent(folder.path) { Any() } + + private fun clearNewQueuedOperation(game: InstalledStoreGame, enqueueId: String, preserveActive: Boolean) { + val folder = game.folder.canonicalFile + if (preserveActive && com.droiddeck.launcher.stores.download.DownloadQueue.isActive(game.key)) return + val operation = readOperation(folder) ?: return + if (operation.optString("enqueueId") != enqueueId || operation.optString("phase") != "queued" || + operation.optString("folder") != folder.path || operation.optString("store") != game.sidecar.store.id || + operation.optString("id") != game.sidecar.id) return + val file = operationFile(folder) + require(!Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" } + if (file.isFile) check(file.delete()) { "Could not clear the failed queued maintenance operation" } + } + fun complete( context: Context, game: InstalledStoreGame, @@ -238,7 +274,7 @@ object StoreMaintenance { StoresState.post { checks.remove(game.key) } } - private fun writeOperation(proposal: MaintenanceProposal, game: InstalledStoreGame) { + private fun writeOperation(proposal: MaintenanceProposal, game: InstalledStoreGame, enqueueId: String) { val folder = proposal.folder.canonicalFile require(proposal.before.store == game.sidecar.store && proposal.before.id == game.sidecar.id) val directory = File(folder, ".droiddeck-maintenance") @@ -248,7 +284,7 @@ object StoreMaintenance { require(!Files.isSymbolicLink(file.toPath())) { "Maintenance journal is a symbolic link" } require(!file.exists()) { "Resume or recover the existing ${game.sidecar.store.label} operation first" } val value = JSONObject().put("schema", 1).put("store", game.sidecar.store.id).put("id", game.sidecar.id) - .put("folder", folder.path).put("operation", proposal.operation.name).put("phase", "queued") + .put("folder", folder.path).put("enqueueId", enqueueId).put("operation", proposal.operation.name).put("phase", "queued") .put("before", proposal.before.toJson()).put("targetLabel", proposal.targetLabel) .put("downloadBytes", proposal.downloadBytes).put("diskBytes", proposal.diskBytes) .put("description", proposal.description).put("target", proposal.target) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt index e5fdf74fe..005d7747a 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt @@ -191,17 +191,18 @@ object XboxBackend : StoreBackend { downloadBytes = size, diskBytes = size, description = "Xodus refreshes the installed package through its licensed streaming path.", target = target) } - internal fun enqueueMaintenance(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) { + internal fun enqueueMaintenance(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { val sidecar = game.sidecar val item = CatalogItem(store, sidecar.id, sidecar.title, sidecar.hero, tallImageUrl = sidecar.cover, owned = sidecar.extra["access"] == "owned", subscription = sidecar.extra["access"] == "gamepass", extra = sidecar.extra + mapOf("contentId" to proposal.target.getString("contentId"), "market" to proposal.target.getString("market"), StoreMaintenance.OPERATION_KEY to MaintenanceAction.UPDATE.name, StoreMaintenance.FOLDER_KEY to game.folder.canonicalPath)) val root = game.folder.parentFile?.parentFile ?: error("Xbox install root is missing") - com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root) val entry = DownloadEntry(store, sidecar.id, sidecar.title, cover = sidecar.cover, bytesTotal = proposal.downloadBytes, location = StoreInstallRoot.labelFor(context, game.folder)) - DownloadQueue.enqueue(context.applicationContext, entry) { XboxInstaller(context.applicationContext, item, game.folder, game) } + return DownloadQueue.enqueueIfIdle(context.applicationContext, entry, onAccepted = { + com.droiddeck.launcher.stores.download.SavedQueue.remember(context, item, root) + }) { XboxInstaller(context.applicationContext, item, game.folder, game) } } private fun enqueue(context: Context, item: CatalogItem, root: File) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt index 5b690db87..4ad504533 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxMaintenance.kt @@ -26,9 +26,9 @@ internal object XboxMaintenanceAdapter : StoreMaintenanceAdapter { return XboxBackend.prepareMaintenance(context, game) } - override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) { + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { require(proposal.operation == MaintenanceAction.UPDATE && proposal.target.optInt("schema") == 1) - XboxBackend.enqueueMaintenance(context, game, proposal) + return XboxBackend.enqueueMaintenance(context, game, proposal) } override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) { @@ -40,7 +40,7 @@ internal object XboxMaintenanceAdapter : StoreMaintenanceAdapter { ?: error("The original Xbox settings are missing"), operation.optString("targetLabel"), MaintenanceAction.UPDATE, operation.optLong("downloadBytes", MaintenanceProposal.UNKNOWN_BYTES), operation.optLong("diskBytes", MaintenanceProposal.UNKNOWN_BYTES), operation.optString("description"), JSONObject(target.toString())) - XboxBackend.enqueueMaintenance(context, game, proposal) + check(XboxBackend.enqueueMaintenance(context, game, proposal)) { "Xbox maintenance is already queued" } } override fun recovery(context: Context, game: InstalledStoreGame): MaintenanceRecovery { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt index 9e0ee0a86..ccb868663 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreMaintenanceUi.kt @@ -129,7 +129,10 @@ internal fun StoreMaintenanceActions(game: InstalledStoreGame) { when (current.state) { MaintenanceCheckState.CHECKING -> Text("Checking for updates…", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) MaintenanceCheckState.CURRENT -> Text("Up to date", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) - MaintenanceCheckState.AVAILABLE -> Unit + MaintenanceCheckState.AVAILABLE -> if (MaintenanceAction.UPDATE !in capabilities) { + Text("An update is available. Use ${adapter.openClientLabel(game)} to apply it.", + fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } MaintenanceCheckState.UNKNOWN, MaintenanceCheckState.FAILED -> if (current.reason.isNotBlank()) Text(current.reason, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt index 898e074f6..9da23f594 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreLaunchTest.kt @@ -1,6 +1,7 @@ package com.droiddeck.launcher.stores import android.app.Application +import android.os.Looper import com.droiddeck.launcher.frontend.AddedGames import com.droiddeck.launcher.session.SessionPrefs import org.junit.Assert.* @@ -10,6 +11,9 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean @RunWith(RobolectricTestRunner::class) @Config(sdk = [33], application = Application::class) @@ -18,9 +22,12 @@ class StoreLaunchTest { private fun install(state: String = StoreGameSidecar.STATE_INSTALLED): File { SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "") - val folder = File(StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), Store.XBOX), "Deep Rock Galactic") + val storeDir = StoreInstallRoot.storeDir(StoreInstallRoot.installRoot(app), Store.XBOX) + storeDir.listFiles()?.filter { StoreGameSidecar.read(it)?.id == "9NHFVWX1V7QJ" }?.forEach { it.deleteRecursively() } + val folder = File(storeDir, "Deep Rock Galactic") File(folder, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("licensed image") } StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe", state = state).write(folder) + SessionPrefs.setAddedGameRemoved(app, folder.path, false) return folder } @@ -53,4 +60,46 @@ class StoreLaunchTest { File(folder, "FSD/Binaries/WinGDK/FSD.exe").delete() assertNull(StoreLaunch.installedGame(app, Store.XBOX, "9NHFVWX1V7QJ")) } + + @Test fun installedTargetWinsOverAnEarlierUpdatingDuplicate() { + val updating = install(StoreGameSidecar.STATE_UPDATING) + val installed = File(updating.parentFile, "ZZZ installed copy") + File(installed, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("installed image") } + StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe").write(installed) + + try { + assertEquals(installed, StoreLaunch.findInstalled(app, Store.XBOX, "9NHFVWX1V7QJ")!!.first) + } finally { + installed.deleteRecursively() + } + } + + @Test fun prepareScansOffTheCallerAndPostsReadyBackToMainLooper() { + val updating = install(StoreGameSidecar.STATE_UPDATING) + val installed = File(updating.parentFile, "ZZZ installed copy") + File(installed, "FSD/Binaries/WinGDK/FSD.exe").apply { parentFile!!.mkdirs(); writeText("installed image") } + StoreGameSidecar(Store.XBOX, "9NHFVWX1V7QJ", "Deep Rock Galactic", "FSD/Binaries/WinGDK/FSD.exe").write(installed) + val callbackRan = AtomicBoolean(false) + val callbackOnMain = AtomicBoolean(false) + val callback = CountDownLatch(1) + + try { + StoreLaunch.prepare(app, Store.XBOX, "9NHFVWX1V7QJ") { + callbackOnMain.set(Looper.myLooper() == Looper.getMainLooper()) + callbackRan.set(true) + callback.countDown() + } + + assertFalse("ready callback must not run inline on the caller", callbackRan.get()) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (callback.count > 0 && System.nanoTime() < deadline) { + org.robolectric.shadows.ShadowLooper.idleMainLooper() + Thread.sleep(10) + } + assertTrue("ready callback was not posted", callback.await(1, TimeUnit.SECONDS)) + assertTrue("ready callback must run on the main looper", callbackOnMain.get()) + } finally { + installed.deleteRecursively() + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenanceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenanceTest.kt new file mode 100644 index 000000000..910be4aee --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/stores/battlenet/BattleNetMaintenanceTest.kt @@ -0,0 +1,18 @@ +package com.droiddeck.launcher.stores.battlenet + +import com.droiddeck.launcher.stores.InstalledStoreGame +import com.droiddeck.launcher.stores.Store +import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.maintenance.MaintenanceAction +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File + +class BattleNetMaintenanceTest { + @Test fun agentManagedGamesOfferOnlyClientHandoffThroughSharedMaintenance() { + val folder = File("/tmp/battlenet-agent-game") + val game = InstalledStoreGame(StoreGameSidecar(Store.BATTLENET, "wow", "World of Warcraft", ""), folder) + + assertEquals(setOf(MaintenanceAction.OPEN_CLIENT), BattleNetMaintenanceAdapter.capabilities(game)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt index 879e8de91..b77be045f 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/download/DownloadQueueRetryTest.kt @@ -7,6 +7,7 @@ import java.io.File import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicBoolean import org.junit.After import org.junit.Assert.* import org.junit.Test @@ -137,4 +138,40 @@ class DownloadQueueRetryTest { assertTrue(ran.await(5, TimeUnit.SECONDS)) awaitFailure(entry.key, "restored") } + + @Test fun maintenanceAdmissionRejectsDuplicateWithoutReplacingSavedQueueItem() { + val id = "maintenance-admission-${java.util.UUID.randomUUID()}" + val first = CatalogItem(Store.EPIC, id, "First update", imageUrl = null, owned = true) + val second = CatalogItem(Store.EPIC, id, "Other operation", imageUrl = null, owned = true) + val entry = DownloadEntry(Store.EPIC, id, first.title) + val firstFactory = { object : DownloadQueue.DownloadJob { + override fun run(handle: DownloadQueue.JobHandle): String? = null + override fun cancel(deleteFiles: Boolean) = Unit + } } + DownloadQueue.holdPaused(entry.key) + + val firstAccepted = DownloadQueue.enqueueIfIdle(app, entry, + onAccepted = { SavedQueue.remember(app, first, null) }, factory = firstFactory) + val duplicateRemembered = AtomicBoolean(false) + val secondAccepted = DownloadQueue.enqueueIfIdle(app, entry.copy(name = second.title), + onAccepted = { duplicateRemembered.set(true); SavedQueue.remember(app, second, null) }, factory = firstFactory) + + assertTrue(firstAccepted) + assertFalse(secondAccepted) + assertFalse(duplicateRemembered.get()) + assertEquals(DownloadState.PAUSED, DownloadQueue.entry(entry.key)?.state) + + val saved = File(app.filesDir, "store-queue.json") + val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + var savedFirst = false + while (!savedFirst && System.nanoTime() < until) { + savedFirst = runCatching { + val rows = JSONArray(saved.readText()) + (0 until rows.length()).any { i -> rows.getJSONObject(i).getJSONObject("item").getString("title") == first.title } + }.getOrDefault(false) + if (!savedFirst) Thread.sleep(10) + } + assertTrue("only the accepted operation's saved item should persist", savedFirst) + DownloadQueue.clear(app, entry.key) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt index e026f8e4d..e65425780 100644 --- a/app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/stores/maintenance/StoreMaintenanceTest.kt @@ -4,9 +4,17 @@ import android.content.Context import com.droiddeck.launcher.stores.InstalledStoreGame import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.StoreGameSidecar +import com.droiddeck.launcher.stores.CatalogItem +import com.droiddeck.launcher.stores.download.DownloadEntry +import com.droiddeck.launcher.stores.download.DownloadQueue +import com.droiddeck.launcher.stores.download.DownloadState +import com.droiddeck.launcher.stores.download.SavedQueue import org.json.JSONObject +import org.json.JSONArray import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -16,6 +24,7 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config import java.io.File +import java.util.concurrent.TimeUnit @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) @@ -27,7 +36,7 @@ class StoreMaintenanceTest { override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE) override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck(MaintenanceCheckState.CURRENT) override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused") - override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) = Unit + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal) = true override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit } @@ -90,6 +99,105 @@ class StoreMaintenanceTest { assertEquals(RecoveryState.RESUMABLE, StoreMaintenance.recovery(RuntimeEnvironment.getApplication(), game).state) } + @Test fun failedQueueSubmissionClearsOnlyItsNewQueuedJournalAndRethrowsOriginalError() { + val folder = temporary.newFolder().canonicalFile + val sidecar = StoreGameSidecar(Store.EPIC, "enqueue-failure", "Example", "bin/game.exe") + sidecar.write(folder) + val game = InstalledStoreGame(sidecar, folder) + val failure = IllegalStateException("queue rejected") + StoreMaintenance.register(object : StoreMaintenanceAdapter { + override val store = Store.EPIC + override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE) + override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck() + override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused") + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { throw failure } + override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit + }) + val proposal = MaintenanceProposal(folder, sidecar, "target", MaintenanceAction.UPDATE, + target = JSONObject().put("schema", 1).put("build", "target")) + + val thrown = runCatching { StoreMaintenance.enqueue(RuntimeEnvironment.getApplication(), game, proposal) }.exceptionOrNull() + + assertSame(failure, thrown) + assertNull(StoreMaintenance.readOperation(folder)) + } + + @Test fun failedQueueSubmissionPreservesJournalThatHasAdvancedFromQueued() { + val folder = temporary.newFolder().canonicalFile + val sidecar = StoreGameSidecar(Store.EPIC, "enqueue-advanced", "Example", "bin/game.exe") + sidecar.write(folder) + val game = InstalledStoreGame(sidecar, folder) + val failure = IllegalStateException("queue response lost") + StoreMaintenance.register(object : StoreMaintenanceAdapter { + override val store = Store.EPIC + override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE) + override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck() + override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused") + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { + val file = StoreMaintenance.operationFile(game.folder) + file.writeText(JSONObject(file.readText()).put("phase", "download").toString()) + throw failure + } + override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit + }) + val proposal = MaintenanceProposal(folder, sidecar, "target", MaintenanceAction.UPDATE, + target = JSONObject().put("schema", 1).put("build", "target")) + + val thrown = runCatching { StoreMaintenance.enqueue(RuntimeEnvironment.getApplication(), game, proposal) }.exceptionOrNull() + + assertSame(failure, thrown) + assertEquals("download", StoreMaintenance.readOperation(folder)!!.getString("phase")) + } + + @Test fun queueRaceRejectionClearsOnlyMaintenanceJournalAndKeepsExistingSavedJob() { + val app = RuntimeEnvironment.getApplication() + val folder = temporary.newFolder().canonicalFile + val sidecar = StoreGameSidecar(Store.EPIC, "queue-race", "Example", "bin/game.exe") + sidecar.write(folder) + val game = InstalledStoreGame(sidecar, folder) + val existing = CatalogItem(Store.EPIC, sidecar.id, "Existing job", imageUrl = null, owned = true) + val maintenance = CatalogItem(Store.EPIC, sidecar.id, "Maintenance job", imageUrl = null, owned = true) + val key = DownloadEntry(Store.EPIC, sidecar.id, existing.title).key + fun job() = object : DownloadQueue.DownloadJob { + override fun run(handle: DownloadQueue.JobHandle): String? = null + override fun cancel(deleteFiles: Boolean) = Unit + } + DownloadQueue.holdPaused(key) + StoreMaintenance.register(object : StoreMaintenanceAdapter { + override val store = Store.EPIC + override fun capabilities(game: InstalledStoreGame) = setOf(MaintenanceAction.UPDATE) + override fun check(context: Context, game: InstalledStoreGame) = MaintenanceCheck() + override fun prepare(context: Context, game: InstalledStoreGame, action: MaintenanceAction) = error("unused") + override fun enqueue(context: Context, game: InstalledStoreGame, proposal: MaintenanceProposal): Boolean { + SavedQueue.remember(context, existing, null) + DownloadQueue.enqueue(context, DownloadEntry(Store.EPIC, sidecar.id, existing.title), ::job) + return DownloadQueue.enqueueIfIdle(context, DownloadEntry(Store.EPIC, sidecar.id, maintenance.title), onAccepted = { + SavedQueue.remember(context, maintenance, null) + }, factory = ::job) + } + override fun resume(context: Context, game: InstalledStoreGame, operation: JSONObject) = Unit + }) + val proposal = MaintenanceProposal(folder, sidecar, "target", MaintenanceAction.UPDATE, + target = JSONObject().put("schema", 1).put("build", "target")) + + assertTrue(runCatching { StoreMaintenance.enqueue(app, game, proposal) }.isFailure) + + assertNull(StoreMaintenance.readOperation(folder)) + assertEquals(DownloadState.PAUSED, DownloadQueue.entry(key)?.state) + val saved = File(app.filesDir, "store-queue.json") + val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + var existingSaved = false + while (!existingSaved && System.nanoTime() < until) { + existingSaved = runCatching { + val rows = JSONArray(saved.readText()) + (0 until rows.length()).any { i -> rows.getJSONObject(i).getJSONObject("item").getString("title") == existing.title } + }.getOrDefault(false) + if (!existingSaved) Thread.sleep(10) + } + assertTrue("rejected maintenance must not replace the active queue's saved item", existingSaved) + DownloadQueue.clear(app, key) + } + @Test fun applyLeaseIsExclusiveAndMarksTheJournalBeforeMutation() { val folder = temporary.newFolder().canonicalFile val sidecar = StoreGameSidecar(Store.EPIC, "app", "Example", "bin/game.exe") diff --git a/docs/development/store-maintenance.md b/docs/development/store-maintenance.md index 8fce14664..57e46ccc7 100644 --- a/docs/development/store-maintenance.md +++ b/docs/development/store-maintenance.md @@ -18,10 +18,11 @@ its existing engine or client can perform. checkout; it is not an optional engine pack. Upstream comparison: [Nile CLI](https://github.com/imLinguin/nile/blob/master/nile/cli.py), [download manager](https://github.com/imLinguin/nile/blob/master/nile/downloading/manager.py), and [active worker](https://github.com/imLinguin/nile/blob/master/nile/downloading/worker.py). -- **Battle.net:** checks and update handoff to the existing Agent workflow. - When unattended repair is unavailable, the UI opens Battle.net for its - Scan and Repair flow. The app does not claim the client repair already ran. - See [Blizzard's Scan and Repair instructions](https://eu.support.blizzard.com/es/help/article/6572). +- **Battle.net:** metadata checks and client handoff for Scan and Repair. Its + Agent-managed installs do not have the executable sidecar required by the + shared apply/restore flow, so updates remain in the existing Agent/Battle.net + workflow. The app does not claim client repair already ran. See [Blizzard's + Scan and Repair instructions](https://eu.support.blizzard.com/en/help/article/6572). - **Xbox:** update through Xodus's licensed package streaming path. The UI reports an unknown package version when Xodus does not expose one; transfer and space amounts are client-determined. Metadata checks do not claim success