Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/src/main/java/app/gamenative/data/DownloadInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,31 @@ data class DownloadInfo(
private val statusMessage = MutableStateFlow<String?>(null)
private val postInstallSyncing = MutableStateFlow(false)

private var wasAutoPaused: Boolean = false
private var autoResumeCallback: (() -> Unit)? = null

// For GameDownloadQueue integration
private var queueGameSource: GameSource? = null
private var queueGameId: String? = null

fun cancel() {
cancel("Cancelled by user")
}

fun pause(message: String = "Paused", autoPaused: Boolean = false) {
persistProgressSnapshot()
setActive(false)
setPostInstallSyncing(false)
resetSpeedTracking()
wasAutoPaused = autoPaused
downloadJob?.cancel(CancellationException(message))

// If user manually paused (not auto-paused), unregister from queue to resume next download
if (!autoPaused && queueGameSource != null && queueGameId != null) {
app.gamenative.service.GameDownloadQueue.unregisterDownload(queueGameSource!!, queueGameId!!)
}
}

fun failedToDownload() {
cancel("Failed to download")
}
Expand All @@ -52,6 +73,11 @@ data class DownloadInfo(
setPostInstallSyncing(false)
resetSpeedTracking()
downloadJob?.cancel(CancellationException(message))

// Unregister from queue to resume next download
if (queueGameSource != null && queueGameId != null) {
app.gamenative.service.GameDownloadQueue.unregisterDownload(queueGameSource!!, queueGameId!!)
}
}

fun setDownloadJob(job: Job) {
Expand Down Expand Up @@ -175,6 +201,29 @@ data class DownloadInfo(
}
}

fun wasAutoPaused(): Boolean = wasAutoPaused

fun setAutoResumeCallback(callback: () -> Unit) {
autoResumeCallback = callback
}

fun triggerAutoResume() {
if (wasAutoPaused) {
wasAutoPaused = false
autoResumeCallback?.invoke()
autoResumeCallback = null
}
}

/**
* Set the queue identifiers for this download.
* This allows the DownloadInfo to unregister itself from the queue when paused/cancelled.
*/
fun setQueueIdentifiers(gameSource: GameSource, gameId: String) {
queueGameSource = gameSource
queueGameId = gameId
}

fun isActive(): Boolean = isActive

/**
Expand Down
135 changes: 135 additions & 0 deletions app/src/main/java/app/gamenative/service/GameDownloadQueue.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package app.gamenative.service

import android.content.Context
import app.gamenative.data.DownloadInfo
import app.gamenative.data.GameSource
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap

/**
* Centralized queue for managing downloads across all game services.
* Ensures only one download is active at a time across Steam, Epic, GOG, and Amazon.
*/
object GameDownloadQueue {

data class DownloadEntry(
val gameSource: GameSource,
val gameId: String,
val downloadInfo: DownloadInfo
)

/**
* Listener interface for services to handle resume requests.
*/
interface ResumeListener {
fun onResumeRequested(gameSource: GameSource, gameId: String)
}

private val activeDownloads = ConcurrentHashMap<String, DownloadEntry>()
private val resumeListeners = ConcurrentHashMap<GameSource, ResumeListener>()

private fun makeKey(gameSource: GameSource, gameId: String): String {
return "${gameSource.name}_$gameId"
}

/**
* Register a resume listener for a specific game source.
* Each service should register its own listener to handle resume requests.
*/
fun registerResumeListener(gameSource: GameSource, listener: ResumeListener) {
resumeListeners[gameSource] = listener
Timber.i("[GameDownloadQueue] Registered resume listener for $gameSource")
}

/**
* Unregister a resume listener for a specific game source.
*/
fun unregisterResumeListener(gameSource: GameSource) {
resumeListeners.remove(gameSource)
Timber.i("[GameDownloadQueue] Unregistered resume listener for $gameSource")
}

/**
* Register a new download. This will auto-pause all other active downloads.
*/
fun registerDownload(
gameSource: GameSource,
gameId: String,
downloadInfo: DownloadInfo
) {
val key = makeKey(gameSource, gameId)

// Set queue identifiers on the DownloadInfo so it can unregister itself
downloadInfo.setQueueIdentifiers(gameSource, gameId)

// Auto-pause all other active downloads
activeDownloads.forEach { (existingKey, entry) ->
if (existingKey != key && entry.downloadInfo.isActive()) {
Timber.i("[GameDownloadQueue] Auto-pausing ${entry.gameSource} download for ${entry.gameId}")
entry.downloadInfo.pause(message = "Paused for new download", autoPaused = true)
}
}

// Register the new download
activeDownloads[key] = DownloadEntry(gameSource, gameId, downloadInfo)
Timber.i("[GameDownloadQueue] Registered ${gameSource} download for $gameId")
}

/**
* Unregister a download when it completes or is cancelled.
* Automatically resumes the next paused download if available.
*/
fun unregisterDownload(gameSource: GameSource, gameId: String) {
val key = makeKey(gameSource, gameId)
activeDownloads.remove(key)
Timber.i("[GameDownloadQueue] Unregistered ${gameSource} download for $gameId")

// Auto-resume the first paused download (if any)
val nextDownload = activeDownloads.values.firstOrNull { entry ->
entry.downloadInfo.wasAutoPaused()
}

if (nextDownload != null) {
Timber.i("[GameDownloadQueue] Auto-resuming ${nextDownload.gameSource} download for ${nextDownload.gameId}")
val listener = resumeListeners[nextDownload.gameSource]
if (listener != null) {
nextDownload.downloadInfo.setAutoResumeCallback {
listener.onResumeRequested(nextDownload.gameSource, nextDownload.gameId)
}
nextDownload.downloadInfo.triggerAutoResume()
} else {
Timber.w("[GameDownloadQueue] No resume listener registered for ${nextDownload.gameSource}")
}
}
}

/**
* Get all currently active downloads across all services.
*/
fun getActiveDownloads(): Map<String, DownloadEntry> {
return HashMap(activeDownloads)
}

/**
* Get the count of active downloads.
*/
fun getActiveDownloadCount(): Int {
return activeDownloads.count { it.value.downloadInfo.isActive() }
}

/**
* Check if a specific download is registered.
*/
fun isDownloadRegistered(gameSource: GameSource, gameId: String): Boolean {
val key = makeKey(gameSource, gameId)
return activeDownloads.containsKey(key)
}

/**
* Get download info for a specific game.
*/
fun getDownloadInfo(gameSource: GameSource, gameId: String): DownloadInfo? {
val key = makeKey(gameSource, gameId)
return activeDownloads[key]?.downloadInfo
}
}
24 changes: 24 additions & 0 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1844,6 +1844,13 @@ class SteamService : Service(), IChallengeUrlChanged {
notifyDownloadStarted(appId)
instance?.notifierOrNull?.trackDownload(di, getAppInfoOf(appId)?.name.orEmpty(), NotificationHelper.NOTIFICATION_ID_STEAM)

// Register with centralized queue and auto-pause other downloads
GameDownloadQueue.registerDownload(
gameSource = GameSource.STEAM,
gameId = appId.toString(),
downloadInfo = di
)

val downloadJob = instance!!.scope.launch {
try {
// Get licenses from database
Expand Down Expand Up @@ -2096,6 +2103,9 @@ class SteamService : Service(), IChallengeUrlChanged {

// Remove the downloading app info
instance?.downloadingAppInfoDao?.deleteApp(appId)

// Unregister from queue (will auto-resume next paused download)
GameDownloadQueue.unregisterDownload(GameSource.STEAM, appId.toString())
} catch (e: CancellationException) {
Timber.d(e, "Download canceled for app $appId")
throw e
Expand Down Expand Up @@ -3388,6 +3398,17 @@ class SteamService : Service(), IChallengeUrlChanged {

PluviaApp.events.on<AndroidEvent.EndProcess, Unit>(onEndProcess)

// Register resume listener with GameDownloadQueue
GameDownloadQueue.registerResumeListener(GameSource.STEAM, object : GameDownloadQueue.ResumeListener {
override fun onResumeRequested(gameSource: GameSource, gameId: String) {
val appId = gameId.toIntOrNull() ?: return
Timber.i("[SteamService] Resume requested for app $appId")
scope.launch {
downloadApp(appId)
}
}
})

// clear stale download records (completed games) but keep interrupted ones (preserves DLC selection)
scope.launch {
for (record in downloadingAppInfoDao.getAll()) {
Expand Down Expand Up @@ -3559,6 +3580,9 @@ class SteamService : Service(), IChallengeUrlChanged {

connectivityManager.unregisterNetworkCallback(networkCallback)

// Unregister resume listener from GameDownloadQueue
GameDownloadQueue.unregisterResumeListener(GameSource.STEAM)

scope.launch { stop() }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import app.gamenative.data.GameSource
import app.gamenative.db.dao.AmazonGameDao
import app.gamenative.enums.Marker
import app.gamenative.events.AndroidEvent
import app.gamenative.service.GameDownloadQueue
import app.gamenative.service.NotificationHelper
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.ExecutableSelectionUtils
Expand Down Expand Up @@ -458,6 +459,13 @@ class AmazonService : Service() {
AndroidEvent.DownloadStatusChanged(game.appId, true)
)

// Register with centralized queue and auto-pause other downloads
GameDownloadQueue.registerDownload(
gameSource = GameSource.AMAZON,
gameId = productId,
downloadInfo = downloadInfo
)

val job = instance.serviceScope.launch {
try {
val result = instance.amazonDownloadManager.downloadGame(
Expand All @@ -475,6 +483,9 @@ class AmazonService : Service() {
PluviaApp.events.emitJava(
AndroidEvent.LibraryInstallStatusChanged(game.appId, GameSource.AMAZON)
)

// Unregister from queue (will auto-resume next paused download)
GameDownloadQueue.unregisterDownload(GameSource.AMAZON, productId)
} else {
val error = result.exceptionOrNull()
Timber.tag("Amazon").e(error, "Download failed for $productId")
Expand Down Expand Up @@ -783,6 +794,23 @@ class AmazonService : Service() {
super.onCreate()
instance = this
PluviaApp.events.on<AndroidEvent.EndProcess, Unit>(onEndProcess)

// Register resume listener with GameDownloadQueue
GameDownloadQueue.registerResumeListener(GameSource.AMAZON, object : GameDownloadQueue.ResumeListener {
override fun onResumeRequested(gameSource: GameSource, gameId: String) {
Timber.tag("Amazon").i("[AmazonService] Resume requested for product $gameId")
serviceScope.launch {
val game = amazonManager.getGameById(gameId)
if (game != null) {
val installPath = game.installPath.ifBlank {
AmazonConstants.getGameInstallPath(applicationContext, game.title)
}
downloadGame(applicationContext, gameId, installPath)
}
}
}
})

PluviaApp.events.emit(AndroidEvent.ServiceReady)
Timber.i("[Amazon] Service created")
}
Expand Down Expand Up @@ -846,6 +874,10 @@ class AmazonService : Service() {
serviceScope.cancel()
stopForeground(STOP_FOREGROUND_REMOVE)
notificationHelper.cancel(NotificationHelper.NOTIFICATION_ID_AMAZON)

// Unregister resume listener from GameDownloadQueue
GameDownloadQueue.unregisterResumeListener(GameSource.AMAZON)

instance = null
super.onDestroy()
Timber.i("[Amazon] Service destroyed")
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/java/app/gamenative/service/epic/EpicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import app.gamenative.utils.MarkerUtils
import app.gamenative.enums.Marker
import app.gamenative.events.AndroidEvent
import app.gamenative.PluviaApp
import app.gamenative.data.GameSource
import app.gamenative.service.GameDownloadQueue
import app.gamenative.utils.ContainerUtils
import app.gamenative.service.NotificationHelper
import com.winlator.container.Container
Expand Down Expand Up @@ -425,6 +427,13 @@ class EpicService : Service() {
downloadInfo.setActive(true)
instance.notifierOrNull?.trackDownload(downloadInfo, game.title ?: "", NotificationHelper.NOTIFICATION_ID_EPIC)

// Register with centralized queue and auto-pause other downloads
GameDownloadQueue.registerDownload(
gameSource = GameSource.EPIC,
gameId = appId.toString(),
downloadInfo = downloadInfo
)

// Start download in background
val job = instance.scope.launch {
try {
Expand Down Expand Up @@ -473,6 +482,9 @@ class EpicService : Service() {
SnackbarManager.show("Download completed successfully!")
downloadInfo.setProgress(1.0f)
downloadInfo.setActive(false)

// Unregister from queue (will auto-resume next paused download)
GameDownloadQueue.unregisterDownload(GameSource.EPIC, appId.toString())
} else {
val error = result.exceptionOrNull()
Timber.e(error, "[Download] Failed for game $gameId")
Expand Down Expand Up @@ -636,6 +648,26 @@ class EpicService : Service() {
// Initialize notification helper for foreground service
notificationHelper = NotificationHelper(applicationContext)
PluviaApp.events.on<AndroidEvent.EndProcess, Unit>(onEndProcess)

// Register resume listener with GameDownloadQueue
GameDownloadQueue.registerResumeListener(GameSource.EPIC, object : GameDownloadQueue.ResumeListener {
override fun onResumeRequested(gameSource: GameSource, gameId: String) {
val appId = gameId.toIntOrNull() ?: return
Timber.tag("Epic").i("[EpicService] Resume requested for app $appId")
scope.launch {
val game = epicManager.getGameById(appId)
if (game != null) {
val installPath = game.installPath.ifBlank {
EpicConstants.getGameInstallPath(applicationContext, game.appName)
}
val container = ContainerUtils.getOrCreateContainer(applicationContext, "EPIC_$appId")
val language = ContainerUtils.toContainerData(container).language
downloadGame(applicationContext, appId, emptyList(), installPath, language)
}
}
}
})

PluviaApp.events.emit(AndroidEvent.ServiceReady)
}

Expand Down Expand Up @@ -736,6 +768,10 @@ class EpicService : Service() {
scope.cancel() // Cancel any ongoing operations
stopForeground(STOP_FOREGROUND_REMOVE)
notificationHelper.cancel(NotificationHelper.NOTIFICATION_ID_EPIC)

// Unregister resume listener from GameDownloadQueue
GameDownloadQueue.unregisterResumeListener(GameSource.EPIC)

instance = null
}

Expand Down
Loading
Loading