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/app/src/main/java/app/gamenative/data/DownloadInfo.kt b/app/src/main/java/app/gamenative/data/DownloadInfo.kt index 2bfabf8935..48eaa92744 100644 --- a/app/src/main/java/app/gamenative/data/DownloadInfo.kt +++ b/app/src/main/java/app/gamenative/data/DownloadInfo.kt @@ -1,13 +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.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.Volatile data class DownloadInfo( val jobCount: Int = 1, @@ -15,9 +23,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 } + // 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() @@ -32,7 +46,8 @@ data class DownloadInfo( private var emaSpeedBytesPerSec: Double = 0.0 private var hasEmaSpeed: Boolean = false private var isActive: Boolean = true - private val statusMessage = MutableStateFlow(null) + @Volatile + private var currentStatusMessage: String = "" private val postInstallSyncing = MutableStateFlow(false) fun cancel() { @@ -137,10 +152,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 +285,44 @@ 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 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 + } + + // Reserve the next write time and write immediately + nextWriteTime.set(now + PERSIST_DEBOUNCE_MS) + persistenceJob?.cancel() + 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 +330,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") } @@ -309,6 +359,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/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index ea2ab69d6d..d56158a526 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -1889,6 +1889,7 @@ class SteamService : Service(), IChallengeUrlChanged { maxDecompress = maxDecompress, parentJob = coroutineContext[Job], autoStartDownload = false, + 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 b98e7b35ab..22125a93b4 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 @@ -1214,19 +1216,27 @@ 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) { val toRead = minOf(remaining, buffer.size.toLong()).toInt() val bytesRead = input.read(buffer, 0, toRead) - if (bytesRead == -1) break - randomAccessFile.write(buffer, 0, bytesRead) + byteBuffer.clear() + byteBuffer.limit(bytesRead) + 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 20457fd8e6..9e58f49af4 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 @@ -1587,17 +1590,27 @@ 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.limit(bytesRead) + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer) + } totalBytesWritten += bytesRead } } @@ -1608,6 +1621,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 +1631,11 @@ 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.limit(count) + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer) + } totalBytesWritten += count } else { if (inflater.needsDictionary()) { @@ -1635,7 +1653,11 @@ 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.limit(count) + while (byteBuffer.hasRemaining()) { + channel.write(byteBuffer) + } totalBytesWritten += count } else { if (inflater.needsInput()) { 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/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)) + } }