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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
525 changes: 409 additions & 116 deletions app/src/main/java/app/gamenative/data/DownloadInfo.kt

Large diffs are not rendered by default.

1,325 changes: 1,009 additions & 316 deletions app/src/main/java/app/gamenative/service/SteamService.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ class AmazonService : Service() {
)
downloadInfo.setPersistencePath(installPath)

val persistedBytes = downloadInfo.loadPersistedBytesDownloaded(installPath)
val persistedBytes = DownloadInfo.loadPersistedResumeSnapshot(installPath)?.bytesDownloaded ?: 0L
if (persistedBytes > 0L) {
downloadInfo.initializeBytesDownloaded(persistedBytes)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
Expand Down Expand Up @@ -83,6 +84,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -238,8 +240,10 @@ private fun PrimaryActionButton(
)
Text(
text = "${(downloadProgress * 100).toInt()}%",
modifier = Modifier.widthIn(min = 40.dp),
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
color = Color.White,
textAlign = TextAlign.End,
)
}
} else {
Expand Down Expand Up @@ -455,13 +459,50 @@ private fun formatBytes(bytes: Long): String {
val mb = kb * 1024
val gb = mb * 1024
return when {
bytes >= gb -> String.format("%.1f GB", bytes / gb)
bytes >= mb -> String.format("%.1f MB", bytes / mb)
bytes >= kb -> String.format("%.1f KB", bytes / kb)
bytes >= gb -> String.format("%.2f GB", bytes / gb)
bytes >= mb -> String.format("%.2f MB", bytes / mb)
bytes >= kb -> String.format("%.2f KB", bytes / kb)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else -> "$bytes B"
}
}

// Formats network speed in bits per second using decimal units
private fun formatNetworkSpeed(bytesPerSecond: Long): String {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
val bitsPerSecond = (bytesPerSecond.coerceAtLeast(0L).toDouble() * 8.0)
val kb = 1000.0
val mb = kb * 1000.0
val gb = mb * 1000.0
return when {
bitsPerSecond >= gb -> String.format("%.1f Gb/s", bitsPerSecond / gb)
bitsPerSecond >= mb -> String.format("%.1f Mb/s", bitsPerSecond / mb)
bitsPerSecond >= kb -> String.format("%.1f Kb/s", bitsPerSecond / kb)
else -> "${bitsPerSecond.toLong()} b/s"
}
}

private fun formatStableEtaText(etaMs: Long): String {
val totalSeconds = ((etaMs + 999L) / 1000L).coerceAtLeast(0L)
val minutesLeft = totalSeconds / 60L
val secondsPart = totalSeconds % 60L
return "${minutesLeft}m ${secondsPart}s"
}
Comment on lines +483 to +488
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid hardcoded/non-localized ETA and placeholder text.

The new "Xm Ys left" and "XX" placeholders are user-facing but not localized. Please move these to string resources so they translate correctly.

💡 Suggested direction
-private fun formatStableEtaText(etaMs: Long): String {
+private fun formatStableEtaText(context: Context, etaMs: Long): String {
     val totalSeconds = ((etaMs + 999L) / 1000L).coerceAtLeast(0L)
     val minutesLeft = totalSeconds / 60L
     val secondsPart = totalSeconds % 60L
-    return "${minutesLeft}m ${secondsPart}s left"
+    return context.getString(R.string.library_download_eta_minutes_seconds_left, minutesLeft, secondsPart)
 }

And replace "0 / XX"-style literals with resource-backed strings (e.g., unknown-total variants).

Also applies to: 631-636

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt`
around lines 480 - 485, The ETA and placeholder text are hardcoded; update
formatStableEtaText to return a localized string resource (use context.getString
or stringResource with either a plural or formatted string like "%1$d m %2$d s
left") instead of inlining "${minutesLeft}m ${secondsPart}s left", and replace
any "0 / XX" or similar unknown-total placeholders (the other occurrences noted
around the "0 / XX" pattern) with resource-backed strings (including an "unknown
total" variant) so UI text is localizable and translatable.


private fun phaseStringResId(phase: app.gamenative.data.DownloadPhase): Int {
return when (phase) {
app.gamenative.data.DownloadPhase.UNKNOWN -> R.string.library_download_phase_downloading
app.gamenative.data.DownloadPhase.PREPARING -> R.string.library_download_phase_preparing
app.gamenative.data.DownloadPhase.DOWNLOADING -> R.string.library_download_phase_downloading
app.gamenative.data.DownloadPhase.DECOMPRESSING -> R.string.library_download_phase_decompressing
app.gamenative.data.DownloadPhase.PAUSED -> R.string.library_download_phase_paused
app.gamenative.data.DownloadPhase.FAILED -> R.string.library_download_phase_failed
app.gamenative.data.DownloadPhase.VERIFYING -> R.string.library_download_phase_verifying
app.gamenative.data.DownloadPhase.PATCHING -> R.string.library_download_phase_patching
app.gamenative.data.DownloadPhase.APPLYING_DATA -> R.string.library_download_phase_applying_data
app.gamenative.data.DownloadPhase.FINALIZING -> R.string.library_download_phase_finalizing
app.gamenative.data.DownloadPhase.COMPLETE -> R.string.library_download_phase_downloading
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 1, 2026

Choose a reason for hiding this comment

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

P2: COMPLETE phase incorrectly mapped to 'downloading' label - missing dedicated complete state string

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt, line 498:

<comment>COMPLETE phase incorrectly mapped to 'downloading' label - missing dedicated complete state string</comment>

<file context>
@@ -452,13 +456,49 @@ private fun formatBytes(bytes: Long): String {
+        app.gamenative.data.DownloadPhase.PATCHING -> R.string.library_download_phase_patching
+        app.gamenative.data.DownloadPhase.APPLYING_DATA -> R.string.library_download_phase_applying_data
+        app.gamenative.data.DownloadPhase.FINALIZING -> R.string.library_download_phase_finalizing
+        app.gamenative.data.DownloadPhase.COMPLETE -> R.string.library_download_phase_downloading
+    }
+}
</file context>
Fix with Cubic

}
Comment on lines +490 to +503
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Map DownloadPhase.COMPLETE to a completed/finalizing label, not downloading.

Current mapping shows library_download_phase_downloading for COMPLETE, which can display an incorrect phase at end-of-download transitions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/library/LibraryAppScreen.kt`
around lines 490 - 503, In phaseStringResId, change the mapping for
app.gamenative.data.DownloadPhase.COMPLETE so it doesn't return
R.string.library_download_phase_downloading; update it to return the appropriate
completed/finalizing label (e.g., R.string.library_download_phase_finalizing or
R.string.library_download_phase_complete if that string resource exists) to
ensure COMPLETE shows a finished phase; modify the return for
DownloadPhase.COMPLETE in phaseStringResId accordingly.

}

@Composable
internal fun AppScreenContent(
modifier: Modifier = Modifier,
Expand Down Expand Up @@ -532,36 +573,86 @@ internal fun AppScreenContent(
}

// Download progress texts hoisted here so they can be shown inside the button
val downloadStateFlow = remember(downloadInfo) { downloadInfo?.getStatusFlow() }
val downloadState by (
downloadStateFlow?.collectAsState(initial = downloadStateFlow.value)
?: remember { mutableStateOf<app.gamenative.data.DownloadPhase?>(null) }
)
val downloadStatus = downloadState ?: app.gamenative.data.DownloadPhase.UNKNOWN

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, 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 (downloadProgress in 0f..1f && downloadProgress < 1f) {
downloadStatusMessage?.takeUnless { it.isBlank() } ?: ""

// Auto ticking ETA & Speed
var etaTick by remember { mutableStateOf(0L) }
LaunchedEffect(isDownloading, downloadStatus) {
// Only tick and recalculate ETA & speed if we are actively downloading/verifying
// to avoid unnecessary calculations and UI recompositions when paused/failed
val isActivePhase = downloadStatus == app.gamenative.data.DownloadPhase.DOWNLOADING ||
downloadStatus == app.gamenative.data.DownloadPhase.DECOMPRESSING ||
downloadStatus == app.gamenative.data.DownloadPhase.VERIFYING

while (isDownloading && isActivePhase) {
etaTick = System.currentTimeMillis()
kotlinx.coroutines.delay(1000L)
}
}

val (networkSpeedText, downloadTimeLeftText) = remember(displayInfo.appId, downloadProgress, downloadInfo, etaTick) {
val speedBytes = downloadInfo?.getCurrentDownloadSpeed() ?: 0L

val speedText = if (speedBytes > 0L) {
formatNetworkSpeed(speedBytes)
} else {
""
}

val etaMs = downloadInfo?.getEstimatedTimeRemaining()
val displayEtaMs = if (etaMs != null && etaMs > 0L) {
etaMs
} else {
val (bytesDone, bytesTotal) = downloadInfo?.getBytesProgress() ?: (0L to 0L)
if (speedBytes > 0L && bytesTotal > bytesDone) {
(((bytesTotal - bytesDone).toDouble() / speedBytes) * 1000.0).toLong()
} else {
null
}
}

val etaText = if (displayEtaMs != null) {
formatStableEtaText(displayEtaMs)
} else {
null
}

Pair(speedText, etaText)
}

val downloadSizeText = remember(displayInfo.gameId, downloadProgress, downloadInfo) {
val (bytesDone, bytesTotal) = downloadInfo?.getBytesProgress() ?: (0L to 0L)
if (bytesTotal > 0L) {
"${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)}"
} else if (bytesDone > 0L) {
formatBytes(bytesDone)
} else {
downloadingLabel
when {
bytesDone <= 0L && bytesTotal > 0L -> "0 B / ${formatBytes(bytesTotal)}"
bytesDone <= 0L -> "0 / XX"
bytesTotal > 0L -> "${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)}"
else -> "${formatBytes(bytesDone)} / XX"
}
}

val phaseText = remember(displayInfo.gameId, downloadProgress, downloadInfo, downloadStatus) {
val (bytesDone, _) = downloadInfo?.getBytesProgress() ?: (0L to 0L)
if (downloadProgress >= 0.99f && (downloadStatus == app.gamenative.data.DownloadPhase.DOWNLOADING || downloadStatus == app.gamenative.data.DownloadPhase.DECOMPRESSING)) {
// Can't invoke @Composable stringResource inside remember directly, but we can return the ID
R.string.library_download_phase_finalizing
} else if (bytesDone == 0L && downloadStatus == app.gamenative.data.DownloadPhase.DOWNLOADING) {
R.string.library_download_phase_preparing
} else {
phaseStringResId(downloadStatus)
}
}.let { stringResource(it) }

// Handle gamepad button presses
val handleKeyEvent: (KeyEvent) -> Boolean = { event ->
if (event.action == KeyEvent.ACTION_DOWN) {
Expand Down Expand Up @@ -742,6 +833,7 @@ internal fun AppScreenContent(
// Primary action button (left-aligned)
if (isDownloading || hasPartialDownload) {
PrimaryActionButton(
modifier = Modifier.widthIn(min = 210.dp),
text = if (isDownloading) {
stringResource(R.string.pause_download)
} else {
Expand All @@ -762,6 +854,7 @@ internal fun AppScreenContent(
else -> stringResource(R.string.install_app)
}
PrimaryActionButton(
modifier = Modifier.widthIn(min = 210.dp),
text = text,
onClick = onDownloadInstallClick,
enabled = buttonEnabled,
Expand All @@ -779,23 +872,60 @@ internal fun AppScreenContent(
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.Center,
) {
if (downloadSizeText.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = downloadSizeText,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = phaseText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (downloadTimeLeftText.isNotEmpty()) {
val statusMsg = downloadStatusMessage
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = downloadTimeLeftText,
text = networkSpeedText,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.65f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.Ellipsis
)
if (downloadTimeLeftText != null) {
Text(
text = downloadTimeLeftText,
modifier = Modifier.widthIn(min = 80.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.65f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else if (!statusMsg.isNullOrBlank()) {
Text(
text = statusMsg,
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.65f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,9 @@ override fun isInstalled(context: Context, libraryItem: LibraryItem): Boolean =
}
}

override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) {
override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem, shouldPause: Boolean) {
val appId = libraryItem.gameId
if (AmazonService.getDownloadInfoByAppId(appId) != null) {
if (shouldPause) {
Timber.tag(TAG).i("Cancelling download for appId=$appId")
AmazonService.cancelDownloadByAppId(appId)
} else {
Comment on lines +304 to 309
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Avoid synchronous pause cancellation on the UI thread.

Line 308 calls AmazonService.cancelDownloadByAppId(appId) directly from the click path. That call reaches DownloadInfo.cancel() and forces snapshot file writes, which can block the main thread.

Suggested fix
 override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem, shouldPause: Boolean) {
     val appId = libraryItem.gameId
     if (shouldPause) {
-        Timber.tag(TAG).i("Cancelling download for appId=$appId")
-        AmazonService.cancelDownloadByAppId(appId)
+        CoroutineScope(Dispatchers.IO).launch {
+            Timber.tag(TAG).i("Cancelling download for appId=$appId")
+            val cancelled = AmazonService.cancelDownloadByAppId(appId)
+            if (!cancelled) {
+                withContext(Dispatchers.Main) {
+                    Toast.makeText(context, "Failed to pause download", Toast.LENGTH_SHORT).show()
+                }
+            }
+        }
     } else {
         // Resume paused/cancelled download directly — no confirmation dialog
         performDownload(context, libraryItem)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/app/gamenative/ui/screen/library/appscreen/AmazonAppScreen.kt`
around lines 304 - 309, The click handler onPauseResumeClick currently calls
AmazonService.cancelDownloadByAppId(appId) synchronously which leads into
DownloadInfo.cancel() and file I/O on the main thread; change this to perform
the cancellation off the UI thread by dispatching the call to a background
executor or coroutine on an IO dispatcher (e.g., use
lifecycleScope/CoroutineScope with Dispatchers.IO or an injected Executor) so
the UI click path only schedules the work and does not block, making sure to
keep the same parameters (appId) and preserve any logging (Timber.tag(TAG).i)
and error handling around AmazonService.cancelDownloadByAppId.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ abstract class BaseAppScreen {
/**
* Handle pause/resume download click
*/
abstract fun onPauseResumeClick(context: Context, libraryItem: LibraryItem)
abstract fun onPauseResumeClick(context: Context, libraryItem: LibraryItem, shouldPause: Boolean)

/**
* Handle delete download click
Expand Down Expand Up @@ -676,9 +676,10 @@ abstract class BaseAppScreen {
}
},
onPauseResumeClick = {
onPauseResumeClick(context, libraryItem)
val shouldPause = isDownloadingState
onPauseResumeClick(context, libraryItem, shouldPause)
uiScope.launch {
delay(100)
delay(if (shouldPause) 1400L else 100L)
performStateRefresh(false)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ class CustomGameAppScreen : BaseAppScreen() {
PluviaApp.events.emit(AndroidEvent.ExternalGameLaunch(libraryItem.appId))
}

override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) {
override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem, shouldPause: Boolean) {
// Not applicable for Custom Games
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,10 +430,10 @@ class EpicAppScreen : BaseAppScreen() {
}
}

override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) {
override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem, shouldPause: Boolean) {
Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}")

if (isDownloading(context, libraryItem)) {
if (shouldPause) {
val downloadInfo = EpicService.getDownloadInfo(libraryItem.gameId)
// Cancel/pause download
Timber.tag(TAG).i("Pausing Epic download: ${libraryItem.gameId}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,13 @@ class GOGAppScreen : BaseAppScreen() {
}
}

override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem) {
override fun onPauseResumeClick(context: Context, libraryItem: LibraryItem, shouldPause: Boolean) {
Timber.tag(TAG).i("onPauseResumeClick: appId=${libraryItem.appId}")
val gameId = libraryItem.gameId.toString()
val downloadInfo = GOGService.getDownloadInfo(gameId)
val isDownloading = isDownloading(context, libraryItem)

if (isDownloading) {
if (shouldPause && downloadInfo != null) {
Timber.tag(TAG).i("Cancelling GOG download: ${libraryItem.appId}")
downloadInfo?.cancel()
GOGService.cleanupDownload(gameId)
Expand Down
Loading