mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 19:11:08 +00:00
fix: guard maintenance queue admission
This commit is contained in:
1 parent
be51429fa4
commit
3ccf8ca01a
16 files changed
+362
-141
No files matched your search
@@ -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<Pair<File, StoreGameSidecar>> =
|
||||
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<File, StoreGameSidecar>? =
|
||||
matchingInstalls(context, store, id).firstOrNull { it.second.isInstalled }
|
||||
|
||||
private fun preferredInstall(context: Context, store: Store, id: String): Pair<File, StoreGameSidecar>? {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<MaintenanceAction> = buildSet {
|
||||
if (BattleNetInstall.product(game.sidecar.id) != null) add(MaintenanceAction.UPDATE)
|
||||
add(MaintenanceAction.OPEN_CLIENT)
|
||||
}
|
||||
override fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -53,7 +53,7 @@ interface StoreMaintenanceAdapter {
|
||||
fun capabilities(game: InstalledStoreGame): Set<MaintenanceAction>
|
||||
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<String, Long>()
|
||||
private val revisions = ConcurrentHashMap<Store, Long>()
|
||||
private val accounts = ConcurrentHashMap<Store, String>()
|
||||
private val operationLocks = ConcurrentHashMap<String, Any>()
|
||||
val checks = mutableStateMapOf<String, MaintenanceCheck>()
|
||||
|
||||
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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+109
-1
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user