Skip to content
Merged
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
61 changes: 57 additions & 4 deletions app/src/main/java/app/gamenative/data/DownloadInfo.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
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,
val gameId: Int,
var downloadingAppIds: CopyOnWriteArrayList<Int>,
) {
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<Float> = 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()

Expand All @@ -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<String?>(null)
@Volatile
private var currentStatusMessage: String = ""
Comment thread
joshuatam marked this conversation as resolved.
private val postInstallSyncing = MutableStateFlow(false)

fun cancel() {
Expand Down Expand Up @@ -137,10 +152,11 @@ data class DownloadInfo(
}

fun updateStatusMessage(message: String?) {
statusMessage.value = message
currentStatusMessage = message ?: ""
emitProgressChange()
}

fun getStatusMessageFlow(): StateFlow<String?> = statusMessage
fun getCurrentStatusMessage(): String = currentStatusMessage

fun setPostInstallSyncing(syncing: Boolean) {
postInstallSyncing.value = syncing
Expand Down Expand Up @@ -269,19 +285,53 @@ 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()
Comment thread
joshuatam marked this conversation as resolved.
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)
Comment thread
joshuatam marked this conversation as resolved.
persistenceJob?.cancel()
ioScope.launch {
Comment thread
joshuatam marked this conversation as resolved.
Comment thread
joshuatam marked this conversation as resolved.
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()) {
dir.mkdirs()
}
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")
}
Expand Down Expand Up @@ -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()) {
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Comment thread
joshuatam marked this conversation as resolved.
Comment thread
joshuatam marked this conversation as resolved.
channel.write(byteBuffer)
}
Comment thread
joshuatam marked this conversation as resolved.
remaining -= bytesRead
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment thread
joshuatam marked this conversation as resolved.
byteBuffer.limit(bytesRead)
while (byteBuffer.hasRemaining()) {
channel.write(byteBuffer)
}
totalBytesWritten += bytesRead
}
}
Expand All @@ -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) {
Expand All @@ -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()) {
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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())
Comment thread
joshuatam marked this conversation as resolved.
val isRunning = info.isActive() || info.isPostInstallSyncing()
val status = when {
rawProgress < 0f || statusMessage?.startsWith("Failed", ignoreCase = true) == true -> DownloadItemStatus.FAILED
Expand Down Expand Up @@ -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)
Expand All @@ -431,7 +423,6 @@ class DownloadsViewModel @Inject constructor(
observedDownloads[key] = ObservedDownload(
info = binding.info,
progressListener = progressListener,
statusJob = statusJob,
syncingJob = syncingJob,
)
}
Expand Down

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @joshuatam - are the changes in this file improving the speed as well? If not I think we should undo it.

If it is for fixing GOG, we can fix the issue in GOGDownloadManager.kt:937 - set the status without the separate setProgress call so a chunk costs one emit. Thoughts?

@joshuatam joshuatam Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @joshuatam - are the changes in this file improving the speed as well? If not I think we should undo it.

If it is for fixing GOG, we can fix the issue in GOGDownloadManager.kt:937 - set the status without the separate setProgress call so a chunk costs one emit. Thoughts?

It is needed as DownloadInfo changed the way to emit progress.

If you check DownloadsViewModel.kt carefully, the same getStatusMessageFlow() is consuming twice, together with the same call in LibraryAppScreen, there are totally 3 locations listen to the same progress. Changing to listener pattern can make the UI smooth and reduce the chance on updating the same UI elements simultaneously in DownloadsViewModel.kt (could be somehow causing ANR)

If you concern about the mention in this https://github.com/utkarshdalal/GameNative/pull/1785/changes/BASE..7d00f1758010abaeb4a294a411d20594498bcf12#r3695608006.
I would say it is already handled by progressListener https://github.com/utkarshdalal/GameNative/pull/1785/changes/BASE..7d00f1758010abaeb4a294a411d20594498bcf12#diff-27870a3ee8b1cca032461ee90916f564946eb57c690cd5b7cb57e95b64031427R626

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Comment thread
joshuatam marked this conversation as resolved.
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"
Comment thread
joshuatam marked this conversation as resolved.
Comment thread
joshuatam marked this conversation as resolved.
} else if (isDownloading && downloadProgress >= 1f) {
"Unpacking..."
} else if (downloadProgress in 0f..1f && downloadProgress < 1f) {
downloadStatusMessage?.takeUnless { it.isBlank() } ?: ""
} else {
""
}
}
}
Comment thread
joshuatam marked this conversation as resolved.

LaunchedEffect(displayInfo.appId) {
scrollState.animateScrollTo(0)
}
Expand All @@ -599,6 +622,16 @@ internal fun AppScreenContent(
playButtonFocusRequester.requestFocus()
}

LaunchedEffect(downloadInfo) {
Comment thread
joshuatam marked this conversation as resolved.
Comment thread
joshuatam marked this conversation as resolved.
Comment thread
joshuatam marked this conversation as resolved.
Comment thread
joshuatam marked this conversation as resolved.
downloadInfo?.addProgressListener(progressListener)
Comment thread
joshuatam marked this conversation as resolved.
}
Comment thread
joshuatam marked this conversation as resolved.

DisposableEffect(Unit) {
onDispose {
downloadInfo?.removeProgressListener(progressListener)
}
}

// Restore focus when options menu, dialogs
LaunchedEffect(optionsMenuVisible, dialogOpen) {
if (!optionsMenuVisible && !dialogOpen) {
Expand Down Expand Up @@ -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<String?>(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) {
Expand Down
Loading
Loading