From a80ba1f3d1ab3608c3a034cf76eba90d937f3978 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:30:18 +0800 Subject: [PATCH 01/10] feat: Optimize download performance for external storage Optimized download changes: - Debouncing download progress persistence to reduce disk I/O. - Switching to `FileChannel` for file writes and `Path.deleteRecursively()` for robust file system operations across various download managers (Epic, GOG). - Dynamically adjusting download and decompression concurrency based on CPU cores for better resource utilization. - Streamlining download status message updates in `DownloadInfo` and UI components. - Adding an option to skip large file allocation for Steam downloads to external storage. Also updates the JavaSteam dependency to 1.8.0.1-25-SNAPSHOT. --- app/build.gradle.kts | 4 +- .../java/app/gamenative/data/DownloadInfo.kt | 52 +++++++++++++++-- .../app/gamenative/service/SteamService.kt | 8 ++- .../service/epic/EpicDownloadManager.kt | 29 +++++++--- .../gamenative/service/epic/EpicService.kt | 11 ++-- .../service/gog/GOGDownloadManager.kt | 36 +++++++++--- .../app/gamenative/service/gog/GOGManager.kt | 11 ++-- .../gamenative/ui/model/DownloadsViewModel.kt | 11 +--- .../ui/screen/library/LibraryAppScreen.kt | 54 +++++++++++------- .../gamenative/utils/DownloadSpeedConfig.kt | 56 ++++++++++--------- gradle/libs.versions.toml | 2 +- 11 files changed, 182 insertions(+), 92 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26550ed229..d2aaf62d0d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,8 +331,8 @@ dependencies { // JavaSteam val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { - implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-22-SNAPSHOT.jar")) - implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-22-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-25-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-25-SNAPSHOT.jar")) implementation(libs.bundles.javasteam.dev) } else { implementation(libs.javasteam) { diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index 2bfabf8935..45eb236837 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -1,13 +1,19 @@ package app.gamenative.data import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import timber.log.Timber import java.io.File import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean data class DownloadInfo( val jobCount: Int = 1, @@ -15,9 +21,15 @@ data class DownloadInfo( var downloadingAppIds: CopyOnWriteArrayList, ) { private var downloadJob: Job? = null - private val downloadProgressListeners = mutableListOf<((Float) -> Unit)>() + private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val downloadProgressListeners = CopyOnWriteArrayList<(Float) -> Unit>() private val progresses: Array = Array(jobCount) { 0f } + // Debounced persistence state + private var lastPersistTime: Long = 0L + private var persistenceJob: Job? = null + private val hasPendingPersist = AtomicBoolean(false) + private val weights = FloatArray(jobCount) { 1f } // ⇐ new private var weightSum = jobCount.toFloat() @@ -32,7 +44,7 @@ data class DownloadInfo( private var emaSpeedBytesPerSec: Double = 0.0 private var hasEmaSpeed: Boolean = false private var isActive: Boolean = true - private val statusMessage = MutableStateFlow(null) + private var currentStatusMessage: String = "" private val postInstallSyncing = MutableStateFlow(false) fun cancel() { @@ -137,10 +149,11 @@ data class DownloadInfo( } fun updateStatusMessage(message: String?) { - statusMessage.value = message + currentStatusMessage = message ?: "" + emitProgressChange() } - fun getStatusMessageFlow(): StateFlow = statusMessage + fun getCurrentStatusMessage(): String = currentStatusMessage fun setPostInstallSyncing(syncing: Boolean) { postInstallSyncing.value = syncing @@ -269,12 +282,42 @@ data class DownloadInfo( companion object { private const val PERSISTENCE_DIR = ".DownloadInfo" private const val PERSISTENCE_FILE = "bytes_downloaded.txt" + private const val PERSIST_DEBOUNCE_MS = 10_000L // 10 seconds } /** * Persist bytesDownloaded to a file in the app directory. + * Debounced to write at most once every 10 seconds to reduce I/O overhead. */ fun persistBytesDownloaded(appDirPath: String) { + val now = System.currentTimeMillis() + val timeSinceLastPersist = now - lastPersistTime + + // If we wrote recently, schedule a delayed write + if (timeSinceLastPersist < PERSIST_DEBOUNCE_MS) { + if (hasPendingPersist.compareAndSet(false, true)) { + persistenceJob?.cancel() + persistenceJob = ioScope.launch { + delay(PERSIST_DEBOUNCE_MS - timeSinceLastPersist) + hasPendingPersist.set(false) + writePersistedBytes(appDirPath) + } + } + return + } + + // Write immediately if enough time has passed + persistenceJob?.cancel() + hasPendingPersist.set(false) + ioScope.launch { + writePersistedBytes(appDirPath) + } + } + + /** + * Internal method to actually write the bytes to disk. + */ + private fun writePersistedBytes(appDirPath: String) { try { val dir = File(appDirPath, PERSISTENCE_DIR) if (!dir.exists()) { @@ -282,6 +325,7 @@ data class DownloadInfo( } val file = File(dir, PERSISTENCE_FILE) file.writeText(bytesDownloaded.toString()) + lastPersistTime = System.currentTimeMillis() } catch (e: Exception) { Timber.e(e, "Failed to persist bytes downloaded to $appDirPath") } diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index bb419edef2..3739d5c690 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -186,6 +186,8 @@ import app.gamenative.utils.DownloadSpeedConfig import app.gamenative.utils.CustomGameScanner import java.nio.ByteBuffer import java.nio.ByteOrder +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively @AndroidEntryPoint class SteamService : Service(), IChallengeUrlChanged { @@ -1319,6 +1321,7 @@ class SteamService : Service(), IChallengeUrlChanged { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + @OptIn(ExperimentalPathApi::class) suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { // snapshot path before marker removal (removing the marker changes resolution) val appInfo = getInstalledApp(appId) @@ -1342,7 +1345,9 @@ class SteamService : Service(), IChallengeUrlChanged { MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) } - File(appDirPath).deleteRecursively() + File(appDirPath).toPath().deleteRecursively() + + true } // Remove from DB @@ -1871,6 +1876,7 @@ class SteamService : Service(), IChallengeUrlChanged { maxDecompress = maxDecompress, parentJob = coroutineContext[Job], autoStartDownload = false, + skipLargeFileAllocation = PrefManager.useExternalStorage, filesystem = CaseInsensitiveFileSystem( showDebugLog = false, chunkStagingRedirect = chunkStagingRedirectDir?.absolutePath?.toPath(), diff --git a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt index b98e7b35ab..d953fa4ffb 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt @@ -21,6 +21,8 @@ import java.io.File import java.io.InputStream import java.nio.ByteBuffer import java.nio.ByteOrder +import java.nio.channels.FileChannel +import java.nio.file.StandardOpenOption import java.security.MessageDigest import java.util.zip.Inflater import javax.inject.Inject @@ -48,6 +50,8 @@ import java.io.RandomAccessFile import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap.newKeySet import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively /** * EpicDownloadManager handles downloading Epic games @@ -58,6 +62,7 @@ import java.util.concurrent.atomic.AtomicInteger * - file_manifest_list: List of files and their chunk composition */ @Singleton +@OptIn(ExperimentalPathApi::class) class EpicDownloadManager @Inject constructor( private val epicManager: EpicManager, ) { @@ -259,7 +264,7 @@ class EpicDownloadManager @Inject constructor( return@withContext downloadResult } - chunkCacheDir.deleteRecursively() + chunkCacheDir.toPath().deleteRecursively() // Log final directory structure Timber.tag("Epic").i("Download completed successfully for ${game.title}") @@ -403,7 +408,7 @@ class EpicDownloadManager @Inject constructor( ) if (dlcDownloadResult.isFailure) return@withContext dlcDownloadResult - chunkCacheDir.deleteRecursively() + chunkCacheDir.toPath().deleteRecursively() // Update database try { @@ -472,7 +477,7 @@ class EpicDownloadManager @Inject constructor( }.awaitAll() results.firstOrNull { it.isFailure }?.let { failure -> - chunkCacheDir.deleteRecursively() + chunkCacheDir.toPath().deleteRecursively() return@withContext Result.failure( failure.exceptionOrNull() ?: Exception("Chunk download failed"), ) @@ -488,14 +493,14 @@ class EpicDownloadManager @Inject constructor( }.awaitAll() results.firstOrNull { it.isFailure }?.let { failure -> - chunkCacheDir.deleteRecursively() + chunkCacheDir.toPath().deleteRecursively() return@withContext Result.failure( failure.exceptionOrNull() ?: Exception("File assembly failed"), ) } } - chunkCacheDir.deleteRecursively() + chunkCacheDir.toPath().deleteRecursively() Timber.tag("Epic").i("downloadOverlay completed: $installPath") Result.success(Unit) } catch (e: Exception) { @@ -1214,10 +1219,15 @@ class EpicDownloadManager @Inject constructor( chunkFile.inputStream().use { input -> input.skip(chunk.offset.toLong()) - RandomAccessFile(outputFile.path, "rw").use { randomAccessFile -> - randomAccessFile.seek(chunk.fileOffset) + FileChannel.open( + outputFile.toPath(), + StandardOpenOption.WRITE, + StandardOpenOption.CREATE + ).use { channel -> + channel.position(chunk.fileOffset) val buffer = ByteArray(65536) // 64KB buffer for memory efficiency + val byteBuffer = ByteBuffer.wrap(buffer) var remaining = chunk.size.toLong() while (remaining > 0) { @@ -1226,7 +1236,10 @@ class EpicDownloadManager @Inject constructor( if (bytesRead == -1) break - randomAccessFile.write(buffer, 0, bytesRead) + byteBuffer.clear() + byteBuffer.put(buffer, 0, bytesRead) + byteBuffer.flip() + channel.write(byteBuffer) remaining -= bytesRead } } diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index e016eb6813..ad1b003b4e 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -26,6 +26,8 @@ import javax.inject.Inject import kotlinx.coroutines.* import app.gamenative.ui.util.SnackbarManager import timber.log.Timber +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively /** * Epic Games Service - thin coordinator that delegates to other Epic managers. @@ -224,6 +226,7 @@ class EpicService : Service() { .toList() } + @OptIn(ExperimentalPathApi::class) suspend fun deleteGame(context: Context, appId: Int): Result { val instance = getInstance() if (instance == null) { @@ -240,12 +243,8 @@ class EpicService : Service() { val path = if (game.installPath.isNotEmpty()) game.installPath else EpicConstants.getGameInstallPath(context, game.appName) if (File(path).exists()) { Timber.tag("Epic").i("Deleting installation folder: $path") - val deleted = File(path).deleteRecursively() - if (deleted) { - Timber.tag("Epic").i("Successfully deleted installation folder") - } else { - Timber.tag("Epic").w("Failed to delete some files in installation folder") - } + File(path).toPath().deleteRecursively() + Timber.tag("Epic").i("Successfully deleted installation folder") MarkerUtils.removeMarker(path, Marker.DOWNLOAD_COMPLETE_MARKER) MarkerUtils.removeMarker(path, Marker.DOWNLOAD_IN_PROGRESS_MARKER) } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt index 20457fd8e6..61e84af23b 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt @@ -23,7 +23,10 @@ import java.io.ByteArrayOutputStream import java.io.BufferedOutputStream import java.io.File import java.io.FileOutputStream +import java.nio.ByteBuffer +import java.nio.channels.FileChannel import java.nio.file.Files +import java.nio.file.StandardOpenOption import java.security.DigestOutputStream import java.security.MessageDigest import java.util.zip.Inflater @@ -49,6 +52,8 @@ import java.io.RandomAccessFile import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap.newKeySet import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively /** * Custom exception for HTTP status errors with typed status code @@ -74,6 +79,7 @@ class HttpStatusException(val statusCode: Int, message: String) : Exception(mess * - Multiple chunks assemble into single files */ @Singleton +@OptIn(ExperimentalPathApi::class) class GOGDownloadManager @Inject constructor( private val apiClient: GOGApiClient, private val parser: GOGManifestParser, @@ -475,7 +481,7 @@ class GOGDownloadManager @Inject constructor( } // Step 11: Cleanup - chunkCacheDir.deleteRecursively() + chunkCacheDir.toPath().deleteRecursively() saveManifestToGameDir(installPath, gameManifest, selectedBuild.buildId, selectedBuild.versionName, effectiveLang) @@ -1246,7 +1252,7 @@ class GOGDownloadManager @Inject constructor( continue } - depotCacheDir.deleteRecursively() + depotCacheDir.toPath().deleteRecursively() Timber.tag("GOG").i("Successfully downloaded dependency: ${depot.readableName} to ${depotInstallDir.absolutePath}") } @@ -1587,17 +1593,26 @@ class GOGDownloadManager @Inject constructor( val md5Digest = MessageDigest.getInstance("MD5") var totalBytesWritten = 0L - RandomAccessFile(outputFile.path, "rw").use { raf -> - raf.seek(writeOffset) + FileChannel.open( + outputFile.toPath(), + StandardOpenOption.WRITE, + StandardOpenOption.CREATE + ).use { channel -> + channel.position(writeOffset) // If no compressed size specified, data is already uncompressed if (chunk.compressedSize == null) { chunkFile.inputStream().use { input -> val buffer = ByteArray(8192) + val byteBuffer = ByteBuffer.wrap(buffer) var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { md5Digest.update(buffer, 0, bytesRead) - raf.write(buffer, 0, bytesRead) + byteBuffer.clear() + byteBuffer.put(buffer, 0, bytesRead) + byteBuffer.flip() + channel.write(byteBuffer) totalBytesWritten += bytesRead } } @@ -1608,6 +1623,7 @@ class GOGDownloadManager @Inject constructor( chunkFile.inputStream().buffered().use { input -> val inputBuffer = ByteArray(8192) val outputBuffer = ByteArray(8192) + val byteBuffer = ByteBuffer.wrap(outputBuffer) var inputBytesRead: Int while (input.read(inputBuffer).also { inputBytesRead = it } != -1) { @@ -1617,7 +1633,10 @@ class GOGDownloadManager @Inject constructor( val count = inflater.inflate(outputBuffer) if (count > 0) { md5Digest.update(outputBuffer, 0, count) - raf.write(outputBuffer, 0, count) + byteBuffer.clear() + byteBuffer.put(outputBuffer, 0, count) + byteBuffer.flip() + channel.write(byteBuffer) totalBytesWritten += count } else { if (inflater.needsDictionary()) { @@ -1635,7 +1654,10 @@ class GOGDownloadManager @Inject constructor( val count = inflater.inflate(outputBuffer) if (count > 0) { md5Digest.update(outputBuffer, 0, count) - raf.write(outputBuffer, 0, count) + byteBuffer.clear() + byteBuffer.put(outputBuffer, 0, count) + byteBuffer.flip() + channel.write(byteBuffer) totalBytesWritten += count } else { if (inflater.needsInput()) { diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index c2fd406868..3f65a01e8f 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -30,6 +30,8 @@ import kotlinx.coroutines.withContext import okhttp3.Request import org.json.JSONObject import timber.log.Timber +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.deleteRecursively /** * Data class to hold size information from gogdl info command @@ -479,6 +481,7 @@ class GOGManager @Inject constructor( } } + @OptIn(ExperimentalPathApi::class) suspend fun deleteGame(context: Context, libraryItem: LibraryItem): Result { return withContext(Dispatchers.IO) { try { @@ -499,12 +502,8 @@ class GOGManager @Inject constructor( for (path in pathsToClean) { val dir = File(path) if (dir.exists()) { - if (dir.deleteRecursively()) { - Timber.i("Successfully deleted game directory: $path") - } else { - Timber.w("Failed to delete some game files at $path") - failedPaths.add(path) - } + dir.toPath().deleteRecursively() + Timber.i("Successfully deleted game directory: $path") } else { Timber.w("GOG game directory doesn't exist: $path") } diff --git a/app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt b/app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt index a0dbbba23b..9765c68124 100644 --- a/app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt @@ -62,12 +62,10 @@ class DownloadsViewModel @Inject constructor( private data class ObservedDownload( val info: DownloadInfo, val progressListener: (Float) -> Unit, - val statusJob: Job, val syncingJob: Job, ) { fun dispose() { info.removeProgressListener(progressListener) - statusJob.cancel() syncingJob.cancel() } } @@ -286,7 +284,7 @@ class DownloadsViewModel @Inject constructor( ): DownloadItemState { val key = downloadKey(gameSource, appId) val rawProgress = info.getProgress() - val statusMessage = normalizeStatusMessage(info.getStatusMessageFlow().value) + val statusMessage = normalizeStatusMessage(info.getCurrentStatusMessage()) val isRunning = info.isActive() || info.isPostInstallSyncing() val status = when { rawProgress < 0f || statusMessage?.startsWith("Failed", ignoreCase = true) == true -> DownloadItemStatus.FAILED @@ -416,12 +414,6 @@ class DownloadsViewModel @Inject constructor( } binding.info.addProgressListener(progressListener) - val statusJob = viewModelScope.launch(Dispatchers.Default) { - binding.info.getStatusMessageFlow().collect { - updateObservedDownloadItem(binding) - } - } - val syncingJob = viewModelScope.launch(Dispatchers.Default) { binding.info.getPostInstallSyncingFlow().collect { updateObservedDownloadItem(binding) @@ -431,7 +423,6 @@ class DownloadsViewModel @Inject constructor( observedDownloads[key] = ObservedDownload( info = binding.info, progressListener = progressListener, - statusJob = statusJob, syncingJob = syncingJob, ) } diff --git a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt index 55b94093b1..b3869a6b7f 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -591,6 +592,28 @@ internal fun AppScreenContent( // Calculate parallax offset based on scroll val parallaxOffset = scrollState.value * 0.5f + var downloadTimeLeftText by remember { mutableStateOf("")} + + val progressListener: (Float) -> Unit = { + val downloadStatusMessage = downloadInfo?.getCurrentStatusMessage() + + downloadTimeLeftText = run { + val etaMs = downloadInfo?.getEstimatedTimeRemaining() + if (etaMs != null && etaMs > 0L) { + val totalSeconds = etaMs / 1000 + val minutesLeft = totalSeconds / 60 + val secondsPart = totalSeconds % 60 + "${minutesLeft}m ${secondsPart}s left" + } else if (isDownloading && downloadProgress >= 1f) { + "Unpacking..." + } else if (downloadProgress in 0f..1f && downloadProgress < 1f) { + downloadStatusMessage?.takeUnless { it.isBlank() } ?: "" + } else { + "" + } + } + } + LaunchedEffect(displayInfo.appId) { scrollState.animateScrollTo(0) } @@ -599,6 +622,16 @@ internal fun AppScreenContent( playButtonFocusRequester.requestFocus() } + LaunchedEffect(downloadInfo) { + downloadInfo?.addProgressListener(progressListener) + } + + DisposableEffect(Unit) { + onDispose { + downloadInfo?.removeProgressListener(progressListener) + } + } + // Restore focus when options menu, dialogs LaunchedEffect(optionsMenuVisible, dialogOpen) { if (!optionsMenuVisible && !dialogOpen) { @@ -634,28 +667,7 @@ internal fun AppScreenContent( } } - // Download progress texts hoisted here so they can be shown inside the button - val downloadStatusMessageFlow = remember(downloadInfo) { downloadInfo?.getStatusMessageFlow() } - val downloadStatusMessage by ( - downloadStatusMessageFlow?.collectAsState(initial = downloadStatusMessageFlow.value) - ?: remember { mutableStateOf(null) } - ) val downloadingLabel = stringResource(R.string.downloading) - val downloadTimeLeftText = remember(displayInfo.appId, downloadProgress, downloadInfo, isDownloading, downloadStatusMessage) { - val etaMs = downloadInfo?.getEstimatedTimeRemaining() - if (etaMs != null && etaMs > 0L) { - val totalSeconds = etaMs / 1000 - val minutesLeft = totalSeconds / 60 - val secondsPart = totalSeconds % 60 - "${minutesLeft}m ${secondsPart}s left" - } else if (isDownloading && downloadProgress >= 1f) { - downloadStatusMessage?.takeUnless { it.isBlank() } ?: "Unpacking..." - } else if (downloadProgress in 0f..1f && downloadProgress < 1f) { - downloadStatusMessage?.takeUnless { it.isBlank() } ?: "" - } else { - "" - } - } val downloadSizeText = remember(displayInfo.gameId, downloadProgress, downloadInfo) { val (bytesDone, bytesTotal) = downloadInfo?.getBytesProgress() ?: (0L to 0L) if (bytesTotal > 0L) { diff --git a/app/src/main/java/app/gamenative/utils/DownloadSpeedConfig.kt b/app/src/main/java/app/gamenative/utils/DownloadSpeedConfig.kt index 9ced7fa76f..8d111c1a10 100644 --- a/app/src/main/java/app/gamenative/utils/DownloadSpeedConfig.kt +++ b/app/src/main/java/app/gamenative/utils/DownloadSpeedConfig.kt @@ -3,37 +3,41 @@ package app.gamenative.utils import app.gamenative.PrefManager class DownloadSpeedConfig { - private data class Ratios(val download: Double, val decompress: Double) - - private val ratios: Ratios - get() = when (PrefManager.downloadSpeed) { - 8 -> { - Ratios(download = 0.6, decompress = 0.2) - } - - 16 -> { - Ratios(download = 1.2, decompress = 0.4) - } - - 24 -> { - Ratios(download = 1.5, decompress = 0.5) - } - - 32 -> { - Ratios(download = 2.4, decompress = 0.8) - } - - else -> { - Ratios(download = 0.6, decompress = 0.2) - } - } + private data class Limits( + val maxDownloads: Int, + val maxDecompress: Int + ) val cpuCores: Int get() = Runtime.getRuntime().availableProcessors() + private val limits: Limits + get() = when (PrefManager.downloadSpeed) { + 8 -> Limits( + maxDownloads = (cpuCores * 0.75).toInt().coerceIn(3, 8), + maxDecompress = (cpuCores * 0.25).toInt().coerceIn(1, 3) + ) + 16 -> Limits( + maxDownloads = (cpuCores * 1.0).toInt().coerceIn(4, 12), + maxDecompress = (cpuCores * 0.33).toInt().coerceIn(2, 4) + ) + 24 -> Limits( + maxDownloads = (cpuCores * 1.25).toInt().coerceIn(6, 16), + maxDecompress = (cpuCores * 0.4).toInt().coerceIn(2, 5) + ) + 32 -> Limits( + maxDownloads = (cpuCores * 1.5).toInt().coerceIn(8, 20), + maxDecompress = (cpuCores * 0.5).toInt().coerceIn(3, 6) + ) + else -> Limits( + maxDownloads = 3, + maxDecompress = 1 + ) + } + val maxDownloads: Int - get() = (cpuCores * ratios.download).toInt().coerceAtLeast(1) + get() = limits.maxDownloads val maxDecompress: Int - get() = (cpuCores * ratios.decompress).toInt().coerceAtLeast(1) + get() = limits.maxDecompress } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc99038cbb..dfbdef2c29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0.1-24-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest +javasteam = "1.8.0.1-25-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit From 1f9f470db4d180b3f2b713d4215cc28db91723da Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:09:12 +0800 Subject: [PATCH 02/10] revert added file.toPath().deleteRecursively --- .../java/app/gamenative/service/SteamService.kt | 7 ++----- .../gamenative/service/epic/EpicDownloadManager.kt | 13 +++++-------- .../java/app/gamenative/service/epic/EpicService.kt | 11 ++++++----- .../gamenative/service/gog/GOGDownloadManager.kt | 7 ++----- .../java/app/gamenative/service/gog/GOGManager.kt | 11 ++++++----- 5 files changed, 21 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 3739d5c690..4d105d1066 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -186,8 +186,6 @@ import app.gamenative.utils.DownloadSpeedConfig import app.gamenative.utils.CustomGameScanner import java.nio.ByteBuffer import java.nio.ByteOrder -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.deleteRecursively @AndroidEntryPoint class SteamService : Service(), IChallengeUrlChanged { @@ -1321,7 +1319,6 @@ class SteamService : Service(), IChallengeUrlChanged { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } - @OptIn(ExperimentalPathApi::class) suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { // snapshot path before marker removal (removing the marker changes resolution) val appInfo = getInstalledApp(appId) @@ -1345,7 +1342,7 @@ class SteamService : Service(), IChallengeUrlChanged { MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) } - File(appDirPath).toPath().deleteRecursively() + File(appDirPath).deleteRecursively() true } @@ -1876,7 +1873,7 @@ class SteamService : Service(), IChallengeUrlChanged { maxDecompress = maxDecompress, parentJob = coroutineContext[Job], autoStartDownload = false, - skipLargeFileAllocation = PrefManager.useExternalStorage, + skipLargeFileAllocation = !Paths.get(appDirPath).startsWith(Paths.get(DownloadService.baseDataDirPath)), filesystem = CaseInsensitiveFileSystem( showDebugLog = false, chunkStagingRedirect = chunkStagingRedirectDir?.absolutePath?.toPath(), diff --git a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt index d953fa4ffb..2b16601fa3 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt @@ -50,8 +50,6 @@ import java.io.RandomAccessFile import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap.newKeySet import java.util.concurrent.atomic.AtomicInteger -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.deleteRecursively /** * EpicDownloadManager handles downloading Epic games @@ -62,7 +60,6 @@ import kotlin.io.path.deleteRecursively * - file_manifest_list: List of files and their chunk composition */ @Singleton -@OptIn(ExperimentalPathApi::class) class EpicDownloadManager @Inject constructor( private val epicManager: EpicManager, ) { @@ -264,7 +261,7 @@ class EpicDownloadManager @Inject constructor( return@withContext downloadResult } - chunkCacheDir.toPath().deleteRecursively() + chunkCacheDir.deleteRecursively() // Log final directory structure Timber.tag("Epic").i("Download completed successfully for ${game.title}") @@ -408,7 +405,7 @@ class EpicDownloadManager @Inject constructor( ) if (dlcDownloadResult.isFailure) return@withContext dlcDownloadResult - chunkCacheDir.toPath().deleteRecursively() + chunkCacheDir.deleteRecursively() // Update database try { @@ -477,7 +474,7 @@ class EpicDownloadManager @Inject constructor( }.awaitAll() results.firstOrNull { it.isFailure }?.let { failure -> - chunkCacheDir.toPath().deleteRecursively() + chunkCacheDir.deleteRecursively() return@withContext Result.failure( failure.exceptionOrNull() ?: Exception("Chunk download failed"), ) @@ -493,14 +490,14 @@ class EpicDownloadManager @Inject constructor( }.awaitAll() results.firstOrNull { it.isFailure }?.let { failure -> - chunkCacheDir.toPath().deleteRecursively() + chunkCacheDir.deleteRecursively() return@withContext Result.failure( failure.exceptionOrNull() ?: Exception("File assembly failed"), ) } } - chunkCacheDir.toPath().deleteRecursively() + chunkCacheDir.deleteRecursively() Timber.tag("Epic").i("downloadOverlay completed: $installPath") Result.success(Unit) } catch (e: Exception) { diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index ad1b003b4e..e016eb6813 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -26,8 +26,6 @@ import javax.inject.Inject import kotlinx.coroutines.* import app.gamenative.ui.util.SnackbarManager import timber.log.Timber -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.deleteRecursively /** * Epic Games Service - thin coordinator that delegates to other Epic managers. @@ -226,7 +224,6 @@ class EpicService : Service() { .toList() } - @OptIn(ExperimentalPathApi::class) suspend fun deleteGame(context: Context, appId: Int): Result { val instance = getInstance() if (instance == null) { @@ -243,8 +240,12 @@ class EpicService : Service() { val path = if (game.installPath.isNotEmpty()) game.installPath else EpicConstants.getGameInstallPath(context, game.appName) if (File(path).exists()) { Timber.tag("Epic").i("Deleting installation folder: $path") - File(path).toPath().deleteRecursively() - Timber.tag("Epic").i("Successfully deleted installation folder") + val deleted = File(path).deleteRecursively() + if (deleted) { + Timber.tag("Epic").i("Successfully deleted installation folder") + } else { + Timber.tag("Epic").w("Failed to delete some files in installation folder") + } MarkerUtils.removeMarker(path, Marker.DOWNLOAD_COMPLETE_MARKER) MarkerUtils.removeMarker(path, Marker.DOWNLOAD_IN_PROGRESS_MARKER) } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt index 61e84af23b..00ee2a1c2c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt @@ -52,8 +52,6 @@ import java.io.RandomAccessFile import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap.newKeySet import java.util.concurrent.atomic.AtomicInteger -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.deleteRecursively /** * Custom exception for HTTP status errors with typed status code @@ -79,7 +77,6 @@ class HttpStatusException(val statusCode: Int, message: String) : Exception(mess * - Multiple chunks assemble into single files */ @Singleton -@OptIn(ExperimentalPathApi::class) class GOGDownloadManager @Inject constructor( private val apiClient: GOGApiClient, private val parser: GOGManifestParser, @@ -481,7 +478,7 @@ class GOGDownloadManager @Inject constructor( } // Step 11: Cleanup - chunkCacheDir.toPath().deleteRecursively() + chunkCacheDir.deleteRecursively() saveManifestToGameDir(installPath, gameManifest, selectedBuild.buildId, selectedBuild.versionName, effectiveLang) @@ -1252,7 +1249,7 @@ class GOGDownloadManager @Inject constructor( continue } - depotCacheDir.toPath().deleteRecursively() + depotCacheDir.deleteRecursively() Timber.tag("GOG").i("Successfully downloaded dependency: ${depot.readableName} to ${depotInstallDir.absolutePath}") } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt index 3f65a01e8f..c2fd406868 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGManager.kt @@ -30,8 +30,6 @@ import kotlinx.coroutines.withContext import okhttp3.Request import org.json.JSONObject import timber.log.Timber -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.deleteRecursively /** * Data class to hold size information from gogdl info command @@ -481,7 +479,6 @@ class GOGManager @Inject constructor( } } - @OptIn(ExperimentalPathApi::class) suspend fun deleteGame(context: Context, libraryItem: LibraryItem): Result { return withContext(Dispatchers.IO) { try { @@ -502,8 +499,12 @@ class GOGManager @Inject constructor( for (path in pathsToClean) { val dir = File(path) if (dir.exists()) { - dir.toPath().deleteRecursively() - Timber.i("Successfully deleted game directory: $path") + if (dir.deleteRecursively()) { + Timber.i("Successfully deleted game directory: $path") + } else { + Timber.w("Failed to delete some game files at $path") + failedPaths.add(path) + } } else { Timber.w("GOG game directory doesn't exist: $path") } From 815acd1a5cf7a0bb2e7be29609dadd9989705cfd Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:11:50 +0800 Subject: [PATCH 03/10] update downloadInfo and add test --- .../java/app/gamenative/data/DownloadInfo.kt | 38 ++++-- .../app/gamenative/data/DownloadInfoTest.kt | 129 ++++++++++++++++++ 2 files changed, 152 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index 45eb236837..5566a101b2 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -14,6 +14,8 @@ import timber.log.Timber import java.io.File import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong data class DownloadInfo( val jobCount: Int = 1, @@ -25,10 +27,10 @@ data class DownloadInfo( private val downloadProgressListeners = CopyOnWriteArrayList<(Float) -> Unit>() private val progresses: Array = Array(jobCount) { 0f } - // Debounced persistence state - private var lastPersistTime: Long = 0L + // Reservation-based persistence scheduler + private val nextWriteTime = AtomicLong(0L) private var persistenceJob: Job? = null - private val hasPendingPersist = AtomicBoolean(false) + private val persistenceGeneration = AtomicInteger(0) private val weights = FloatArray(jobCount) { 1f } // ⇐ new private var weightSum = jobCount.toFloat() @@ -291,24 +293,26 @@ data class DownloadInfo( */ fun persistBytesDownloaded(appDirPath: String) { val now = System.currentTimeMillis() - val timeSinceLastPersist = now - lastPersistTime - - // If we wrote recently, schedule a delayed write - if (timeSinceLastPersist < PERSIST_DEBOUNCE_MS) { - if (hasPendingPersist.compareAndSet(false, true)) { - persistenceJob?.cancel() - persistenceJob = ioScope.launch { - delay(PERSIST_DEBOUNCE_MS - timeSinceLastPersist) - hasPendingPersist.set(false) + val reserved = nextWriteTime.get() + val delayMs = reserved - now + + // If we need to wait, schedule a delayed write + if (delayMs > 0) { + val currentGen = persistenceGeneration.get() + persistenceJob?.cancel() + persistenceJob = ioScope.launch { + delay(delayMs) + // Only write if generation hasn't been invalidated + if (persistenceGeneration.get() == currentGen) { writePersistedBytes(appDirPath) } } return } - // Write immediately if enough time has passed + // Reserve the next write time and write immediately + nextWriteTime.set(now + PERSIST_DEBOUNCE_MS) persistenceJob?.cancel() - hasPendingPersist.set(false) ioScope.launch { writePersistedBytes(appDirPath) } @@ -325,7 +329,8 @@ data class DownloadInfo( } val file = File(dir, PERSISTENCE_FILE) file.writeText(bytesDownloaded.toString()) - lastPersistTime = System.currentTimeMillis() + val now = System.currentTimeMillis() + nextWriteTime.set(now + PERSIST_DEBOUNCE_MS) } catch (e: Exception) { Timber.e(e, "Failed to persist bytes downloaded to $appDirPath") } @@ -353,6 +358,9 @@ data class DownloadInfo( * Delete the persisted bytes file (called on download completion). */ fun clearPersistedBytesDownloaded(appDirPath: String) { + // Invalidate any pending persistence to prevent recreating the file + persistenceGeneration.incrementAndGet() + persistenceJob?.cancel() try { val file = File(File(appDirPath, PERSISTENCE_DIR), PERSISTENCE_FILE) if (file.exists()) { diff --git a/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt b/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt index ac501e5312..4c709d9250 100644 --- a/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt +++ b/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt @@ -1,11 +1,33 @@ package app.gamenative.data +import java.io.File import java.util.concurrent.CopyOnWriteArrayList +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test +import org.junit.Rule +import org.junit.rules.TemporaryFolder class DownloadInfoTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var testDir: File + + @Before + fun setup() { + testDir = tempFolder.newFolder() + } + + @After + fun cleanup() { + testDir.deleteRecursively() + } @Test fun `post install sync state is tracked independently`() { val info = DownloadInfo( @@ -35,4 +57,111 @@ class DownloadInfoTest { assertFalse(info.isPostInstallSyncing()) assertFalse(info.isActive()) } + + @Test + fun `rapid persistence calls do not trigger multiple immediate writes`() = runBlocking { + val info = DownloadInfo( + jobCount = 1, + gameId = 123, + downloadingAppIds = CopyOnWriteArrayList(), + ) + + info.setTotalExpectedBytes(1000L) + info.updateBytesDownloaded(100L) + + // First call should write immediately + info.persistBytesDownloaded(testDir.absolutePath) + delay(100) + + val firstValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(100L, firstValue) + + // Rapid subsequent calls within debounce window + info.updateBytesDownloaded(50L) + info.persistBytesDownloaded(testDir.absolutePath) + info.updateBytesDownloaded(50L) + info.persistBytesDownloaded(testDir.absolutePath) + info.updateBytesDownloaded(50L) + info.persistBytesDownloaded(testDir.absolutePath) + + // Should still show first write value immediately + val secondValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(100L, secondValue) + + // Wait for debounced write to complete + delay(11_000) + + // Now should show updated value + val finalValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(250L, finalValue) + } + + @Test + fun `clearPersistedBytesDownloaded cancels pending writes`() = runBlocking { + val info = DownloadInfo( + jobCount = 1, + gameId = 123, + downloadingAppIds = CopyOnWriteArrayList(), + ) + + info.setTotalExpectedBytes(1000L) + info.updateBytesDownloaded(100L) + + // Write initial value + info.persistBytesDownloaded(testDir.absolutePath) + delay(100) + + val firstValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(100L, firstValue) + + // Update and schedule a delayed write + info.updateBytesDownloaded(200L) + info.persistBytesDownloaded(testDir.absolutePath) + + // Clear the file before the delayed write executes + info.clearPersistedBytesDownloaded(testDir.absolutePath) + + // File should be deleted + val clearedValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(0L, clearedValue) + + // Wait for what would have been the delayed write + delay(11_000) + + // File should still be deleted (pending write was invalidated) + val finalValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(0L, finalValue) + } + + @Test + fun `completion during debounce interval prevents file recreation`() = runBlocking { + val info = DownloadInfo( + jobCount = 1, + gameId = 123, + downloadingAppIds = CopyOnWriteArrayList(), + ) + + info.setTotalExpectedBytes(1000L) + info.updateBytesDownloaded(500L) + + // Write initial progress + info.persistBytesDownloaded(testDir.absolutePath) + delay(100) + + assertEquals(500L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) + + // Schedule another write within debounce window + info.updateBytesDownloaded(200L) + info.persistBytesDownloaded(testDir.absolutePath) + + // Download completes and clears the file + info.clearPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(0L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) + + // Wait for the scheduled write delay + delay(11_000) + + // File should remain deleted + assertEquals(0L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) + } } From 94997fcef355e6bf549a2503858c94ce6c32569d Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:16:26 +0800 Subject: [PATCH 04/10] fix byteBuffer write --- .../gamenative/service/epic/EpicDownloadManager.kt | 4 +++- .../app/gamenative/service/gog/GOGDownloadManager.kt | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt index 2b16601fa3..aabe0fcfce 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt @@ -1236,7 +1236,9 @@ class EpicDownloadManager @Inject constructor( byteBuffer.clear() byteBuffer.put(buffer, 0, bytesRead) byteBuffer.flip() - channel.write(byteBuffer) + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer) + } remaining -= bytesRead } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt index 00ee2a1c2c..0257b5bf24 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt @@ -1609,7 +1609,9 @@ class GOGDownloadManager @Inject constructor( byteBuffer.clear() byteBuffer.put(buffer, 0, bytesRead) byteBuffer.flip() - channel.write(byteBuffer) + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer) + } totalBytesWritten += bytesRead } } @@ -1633,7 +1635,9 @@ class GOGDownloadManager @Inject constructor( byteBuffer.clear() byteBuffer.put(outputBuffer, 0, count) byteBuffer.flip() - channel.write(byteBuffer) + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer) + } totalBytesWritten += count } else { if (inflater.needsDictionary()) { @@ -1654,7 +1658,9 @@ class GOGDownloadManager @Inject constructor( byteBuffer.clear() byteBuffer.put(outputBuffer, 0, count) byteBuffer.flip() - channel.write(byteBuffer) + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer) + } totalBytesWritten += count } else { if (inflater.needsInput()) { From 690827ddfd44fe2b8f4caa816ae42de4e64bbf82 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sun, 2 Aug 2026 01:51:49 +0800 Subject: [PATCH 05/10] refactor DownloadInfo use newSingleThreadScheduledExecutor for writePersistedBytes --- .../java/app/gamenative/data/DownloadInfo.kt | 68 +++++++----------- .../app/gamenative/service/SteamService.kt | 1 + .../service/amazon/AmazonService.kt | 1 + .../gamenative/service/epic/EpicService.kt | 2 + .../app/gamenative/service/gog/GOGService.kt | 2 + .../app/gamenative/data/DownloadInfoTest.kt | 71 +++++++++---------- 6 files changed, 66 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index 5566a101b2..c7cbfb8df6 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -1,21 +1,18 @@ package app.gamenative.data import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import timber.log.Timber import java.io.File import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.concurrent.Volatile data class DownloadInfo( val jobCount: Int = 1, @@ -23,14 +20,14 @@ data class DownloadInfo( var downloadingAppIds: CopyOnWriteArrayList, ) { private var downloadJob: Job? = null - private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val downloadProgressListeners = CopyOnWriteArrayList<(Float) -> Unit>() private val progresses: Array = Array(jobCount) { 0f } - // Reservation-based persistence scheduler - private val nextWriteTime = AtomicLong(0L) - private var persistenceJob: Job? = null - private val persistenceGeneration = AtomicInteger(0) + // Single-thread executor for persistence with 10-second delay + private val persistenceExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "DownloadInfo-Persistence-$gameId").apply { isDaemon = true } + } + private var persistenceFuture: ScheduledFuture<*>? = null private val weights = FloatArray(jobCount) { 1f } // ⇐ new private var weightSum = jobCount.toFloat() @@ -46,6 +43,7 @@ data class DownloadInfo( private var emaSpeedBytesPerSec: Double = 0.0 private var hasEmaSpeed: Boolean = false private var isActive: Boolean = true + @Volatile private var currentStatusMessage: String = "" private val postInstallSyncing = MutableStateFlow(false) @@ -284,7 +282,7 @@ data class DownloadInfo( companion object { private const val PERSISTENCE_DIR = ".DownloadInfo" private const val PERSISTENCE_FILE = "bytes_downloaded.txt" - private const val PERSIST_DEBOUNCE_MS = 10_000L // 10 seconds + private const val PERSIST_DELAY_MS = 10_000L // 10 seconds } /** @@ -292,30 +290,14 @@ data class DownloadInfo( * Debounced to write at most once every 10 seconds to reduce I/O overhead. */ fun persistBytesDownloaded(appDirPath: String) { - val now = System.currentTimeMillis() - val reserved = nextWriteTime.get() - val delayMs = reserved - now - - // If we need to wait, schedule a delayed write - if (delayMs > 0) { - val currentGen = persistenceGeneration.get() - persistenceJob?.cancel() - persistenceJob = ioScope.launch { - delay(delayMs) - // Only write if generation hasn't been invalidated - if (persistenceGeneration.get() == currentGen) { - writePersistedBytes(appDirPath) - } - } - return - } + // Cancel any pending write + persistenceFuture?.cancel(false) - // Reserve the next write time and write immediately - nextWriteTime.set(now + PERSIST_DEBOUNCE_MS) - persistenceJob?.cancel() - ioScope.launch { + // Schedule a new write with 10-second delay + persistenceFuture = persistenceExecutor.schedule({ + // Only write if generation hasn't been invalidated writePersistedBytes(appDirPath) - } + }, PERSIST_DELAY_MS, TimeUnit.MILLISECONDS) } /** @@ -329,8 +311,6 @@ data class DownloadInfo( } val file = File(dir, PERSISTENCE_FILE) file.writeText(bytesDownloaded.toString()) - val now = System.currentTimeMillis() - nextWriteTime.set(now + PERSIST_DEBOUNCE_MS) } catch (e: Exception) { Timber.e(e, "Failed to persist bytes downloaded to $appDirPath") } @@ -358,9 +338,7 @@ data class DownloadInfo( * Delete the persisted bytes file (called on download completion). */ fun clearPersistedBytesDownloaded(appDirPath: String) { - // Invalidate any pending persistence to prevent recreating the file - persistenceGeneration.incrementAndGet() - persistenceJob?.cancel() + persistenceFuture?.cancel(false) try { val file = File(File(appDirPath, PERSISTENCE_DIR), PERSISTENCE_FILE) if (file.exists()) { @@ -370,4 +348,12 @@ data class DownloadInfo( Timber.e(e, "Failed to clear persisted bytes downloaded from $appDirPath") } } + + /** + * Shutdown the persistence executor. Should be called when the download is complete or cancelled. + */ + fun shutdown() { + persistenceFuture?.cancel(false) + persistenceExecutor.shutdown() + } } diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 4d105d1066..392d17fabc 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -375,6 +375,7 @@ class SteamService : Service(), IChallengeUrlChanged { fun removeDownloadJob(appId: Int) { val removed = downloadJobs.remove(appId) if (removed != null) { + removed.shutdown() notifyDownloadStopped(appId) } } diff --git a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt index 1b14cb4bad..7b1c5f012f 100644 --- a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt +++ b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt @@ -503,6 +503,7 @@ class AmazonService : Service() { } finally { instance.activeDownloads.remove(productId) instance.activeDownloadPaths.remove(productId) + downloadInfo.shutdown() PluviaApp.events.emitJava( AndroidEvent.DownloadStatusChanged(game.appId, false) ) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index e016eb6813..6c6a78269a 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -293,6 +293,7 @@ class EpicService : Service() { Timber.tag("EPIC").i("Cancelling download for Epic game: $appId") downloadInfo.cancel() instance.activeDownloads.remove(appId) + downloadInfo.shutdown() Timber.tag("EPIC").d("Download cancelled for Epic game: $appId") true } else { @@ -506,6 +507,7 @@ class EpicService : Service() { SnackbarManager.show("Download error: ${e.message ?: "Unknown error"}") } finally { instance.activeDownloads.remove(appId) + downloadInfo.shutdown() Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 003d8389f4..868f2b4c0c 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -255,6 +255,7 @@ class GOGService : Service() { Timber.i("Cancelling download for game: $gameId") downloadInfo.cancel() instance.activeDownloads.remove(gameId) + downloadInfo.shutdown() Timber.d("Download cancelled for game: $gameId") true } else { @@ -435,6 +436,7 @@ class GOGService : Service() { // Remove from activeDownloads for both success and failure // so UI knows download is complete and to prevent stale entries instance.activeDownloads.remove(gameId) + downloadInfo.shutdown() Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") } } diff --git a/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt b/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt index 4c709d9250..871bf7335e 100644 --- a/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt +++ b/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt @@ -4,6 +4,7 @@ import java.io.File import java.util.concurrent.CopyOnWriteArrayList import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -24,17 +25,27 @@ class DownloadInfoTest { testDir = tempFolder.newFolder() } + private val testInfos = mutableListOf() + @After fun cleanup() { + testInfos.forEach { it.shutdown() } + testInfos.clear() testDir.deleteRecursively() } - @Test - fun `post install sync state is tracked independently`() { + + private fun createTestInfo(): DownloadInfo { val info = DownloadInfo( jobCount = 1, gameId = 123, downloadingAppIds = CopyOnWriteArrayList(), ) + testInfos.add(info) + return info + } + @Test + fun `post install sync state is tracked independently`() { + val info = createTestInfo() assertFalse(info.isPostInstallSyncing()) @@ -45,11 +56,7 @@ class DownloadInfoTest { @Test fun `cancel clears post install sync state`() { - val info = DownloadInfo( - jobCount = 1, - gameId = 123, - downloadingAppIds = CopyOnWriteArrayList(), - ) + val info = createTestInfo() info.setPostInstallSyncing(true) info.cancel() @@ -60,23 +67,19 @@ class DownloadInfoTest { @Test fun `rapid persistence calls do not trigger multiple immediate writes`() = runBlocking { - val info = DownloadInfo( - jobCount = 1, - gameId = 123, - downloadingAppIds = CopyOnWriteArrayList(), - ) + val info = createTestInfo() info.setTotalExpectedBytes(1000L) info.updateBytesDownloaded(100L) - // First call should write immediately + // Schedule first write info.persistBytesDownloaded(testDir.absolutePath) - delay(100) - val firstValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) - assertEquals(100L, firstValue) + // File should not exist yet (10 second delay) + val initialValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(0L, initialValue) - // Rapid subsequent calls within debounce window + // Rapid subsequent calls within debounce window - each cancels the previous info.updateBytesDownloaded(50L) info.persistBytesDownloaded(testDir.absolutePath) info.updateBytesDownloaded(50L) @@ -84,32 +87,28 @@ class DownloadInfoTest { info.updateBytesDownloaded(50L) info.persistBytesDownloaded(testDir.absolutePath) - // Should still show first write value immediately + // Still no file (all writes were cancelled and rescheduled) val secondValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) - assertEquals(100L, secondValue) + assertEquals(0L, secondValue) - // Wait for debounced write to complete - delay(11_000) + // Wait for final debounced write to complete + TimeUnit.SECONDS.sleep(11) - // Now should show updated value + // Now should show final updated value val finalValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) assertEquals(250L, finalValue) } @Test fun `clearPersistedBytesDownloaded cancels pending writes`() = runBlocking { - val info = DownloadInfo( - jobCount = 1, - gameId = 123, - downloadingAppIds = CopyOnWriteArrayList(), - ) + val info = createTestInfo() info.setTotalExpectedBytes(1000L) info.updateBytesDownloaded(100L) - // Write initial value + // Schedule initial write and wait for it info.persistBytesDownloaded(testDir.absolutePath) - delay(100) + TimeUnit.SECONDS.sleep(11) val firstValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) assertEquals(100L, firstValue) @@ -126,7 +125,7 @@ class DownloadInfoTest { assertEquals(0L, clearedValue) // Wait for what would have been the delayed write - delay(11_000) + TimeUnit.SECONDS.sleep(11) // File should still be deleted (pending write was invalidated) val finalValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) @@ -135,18 +134,14 @@ class DownloadInfoTest { @Test fun `completion during debounce interval prevents file recreation`() = runBlocking { - val info = DownloadInfo( - jobCount = 1, - gameId = 123, - downloadingAppIds = CopyOnWriteArrayList(), - ) + val info = createTestInfo() info.setTotalExpectedBytes(1000L) info.updateBytesDownloaded(500L) - // Write initial progress + // Write initial progress and wait info.persistBytesDownloaded(testDir.absolutePath) - delay(100) + TimeUnit.SECONDS.sleep(11) assertEquals(500L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) @@ -159,7 +154,7 @@ class DownloadInfoTest { assertEquals(0L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) // Wait for the scheduled write delay - delay(11_000) + TimeUnit.SECONDS.sleep(11) // File should remain deleted assertEquals(0L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) From 29aa350e9704c768ba1fc7965d4d92182e26c867 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:02:07 +0800 Subject: [PATCH 06/10] bump javasteam version --- app/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2aaf62d0d..8058a004dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -331,8 +331,8 @@ dependencies { // JavaSteam val localBuild = false // Change to 'true' needed when building JavaSteam manually if (localBuild) { - implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-25-SNAPSHOT.jar")) - implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-25-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/build/libs/javasteam-1.8.0.1-26-SNAPSHOT.jar")) + implementation(files("../../JavaSteam/javasteam-depotdownloader/build/libs/javasteam-depotdownloader-1.8.0.1-26-SNAPSHOT.jar")) implementation(libs.bundles.javasteam.dev) } else { implementation(libs.javasteam) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfbdef2c29..39e75efb83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ espressoCore = "3.6.1" # https://mvnrepository.com/artifact/androidx.test.espres feature-delivery = "2.1.0" # https://mvnrepository.com/artifact/com.google.android.play/feature-delivery play-integrity = "1.6.0" # https://mvnrepository.com/artifact/com.google.android.play/integrity hiltNavigationCompose = "1.2.0" # https://mvnrepository.com/artifact/androidx.hilt/hilt-navigation-compose -javasteam = "1.8.0.1-25-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest +javasteam = "1.8.0.1-26-SNAPSHOT" # https://github.com/joshuatam/JavaSteam/tree/gamenative-latest json = "1.8.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json junit = "4.13.2" # https://mvnrepository.com/artifact/junit/junit junitVersion = "1.2.1" # https://mvnrepository.com/artifact/androidx.test.ext/junit From aed5597b7339198e979161b37af77244ff815682 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:08:40 +0800 Subject: [PATCH 07/10] addressed AI comments --- app/src/main/java/app/gamenative/service/SteamService.kt | 2 +- .../app/gamenative/service/epic/EpicDownloadManager.kt | 4 +--- .../app/gamenative/service/gog/GOGDownloadManager.kt | 9 +++------ 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 392d17fabc..dec8934bbd 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -1874,7 +1874,7 @@ class SteamService : Service(), IChallengeUrlChanged { maxDecompress = maxDecompress, parentJob = coroutineContext[Job], autoStartDownload = false, - skipLargeFileAllocation = !Paths.get(appDirPath).startsWith(Paths.get(DownloadService.baseDataDirPath)), + skipLargeFileAllocation = chunkStagingRedirectDir != null, filesystem = CaseInsensitiveFileSystem( showDebugLog = false, chunkStagingRedirect = chunkStagingRedirectDir?.absolutePath?.toPath(), diff --git a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt index aabe0fcfce..22125a93b4 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt @@ -1230,12 +1230,10 @@ class EpicDownloadManager @Inject constructor( while (remaining > 0) { val toRead = minOf(remaining, buffer.size.toLong()).toInt() val bytesRead = input.read(buffer, 0, toRead) - if (bytesRead == -1) break byteBuffer.clear() - byteBuffer.put(buffer, 0, bytesRead) - byteBuffer.flip() + byteBuffer.limit(bytesRead) while (byteBuffer.hasRemaining()) { channel.write(byteBuffer) } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt index 0257b5bf24..9e58f49af4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt @@ -1607,8 +1607,7 @@ class GOGDownloadManager @Inject constructor( while (input.read(buffer).also { bytesRead = it } != -1) { md5Digest.update(buffer, 0, bytesRead) byteBuffer.clear() - byteBuffer.put(buffer, 0, bytesRead) - byteBuffer.flip() + byteBuffer.limit(bytesRead) while (byteBuffer.hasRemaining()) { channel.write(byteBuffer) } @@ -1633,8 +1632,7 @@ class GOGDownloadManager @Inject constructor( if (count > 0) { md5Digest.update(outputBuffer, 0, count) byteBuffer.clear() - byteBuffer.put(outputBuffer, 0, count) - byteBuffer.flip() + byteBuffer.limit(count) while (byteBuffer.hasRemaining()) { channel.write(byteBuffer) } @@ -1656,8 +1654,7 @@ class GOGDownloadManager @Inject constructor( if (count > 0) { md5Digest.update(outputBuffer, 0, count) byteBuffer.clear() - byteBuffer.put(outputBuffer, 0, count) - byteBuffer.flip() + byteBuffer.limit(count) while (byteBuffer.hasRemaining()) { channel.write(byteBuffer) } From 2179580cfea6902fc5dd0af7fab23ff81bef4979 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:22:51 +0800 Subject: [PATCH 08/10] Revert "refactor DownloadInfo use newSingleThreadScheduledExecutor for writePersistedBytes" This reverts commit 690827ddfd44fe2b8f4caa816ae42de4e64bbf82. --- .../java/app/gamenative/data/DownloadInfo.kt | 68 +++++++++++------- .../app/gamenative/service/SteamService.kt | 1 - .../service/amazon/AmazonService.kt | 1 - .../gamenative/service/epic/EpicService.kt | 2 - .../app/gamenative/service/gog/GOGService.kt | 2 - .../app/gamenative/data/DownloadInfoTest.kt | 71 ++++++++++--------- 6 files changed, 79 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index c7cbfb8df6..5566a101b2 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -1,18 +1,21 @@ package app.gamenative.data import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import timber.log.Timber import java.io.File import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit -import kotlin.concurrent.Volatile +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong data class DownloadInfo( val jobCount: Int = 1, @@ -20,14 +23,14 @@ data class DownloadInfo( var downloadingAppIds: CopyOnWriteArrayList, ) { private var downloadJob: Job? = null + private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val downloadProgressListeners = CopyOnWriteArrayList<(Float) -> Unit>() private val progresses: Array = Array(jobCount) { 0f } - // Single-thread executor for persistence with 10-second delay - private val persistenceExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r -> - Thread(r, "DownloadInfo-Persistence-$gameId").apply { isDaemon = true } - } - private var persistenceFuture: ScheduledFuture<*>? = null + // Reservation-based persistence scheduler + private val nextWriteTime = AtomicLong(0L) + private var persistenceJob: Job? = null + private val persistenceGeneration = AtomicInteger(0) private val weights = FloatArray(jobCount) { 1f } // ⇐ new private var weightSum = jobCount.toFloat() @@ -43,7 +46,6 @@ data class DownloadInfo( private var emaSpeedBytesPerSec: Double = 0.0 private var hasEmaSpeed: Boolean = false private var isActive: Boolean = true - @Volatile private var currentStatusMessage: String = "" private val postInstallSyncing = MutableStateFlow(false) @@ -282,7 +284,7 @@ data class DownloadInfo( companion object { private const val PERSISTENCE_DIR = ".DownloadInfo" private const val PERSISTENCE_FILE = "bytes_downloaded.txt" - private const val PERSIST_DELAY_MS = 10_000L // 10 seconds + private const val PERSIST_DEBOUNCE_MS = 10_000L // 10 seconds } /** @@ -290,14 +292,30 @@ data class DownloadInfo( * Debounced to write at most once every 10 seconds to reduce I/O overhead. */ fun persistBytesDownloaded(appDirPath: String) { - // Cancel any pending write - persistenceFuture?.cancel(false) + val now = System.currentTimeMillis() + val reserved = nextWriteTime.get() + val delayMs = reserved - now + + // If we need to wait, schedule a delayed write + if (delayMs > 0) { + val currentGen = persistenceGeneration.get() + persistenceJob?.cancel() + persistenceJob = ioScope.launch { + delay(delayMs) + // Only write if generation hasn't been invalidated + if (persistenceGeneration.get() == currentGen) { + writePersistedBytes(appDirPath) + } + } + return + } - // Schedule a new write with 10-second delay - persistenceFuture = persistenceExecutor.schedule({ - // Only write if generation hasn't been invalidated + // Reserve the next write time and write immediately + nextWriteTime.set(now + PERSIST_DEBOUNCE_MS) + persistenceJob?.cancel() + ioScope.launch { writePersistedBytes(appDirPath) - }, PERSIST_DELAY_MS, TimeUnit.MILLISECONDS) + } } /** @@ -311,6 +329,8 @@ data class DownloadInfo( } val file = File(dir, PERSISTENCE_FILE) file.writeText(bytesDownloaded.toString()) + val now = System.currentTimeMillis() + nextWriteTime.set(now + PERSIST_DEBOUNCE_MS) } catch (e: Exception) { Timber.e(e, "Failed to persist bytes downloaded to $appDirPath") } @@ -338,7 +358,9 @@ data class DownloadInfo( * Delete the persisted bytes file (called on download completion). */ fun clearPersistedBytesDownloaded(appDirPath: String) { - persistenceFuture?.cancel(false) + // Invalidate any pending persistence to prevent recreating the file + persistenceGeneration.incrementAndGet() + persistenceJob?.cancel() try { val file = File(File(appDirPath, PERSISTENCE_DIR), PERSISTENCE_FILE) if (file.exists()) { @@ -348,12 +370,4 @@ data class DownloadInfo( Timber.e(e, "Failed to clear persisted bytes downloaded from $appDirPath") } } - - /** - * Shutdown the persistence executor. Should be called when the download is complete or cancelled. - */ - fun shutdown() { - persistenceFuture?.cancel(false) - persistenceExecutor.shutdown() - } } diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index dec8934bbd..2f6bde94aa 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -375,7 +375,6 @@ class SteamService : Service(), IChallengeUrlChanged { fun removeDownloadJob(appId: Int) { val removed = downloadJobs.remove(appId) if (removed != null) { - removed.shutdown() notifyDownloadStopped(appId) } } diff --git a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt index 7b1c5f012f..1b14cb4bad 100644 --- a/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt +++ b/app/src/main/java/app/gamenative/service/amazon/AmazonService.kt @@ -503,7 +503,6 @@ class AmazonService : Service() { } finally { instance.activeDownloads.remove(productId) instance.activeDownloadPaths.remove(productId) - downloadInfo.shutdown() PluviaApp.events.emitJava( AndroidEvent.DownloadStatusChanged(game.appId, false) ) diff --git a/app/src/main/java/app/gamenative/service/epic/EpicService.kt b/app/src/main/java/app/gamenative/service/epic/EpicService.kt index 6c6a78269a..e016eb6813 100644 --- a/app/src/main/java/app/gamenative/service/epic/EpicService.kt +++ b/app/src/main/java/app/gamenative/service/epic/EpicService.kt @@ -293,7 +293,6 @@ class EpicService : Service() { Timber.tag("EPIC").i("Cancelling download for Epic game: $appId") downloadInfo.cancel() instance.activeDownloads.remove(appId) - downloadInfo.shutdown() Timber.tag("EPIC").d("Download cancelled for Epic game: $appId") true } else { @@ -507,7 +506,6 @@ class EpicService : Service() { SnackbarManager.show("Download error: ${e.message ?: "Unknown error"}") } finally { instance.activeDownloads.remove(appId) - downloadInfo.shutdown() Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") } } diff --git a/app/src/main/java/app/gamenative/service/gog/GOGService.kt b/app/src/main/java/app/gamenative/service/gog/GOGService.kt index 868f2b4c0c..003d8389f4 100644 --- a/app/src/main/java/app/gamenative/service/gog/GOGService.kt +++ b/app/src/main/java/app/gamenative/service/gog/GOGService.kt @@ -255,7 +255,6 @@ class GOGService : Service() { Timber.i("Cancelling download for game: $gameId") downloadInfo.cancel() instance.activeDownloads.remove(gameId) - downloadInfo.shutdown() Timber.d("Download cancelled for game: $gameId") true } else { @@ -436,7 +435,6 @@ class GOGService : Service() { // Remove from activeDownloads for both success and failure // so UI knows download is complete and to prevent stale entries instance.activeDownloads.remove(gameId) - downloadInfo.shutdown() Timber.d("[Download] Finished for game $gameId, progress: ${downloadInfo.getProgress()}, active: ${downloadInfo.isActive()}") } } diff --git a/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt b/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt index 871bf7335e..4c709d9250 100644 --- a/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt +++ b/app/src/test/java/app/gamenative/data/DownloadInfoTest.kt @@ -4,7 +4,6 @@ import java.io.File import java.util.concurrent.CopyOnWriteArrayList import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -25,27 +24,17 @@ class DownloadInfoTest { testDir = tempFolder.newFolder() } - private val testInfos = mutableListOf() - @After fun cleanup() { - testInfos.forEach { it.shutdown() } - testInfos.clear() testDir.deleteRecursively() } - - private fun createTestInfo(): DownloadInfo { + @Test + fun `post install sync state is tracked independently`() { val info = DownloadInfo( jobCount = 1, gameId = 123, downloadingAppIds = CopyOnWriteArrayList(), ) - testInfos.add(info) - return info - } - @Test - fun `post install sync state is tracked independently`() { - val info = createTestInfo() assertFalse(info.isPostInstallSyncing()) @@ -56,7 +45,11 @@ class DownloadInfoTest { @Test fun `cancel clears post install sync state`() { - val info = createTestInfo() + val info = DownloadInfo( + jobCount = 1, + gameId = 123, + downloadingAppIds = CopyOnWriteArrayList(), + ) info.setPostInstallSyncing(true) info.cancel() @@ -67,19 +60,23 @@ class DownloadInfoTest { @Test fun `rapid persistence calls do not trigger multiple immediate writes`() = runBlocking { - val info = createTestInfo() + val info = DownloadInfo( + jobCount = 1, + gameId = 123, + downloadingAppIds = CopyOnWriteArrayList(), + ) info.setTotalExpectedBytes(1000L) info.updateBytesDownloaded(100L) - // Schedule first write + // First call should write immediately info.persistBytesDownloaded(testDir.absolutePath) + delay(100) - // File should not exist yet (10 second delay) - val initialValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) - assertEquals(0L, initialValue) + val firstValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) + assertEquals(100L, firstValue) - // Rapid subsequent calls within debounce window - each cancels the previous + // Rapid subsequent calls within debounce window info.updateBytesDownloaded(50L) info.persistBytesDownloaded(testDir.absolutePath) info.updateBytesDownloaded(50L) @@ -87,28 +84,32 @@ class DownloadInfoTest { info.updateBytesDownloaded(50L) info.persistBytesDownloaded(testDir.absolutePath) - // Still no file (all writes were cancelled and rescheduled) + // Should still show first write value immediately val secondValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) - assertEquals(0L, secondValue) + assertEquals(100L, secondValue) - // Wait for final debounced write to complete - TimeUnit.SECONDS.sleep(11) + // Wait for debounced write to complete + delay(11_000) - // Now should show final updated value + // Now should show updated value val finalValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) assertEquals(250L, finalValue) } @Test fun `clearPersistedBytesDownloaded cancels pending writes`() = runBlocking { - val info = createTestInfo() + val info = DownloadInfo( + jobCount = 1, + gameId = 123, + downloadingAppIds = CopyOnWriteArrayList(), + ) info.setTotalExpectedBytes(1000L) info.updateBytesDownloaded(100L) - // Schedule initial write and wait for it + // Write initial value info.persistBytesDownloaded(testDir.absolutePath) - TimeUnit.SECONDS.sleep(11) + delay(100) val firstValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) assertEquals(100L, firstValue) @@ -125,7 +126,7 @@ class DownloadInfoTest { assertEquals(0L, clearedValue) // Wait for what would have been the delayed write - TimeUnit.SECONDS.sleep(11) + delay(11_000) // File should still be deleted (pending write was invalidated) val finalValue = info.loadPersistedBytesDownloaded(testDir.absolutePath) @@ -134,14 +135,18 @@ class DownloadInfoTest { @Test fun `completion during debounce interval prevents file recreation`() = runBlocking { - val info = createTestInfo() + val info = DownloadInfo( + jobCount = 1, + gameId = 123, + downloadingAppIds = CopyOnWriteArrayList(), + ) info.setTotalExpectedBytes(1000L) info.updateBytesDownloaded(500L) - // Write initial progress and wait + // Write initial progress info.persistBytesDownloaded(testDir.absolutePath) - TimeUnit.SECONDS.sleep(11) + delay(100) assertEquals(500L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) @@ -154,7 +159,7 @@ class DownloadInfoTest { assertEquals(0L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) // Wait for the scheduled write delay - TimeUnit.SECONDS.sleep(11) + delay(11_000) // File should remain deleted assertEquals(0L, info.loadPersistedBytesDownloaded(testDir.absolutePath)) From d205b889a69e1c2279efdd3a3829f8df5209a220 Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:26:34 +0800 Subject: [PATCH 09/10] make currentStatusMessage Volatile --- app/src/main/java/app/gamenative/data/DownloadInfo.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index 5566a101b2..48eaa92744 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -13,9 +13,9 @@ import kotlinx.coroutines.flow.StateFlow import timber.log.Timber import java.io.File import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.Volatile data class DownloadInfo( val jobCount: Int = 1, @@ -46,6 +46,7 @@ data class DownloadInfo( private var emaSpeedBytesPerSec: Double = 0.0 private var hasEmaSpeed: Boolean = false private var isActive: Boolean = true + @Volatile private var currentStatusMessage: String = "" private val postInstallSyncing = MutableStateFlow(false) From f43c1e1026595943c06f7cab3b8d2011757c7c2b Mon Sep 17 00:00:00 2001 From: Joshua Tam <297250+joshuatam@users.noreply.github.com> Date: Sun, 2 Aug 2026 02:40:45 +0800 Subject: [PATCH 10/10] remove unnecessary change --- app/src/main/java/app/gamenative/service/SteamService.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 2f6bde94aa..dfddb2d09a 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -1343,8 +1343,6 @@ class SteamService : Service(), IChallengeUrlChanged { } File(appDirPath).deleteRecursively() - - true } // Remove from DB