Skip to content

[Draft] feat: Add Download Manager#858

Open
eredisg wants to merge 10 commits intoutkarshdalal:masterfrom
eredisg:feat/add-download-manager
Open

[Draft] feat: Add Download Manager#858
eredisg wants to merge 10 commits intoutkarshdalal:masterfrom
eredisg:feat/add-download-manager

Conversation

@eredisg
Copy link

@eredisg eredisg commented Mar 14, 2026

image image

Features

  • View downloads across all supported platforms (Steam, GOG, Epic, Amazon) with the ability to pause and delete downloads all in one view
  • Added partial_install column to GOG, Epic, and Amazon to easily track pending/partially downloaded games.

In discussion with @xXJSONDeruloXx, we are looking to combine #816 into a two pane view.


Summary by cubic

Add a unified Downloads Manager to track and control active and partial downloads across Steam, GOG, Epic, and Amazon. Open it from the Library system menu; includes pause, resume (for partials), and cancel with confirmation.

  • New Features

    • New UI: DownloadsScreen + DownloadsViewModel + DownloadsState. Shows progress, bytes, ETA, status. Refreshes every second. Pause/resume and cancel with confirm.
    • Platform integration: SteamService, EpicService, GOGService, AmazonService expose getActiveDownloads() and getPartialDownloads(). EpicDownloadManager, GOGDownloadManager, AmazonDownloadManager set/clear partials on start/finish.
    • Persistence: Added partial_install to epic_games, gog_games, amazon_games. DAOs surface getPartialDownloads() and retain the flag on upserts/updates.
    • Navigation: Downloads entry in the Library system menu. HomeScreen routes HomeDestination.Downloads with a back button.
    • UX: Empty state and cancel-confirm copy. Small styling tweaks for readability. Minor cleanup to remove duplicate UI logic.
  • Migration

    • Room DB bumped to v14 with auto-migration (13 → 14) adding partial_install. No manual steps.

Written for commit 3bca522. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Added a Downloads screen to monitor/manage downloads across providers.
    • View progress, speed, ETA and sizes; pause, resume, and cancel with confirmation.
    • Partially downloaded games are tracked separately from completed installs.
    • Downloads accessible from the Library menu and via new Downloads navigation/back flow.
    • New empty-state and cancel-confirmation messages for downloads.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 14, 2026

📝 Walkthrough

Walkthrough

This PR adds partial-download tracking across platforms by introducing a partialInstall boolean to game entities, updating the Room schema (DB v13→14 with auto-migration), extending DAOs/managers/services to read/write partial state, and adding a unified Downloads UI and DownloadsViewModel to surface and manage active/partial downloads.

Changes

Cohort / File(s) Summary
Entity schema
app/src/main/java/app/gamenative/data/AmazonGame.kt, app/src/main/java/app/gamenative/data/EpicGame.kt, app/src/main/java/app/gamenative/data/GOGGame.kt
Added partialInstall: Boolean with @ColumnInfo(name = "partial_install", defaultValue = "0") to entities (constructor/signature change).
Database
app/src/main/java/app/gamenative/db/PluviaDatabase.kt
Bumped Room DB version 13→14 and added AutoMigration(from = 13, to = 14) to add partial_install columns.
DAOs
app/src/main/java/app/gamenative/db/dao/AmazonGameDao.kt, .../EpicGameDao.kt, .../GOGGameDao.kt, .../DownloadingAppInfoDao.kt
Added getPartialDownloads() queries, getNonInstalledGames() where applicable, markAsPartialInstall() mutation(s), preserved partialInstall in upserts, and added DownloadingAppInfoDao.getAll(). Updated uninstall/install queries to clear/set partial_install.
Managers
app/src/main/java/app/gamenative/service/amazon/AmazonManager.kt, .../epic/EpicManager.kt, .../gog/GOGManager.kt
Added getPartialDownloads() wrappers delegating to DAOs; GOG deletion/uninstall logic updated to consider and clear partialInstall.
Services (accessors)
app/src/main/java/app/gamenative/service/SteamService.kt, .../amazon/AmazonService.kt, .../epic/EpicService.kt, .../gog/GOGService.kt
Added companion accessors: getActiveDownloads() and getPartialDownloads() (provider-specific) to expose active and pending downloads.
Download managers
app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt, .../epic/EpicDownloadManager.kt, .../gog/GOGDownloadManager.kt
Mark game as partial (partialInstall = true) on download start and clear (partialInstall = false) on successful finalize/install.
UI state & viewmodel
app/src/main/java/app/gamenative/ui/data/DownloadsState.kt, app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt
Added DownloadItemState, CancelConfirmation, DownloadsState; implemented DownloadsViewModel with periodic polling, metadata caching, StateFlow state, and pause/resume/cancel workflows with provider-specific cleanup.
Screens & navigation
app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt, app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt, .../library/LibraryScreen.kt
Added HomeDownloadsScreen composable and integrated Downloads destination; Library/Home UI updated to expose onDownloadsClick and navigate to Downloads.
UI components & resources
app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt, .../LibraryTabBar.kt, app/src/main/res/values/strings.xml
Added Downloads menu item and icon import; added downloads_empty and downloads_cancel_confirm strings.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as DownloadsScreen
    participant VM as DownloadsViewModel
    participant Service as Service Layer
    participant DAO as DAO Layer
    participant DB as Database

    User->>UI: Open Downloads screen
    UI->>VM: Subscribe state flow
    VM->>VM: Start polling loop
    loop Poll
        VM->>Service: getActiveDownloads()
        Service-->>VM: Map<id, DownloadInfo>
        VM->>Service: getPartialDownloads()
        Service->>DAO: getPartialDownloads()
        DAO->>DB: SELECT WHERE partial_install=1
        DB-->>DAO: List<Game>
        DAO-->>Service: List<Game>
        Service-->>VM: List<id>
        VM->>VM: Build DownloadsState
        VM-->>UI: Emit state
    end
    UI->>VM: onCancelDownload(id)
    VM->>VM: show CancelConfirmation
    UI->>VM: onConfirmCancel()
    VM->>Service: cancel download / delete files
    Service->>DAO: update/clear install fields
    DAO->>DB: UPDATE/DELETE
    VM->>VM: refresh/poll
    VM-->>UI: updated state
Loading
sequenceDiagram
    participant DM as DownloadManager
    participant Manager as PlatformManager
    participant DAO as DAO
    participant DB as Database

    DM->>Manager: download started for appId
    Manager->>DAO: markAsPartialInstall(appId)
    DAO->>DB: UPDATE partial_install=1
    DB-->>DAO: OK

    DM->>Manager: download completed
    Manager->>DAO: markAsInstalled(appId, metadata)
    DAO->>DB: UPDATE is_installed=1, partial_install=0, install_path=..., install_size=...
    DB-->>DAO: OK
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Hoppity-hop, downloads begin,

Partial flags mark where we've been,
Polling steady, progress glows,
Pause, resume — the list still grows,
Library learns what's underway, hooray for the downloads day!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '[Draft] feat: Add Download Manager' accurately describes the main feature addition in the PR—implementing a unified Downloads Manager across Steam, GOG, Epic, and Amazon.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can enable review details to help with troubleshooting, context usage and more.

Enable the reviews.review_details setting to include review details such as the model used, the time taken for each step and more in the review comments.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

4 issues found across 26 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt:87">
P2: `partialInstall` is set too early and is not cleared on many early failure returns, leaving stale partial-download state.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt:55">
P2: pollDownloads is triggered from multiple independent coroutines (event callback and 1s loop) without serialization. Overlapping polls can finish out of order and overwrite newer download state with stale snapshots.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/data/DownloadsState.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/data/DownloadsState.kt:26">
P1: Downloads map is keyed only by `String` even though identity is `(gameSource, appId)`, allowing cross-source key collisions and overwrites.</violation>
</file>

<file name="app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt">

<violation number="1" location="app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt:383">
P2: Gen 1 (legacy) downloads return before the new partialInstall=true update, so partial installs from Gen 1 are never tracked in the DB and can be missed by the Downloads manager.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

)

data class DownloadsState(
val downloads: Map<String, DownloadItemState> = emptyMap<String, DownloadItemState>(),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 14, 2026

Choose a reason for hiding this comment

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

P1: Downloads map is keyed only by String even though identity is (gameSource, appId), allowing cross-source key collisions and overwrites.

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/data/DownloadsState.kt, line 26:

<comment>Downloads map is keyed only by `String` even though identity is `(gameSource, appId)`, allowing cross-source key collisions and overwrites.</comment>

<file context>
@@ -0,0 +1,28 @@
+)
+
+data class DownloadsState(
+    val downloads: Map<String, DownloadItemState> = emptyMap<String, DownloadItemState>(),
+    val cancelConfirmation: CancelConfirmation? = null,
+)
</file context>
Fix with Cubic

MarkerUtils.addMarker(installPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)

// Mark as partial install in DB so Downloads screen can detect it
epicManager.updateGame(game.copy(partialInstall = true))
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 14, 2026

Choose a reason for hiding this comment

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

P2: partialInstall is set too early and is not cleared on many early failure returns, leaving stale partial-download state.

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/service/epic/EpicDownloadManager.kt, line 87:

<comment>`partialInstall` is set too early and is not cleared on many early failure returns, leaving stale partial-download state.</comment>

<file context>
@@ -83,6 +83,9 @@ class EpicDownloadManager @Inject constructor(
             MarkerUtils.addMarker(installPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)
 
+            // Mark as partial install in DB so Downloads screen can detect it
+            epicManager.updateGame(game.copy(partialInstall = true))
+
             // Emit download started event so UI can attach progress listeners
</file context>
Fix with Cubic

private val gameIconCache = ConcurrentHashMap<String, String>()

private val onDownloadStatusChanged: (AndroidEvent.DownloadStatusChanged) -> Unit = {
viewModelScope.launch(Dispatchers.IO) { pollDownloads() }
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 14, 2026

Choose a reason for hiding this comment

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

P2: pollDownloads is triggered from multiple independent coroutines (event callback and 1s loop) without serialization. Overlapping polls can finish out of order and overwrite newer download state with stale snapshots.

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/model/DownloadsViewModel.kt, line 55:

<comment>pollDownloads is triggered from multiple independent coroutines (event callback and 1s loop) without serialization. Overlapping polls can finish out of order and overwrite newer download state with stale snapshots.</comment>

<file context>
@@ -0,0 +1,405 @@
+    private val gameIconCache = ConcurrentHashMap<String, String>()
+
+    private val onDownloadStatusChanged: (AndroidEvent.DownloadStatusChanged) -> Unit = {
+        viewModelScope.launch(Dispatchers.IO) { pollDownloads() }
+    }
+
</file context>
Fix with Cubic

MarkerUtils.addMarker(installPath.absolutePath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)

// Mark as partial install in DB so Downloads screen can detect it
val currentGame = gogManager.getGameFromDbById(gameId)
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 14, 2026

Choose a reason for hiding this comment

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

P2: Gen 1 (legacy) downloads return before the new partialInstall=true update, so partial installs from Gen 1 are never tracked in the DB and can be missed by the Downloads manager.

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/service/gog/GOGDownloadManager.kt, line 383:

<comment>Gen 1 (legacy) downloads return before the new partialInstall=true update, so partial installs from Gen 1 are never tracked in the DB and can be missed by the Downloads manager.</comment>

<file context>
@@ -379,6 +379,12 @@ class GOGDownloadManager @Inject constructor(
             MarkerUtils.addMarker(installPath.absolutePath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)
 
+            // Mark as partial install in DB so Downloads screen can detect it
+            val currentGame = gogManager.getGameFromDbById(gameId)
+            if (currentGame != null) {
+                gogManager.updateGame(currentGame.copy(partialInstall = true))
</file context>
Fix with Cubic

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt (1)

565-583: ⚠️ Potential issue | 🟡 Minor

Duplicate focusRequester assignment - Downloads and Settings both use firstItemFocusRequester.

Both the Downloads menu item (Line 572) and the Settings menu item (Line 582) are assigned firstItemFocusRequester. When the menu opens, LaunchedEffect at Line 729 requests focus on this requester, but having two items share the same FocusRequester can cause undefined behavior.

Since Downloads is now the first item, only it should use firstItemFocusRequester.

Proposed fix - remove focusRequester from Settings
                         SystemMenuItem(
                             text = stringResource(R.string.settings_text),
                             icon = Icons.Default.Settings,
                             onClick = {
                                 onNavigateRoute(PluviaScreen.Settings.route)
                                 onDismiss()
                             },
-                            focusRequester = firstItemFocusRequester,
                         )
🤖 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/components/SystemMenu.kt`
around lines 565 - 583, The Settings SystemMenuItem is mistakenly sharing
firstItemFocusRequester with the Downloads item causing focus conflicts; update
the SystemMenuItem for Settings (the one calling
onNavigateRoute(PluviaScreen.Settings.route) and onDismiss) to remove the
focusRequester parameter so only the Downloads SystemMenuItem keeps
firstItemFocusRequester (which is used by the LaunchedEffect that requests
focus).
🧹 Nitpick comments (4)
app/src/main/java/app/gamenative/service/SteamService.kt (1)

533-538: Prefer snapshotting active IDs once before filtering partial downloads.

Using a single snapshot avoids mixed-state reads while downloadJobs is changing.

♻️ Suggested refinement
 suspend fun getPartialDownloads(): List<Int> {
-    return instance?.downloadingAppInfoDao?.getAll()
-        ?.map { it.appId }
-        ?.filter { appId -> !downloadJobs.containsKey(appId) }
-        ?: emptyList()
+    val activeIds = downloadJobs.keys.toSet()
+    return instance?.downloadingAppInfoDao?.getAll()
+        ?.asSequence()
+        ?.map { it.appId }
+        ?.filterNot { it in activeIds }
+        ?.toList()
+        ?: emptyList()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/SteamService.kt` around lines 533 -
538, In getPartialDownloads(), avoid mixed-state reads by snapshotting the
active download IDs once before filtering: call downloadingAppInfoDao.getAll()
to get app IDs, capture current keys from downloadJobs into a single immutable
collection (e.g., downloadJobs.keys.toSet()) and then filter the DAO results
against that snapshot; update references to downloadJobs in this method so the
check uses the captured snapshot to prevent racey/mixed-state reads while
downloadJobs mutates.
app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt (1)

55-65: Missing onDownloadsClick parameter in Friends placeholder.

The HomeLibraryScreen call in the Friends branch omits onDownloadsClick, while the Library branch includes it. If the signature requires this parameter (or has a non-empty default), this inconsistency could cause issues when the Friends feature is implemented.

Consider adding the parameter for consistency, even if the Friends screen is just a placeholder.

Proposed fix
         HomeDestination.Friends -> {
             // TODO: Friends screen
             HomeLibraryScreen(
                 onClickPlay = onClickPlay,
                 onTestGraphics = onTestGraphics,
                 onNavigateRoute = onNavigateRoute,
                 onLogout = onLogout,
                 onGoOnline = onGoOnline,
+                onDownloadsClick = { viewModel.onDestination(HomeDestination.Downloads) },
                 isOffline = isOffline,
             )
         }
🤖 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/HomeScreen.kt` around lines 55 -
65, The Friends branch rendering uses HomeLibraryScreen but omits the
onDownloadsClick parameter; add onDownloadsClick = onDownloadsClick to the
HomeLibraryScreen call inside the HomeDestination.Friends branch so its call
signature matches the Library branch (locate the HomeDestination.Friends case
and the HomeLibraryScreen invocation and pass the onDownloadsClick argument).
app/src/main/java/app/gamenative/ui/data/DownloadsState.kt (1)

25-28: Redundant type specification in emptyMap().

The explicit type parameters can be inferred from the property type declaration.

Proposed simplification
 data class DownloadsState(
-    val downloads: Map<String, DownloadItemState> = emptyMap<String, DownloadItemState>(),
+    val downloads: Map<String, DownloadItemState> = emptyMap(),
     val cancelConfirmation: CancelConfirmation? = null,
 )
🤖 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/data/DownloadsState.kt` around lines 25 -
28, The downloads property in data class DownloadsState redundantly specifies
type parameters in emptyMap<String, DownloadItemState>(); remove the explicit
type arguments so it reads val downloads: Map<String, DownloadItemState> =
emptyMap() to let the compiler infer types while leaving cancelConfirmation
(CancelConfirmation?) unchanged; update the DownloadsState declaration
accordingly.
app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt (1)

49-50: Consider adding exclude = 0 filter to getPartialDownloads().

The getNonInstalledGames() query includes AND exclude = 0 to filter out excluded games (DLC, Amazon Prime duplicates, etc.), but getPartialDownloads() does not. This inconsistency could cause excluded games to appear in the downloads list.

Proposed fix
-    `@Query`("SELECT * FROM gog_games WHERE partial_install = 1")
+    `@Query`("SELECT * FROM gog_games WHERE partial_install = 1 AND exclude = 0")
     suspend fun getPartialDownloads(): List<GOGGame>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt` around lines 49 - 50,
The getPartialDownloads() query is missing the exclude filter so excluded games
can appear in partial downloads; update the SQL for the suspend fun
getPartialDownloads() (which currently uses WHERE partial_install = 1) to
include AND exclude = 0 (i.e. WHERE partial_install = 1 AND exclude = 0) so it
matches the getNonInstalledGames() behavior and filters out excluded entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/db/dao/EpicGameDao.kt`:
- Around line 34-35: The uninstall DAO method (EpicGameDao.uninstall) is being
called from EpicManager.uninstall() without synchronizing with active downloads,
allowing a concurrent download write to re-set partial_install; modify
EpicManager.uninstall() to first serialize with in-progress downloads for the
same app: check for any active download job for that app (e.g., the download
job/map used by the manager), cancel it and await its completion/finalization or
acquire a per-app Mutex/lock before proceeding, ensuring the download's
termination handlers cannot race and re-write partial_install; only after
cancellation/await (or releasing the per-app lock) call EpicGameDao.uninstall(),
or wrap the sequence in a consistent per-app critical section so uninstall
cannot run concurrently with download write operations.

In `@app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt`:
- Around line 57-58: The code currently sets the partial-install flag via
amazonManager.markAsPartialInstall(productId) before
entitlement/credential/spec/manifest validation, so failed starts can leave
partial_install=1; move the markAsPartialInstall call out of its current
pre-validation location and instead invoke
amazonManager.markAsPartialInstall(productId) only after the manifest has been
successfully parsed and just before the file transfer is initiated (i.e., after
the checks in the block that validates entitlement/credentials/spec/manifest and
immediately prior to starting the download/transfer routine). Remove the early
call so only the successful-to-start path sets the flag, leaving existing
rollback/removal logic (the failure branches that clear the marker) unchanged.

In `@app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt`:
- Around line 86-87: The current code sets partialInstall via
epicManager.updateGame(game.copy(partialInstall = true)) before the Epic
preflight and validation, which can leave the DB flagged when preflight fails;
move the partialInstall write so it occurs immediately after Epic
preflight/manifest and chunk/file validation succeed and right before chunk
transfer begins (i.e., after the preflight/validate logic and before the
transfer loop), or alternatively ensure every early return path from the
preflight/validation clears the flag by calling
epicManager.updateGame(game.copy(partialInstall = false)); update references in
EpicDownloadManager.kt around the preflight/validation code and the transfer
start to implement this change.

In `@app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt`:
- Around line 382-386: The partialInstall flag is only set after downloadGame()
already delegates to downloadGameGen1(), so Gen1 downloads never become partial;
either set the partialInstall transition before the Gen1 branch in
downloadGame() or add the same early transition at the start of
downloadGameGen1(): fetch the game via gogManager.getGameFromDbById(gameId) and,
if non-null, call gogManager.updateGame(currentGame.copy(partialInstall = true))
before handing off to or continuing the Gen1 flow so getPartialDownloads() and
the Downloads screen see the interrupted legacy installs.

In `@app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt`:
- Around line 54-66: Multiple callers (onDownloadStatusChanged handler, the
periodic loop, and post-delete refresh) call pollDownloads() concurrently
causing race conditions; serialize these invocations by introducing a single
synchronization point (e.g., a Mutex or a dedicated actor/Channel) and wrap
every call site (the onDownloadStatusChanged lambda, the init periodic loop, and
the post-delete refresh call) to acquire the mutex/submit a request before
running pollDownloads(), ensuring only one pollDownloads() runs at a time;
reference the onDownloadStatusChanged handler, the init { ... while(true) {
pollDownloads() ... } } loop, and any post-delete refresh caller to update to
the new serialized call pattern.
- Around line 81-90: The current use of gameNameCache.getOrPut and
gameIconCache.getOrPut stores placeholder values when steamAppDao.findApp(appId)
returns null, causing permanent stale placeholders; change the logic in
DownloadsViewModel so you first call steamAppDao.findApp(appId), and only put
into gameNameCache and gameIconCache when the returned app is non-null (e.g.,
compute name/icon and cache them), otherwise return the fallback (like "Steam
App $appId" or empty string) without inserting into the cache; apply the same
change for the other provider blocks that use getOrPut with steamAppDao.findApp
to avoid caching DAO misses.
- Around line 79-122: The items map is keyed by raw appId which can collide
across providers; update all provider loops (e.g., the Steam loops using
SteamService.getActiveDownloads() and SteamService.getPartialDownloads()) to use
a source-qualified key like "${GameSource.STEAM}_$appId" (and do the same in
Epic/GOG/Amazon branches) when reading/checking/inserting into items so the
stable identifier matches what DownloadsScreen expects; ensure you use that same
qualifiedKey when calling items.containsKey(...) and when assigning
DownloadItemState (keep DownloadItemState.appId as the same qualified id if that
layer expects the stable identifier).

---

Outside diff comments:
In `@app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt`:
- Around line 565-583: The Settings SystemMenuItem is mistakenly sharing
firstItemFocusRequester with the Downloads item causing focus conflicts; update
the SystemMenuItem for Settings (the one calling
onNavigateRoute(PluviaScreen.Settings.route) and onDismiss) to remove the
focusRequester parameter so only the Downloads SystemMenuItem keeps
firstItemFocusRequester (which is used by the LaunchedEffect that requests
focus).

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt`:
- Around line 49-50: The getPartialDownloads() query is missing the exclude
filter so excluded games can appear in partial downloads; update the SQL for the
suspend fun getPartialDownloads() (which currently uses WHERE partial_install =
1) to include AND exclude = 0 (i.e. WHERE partial_install = 1 AND exclude = 0)
so it matches the getNonInstalledGames() behavior and filters out excluded
entries.

In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 533-538: In getPartialDownloads(), avoid mixed-state reads by
snapshotting the active download IDs once before filtering: call
downloadingAppInfoDao.getAll() to get app IDs, capture current keys from
downloadJobs into a single immutable collection (e.g.,
downloadJobs.keys.toSet()) and then filter the DAO results against that
snapshot; update references to downloadJobs in this method so the check uses the
captured snapshot to prevent racey/mixed-state reads while downloadJobs mutates.

In `@app/src/main/java/app/gamenative/ui/data/DownloadsState.kt`:
- Around line 25-28: The downloads property in data class DownloadsState
redundantly specifies type parameters in emptyMap<String, DownloadItemState>();
remove the explicit type arguments so it reads val downloads: Map<String,
DownloadItemState> = emptyMap() to let the compiler infer types while leaving
cancelConfirmation (CancelConfirmation?) unchanged; update the DownloadsState
declaration accordingly.

In `@app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt`:
- Around line 55-65: The Friends branch rendering uses HomeLibraryScreen but
omits the onDownloadsClick parameter; add onDownloadsClick = onDownloadsClick to
the HomeLibraryScreen call inside the HomeDestination.Friends branch so its call
signature matches the Library branch (locate the HomeDestination.Friends case
and the HomeLibraryScreen invocation and pass the onDownloadsClick argument).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 948eaa0a-2c79-40d2-bf97-929d4df797ba

📥 Commits

Reviewing files that changed from the base of the PR and between 403ba85 and d579277.

📒 Files selected for processing (26)
  • app/src/main/java/app/gamenative/data/AmazonGame.kt
  • app/src/main/java/app/gamenative/data/EpicGame.kt
  • app/src/main/java/app/gamenative/data/GOGGame.kt
  • app/src/main/java/app/gamenative/db/PluviaDatabase.kt
  • app/src/main/java/app/gamenative/db/dao/AmazonGameDao.kt
  • app/src/main/java/app/gamenative/db/dao/DownloadingAppInfoDao.kt
  • app/src/main/java/app/gamenative/db/dao/EpicGameDao.kt
  • app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt
  • app/src/main/java/app/gamenative/service/amazon/AmazonManager.kt
  • app/src/main/java/app/gamenative/service/amazon/AmazonService.kt
  • app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt
  • app/src/main/java/app/gamenative/service/epic/EpicManager.kt
  • app/src/main/java/app/gamenative/service/epic/EpicService.kt
  • app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGManager.kt
  • app/src/main/java/app/gamenative/service/gog/GOGService.kt
  • app/src/main/java/app/gamenative/ui/data/DownloadsState.kt
  • app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt
  • app/src/main/java/app/gamenative/ui/screen/HomeScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/LibraryScreen.kt
  • app/src/main/java/app/gamenative/ui/screen/library/components/LibraryTabBar.kt
  • app/src/main/java/app/gamenative/ui/screen/library/components/SystemMenu.kt
  • app/src/main/res/values/strings.xml

Comment on lines +34 to 35
@Query("UPDATE epic_games SET is_installed = 0, partial_install = 0, install_path='',install_size = 0 WHERE id = :appId")
suspend fun uninstall(appId: Int)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Uninstall state reset is not synchronized with active downloads.

EpicManager.uninstall() (app/src/main/java/app/gamenative/service/epic/EpicManager.kt, Line 715-719) calls this directly without cancel/await, so a concurrent download write can re-mark partial_install after uninstall. Please serialize uninstall with download cancellation/completion for the same app before this query runs.

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

In `@app/src/main/java/app/gamenative/db/dao/EpicGameDao.kt` around lines 34 - 35,
The uninstall DAO method (EpicGameDao.uninstall) is being called from
EpicManager.uninstall() without synchronizing with active downloads, allowing a
concurrent download write to re-set partial_install; modify
EpicManager.uninstall() to first serialize with in-progress downloads for the
same app: check for any active download job for that app (e.g., the download
job/map used by the manager), cancel it and await its completion/finalization or
acquire a per-app Mutex/lock before proceeding, ensuring the download's
termination handlers cannot race and re-write partial_install; only after
cancellation/await (or releasing the per-app lock) call EpicGameDao.uninstall(),
or wrap the sequence in a consistent per-app critical section so uninstall
cannot run concurrently with download write operations.

Comment on lines +57 to +58
// Mark as partial install in DB so Downloads screen can detect it
amazonManager.markAsPartialInstall(productId)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Delay the Amazon partial-install flag until validation passes.

This runs before the entitlement, credential, spec, and manifest checks. The early failure branches at Lines 66-106 only remove the marker, so a failed start is still persisted as partial_install = 1. Mark the row partial only once the manifest has been parsed and file transfer is about to begin.

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

In `@app/src/main/java/app/gamenative/service/amazon/AmazonDownloadManager.kt`
around lines 57 - 58, The code currently sets the partial-install flag via
amazonManager.markAsPartialInstall(productId) before
entitlement/credential/spec/manifest validation, so failed starts can leave
partial_install=1; move the markAsPartialInstall call out of its current
pre-validation location and instead invoke
amazonManager.markAsPartialInstall(productId) only after the manifest has been
successfully parsed and just before the file transfer is initiated (i.e., after
the checks in the block that validates entitlement/credentials/spec/manifest and
immediately prior to starting the download/transfer routine). Remove the early
call so only the successful-to-start path sets the flag, leaving existing
rollback/removal logic (the failure branches that clear the marker) unchanged.

Comment on lines +86 to +87
// Mark as partial install in DB so Downloads screen can detect it
epicManager.updateGame(game.copy(partialInstall = true))
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Move the partialInstall write below Epic preflight.

This flag is set before manifest fetch and chunk/file validation, but the only reset is the success update at Lines 349-355. Any early return@withContext Result.failure(...) before bytes are written leaves the row stuck as a partial download even though nothing resumable exists on disk. Set it right before chunk transfer starts, or clear it on non-resumable preflight failures.

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

In `@app/src/main/java/app/gamenative/service/epic/EpicDownloadManager.kt` around
lines 86 - 87, The current code sets partialInstall via
epicManager.updateGame(game.copy(partialInstall = true)) before the Epic
preflight and validation, which can leave the DB flagged when preflight fails;
move the partialInstall write so it occurs immediately after Epic
preflight/manifest and chunk/file validation succeed and right before chunk
transfer begins (i.e., after the preflight/validate logic and before the
transfer loop), or alternatively ensure every early return path from the
preflight/validation clears the flag by calling
epicManager.updateGame(game.copy(partialInstall = false)); update references in
EpicDownloadManager.kt around the preflight/validation code and the transfer
start to implement this change.

Comment on lines +382 to +386
// Mark as partial install in DB so Downloads screen can detect it
val currentGame = gogManager.getGameFromDbById(gameId)
if (currentGame != null) {
gogManager.updateGame(currentGame.copy(partialInstall = true))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Gen 1 GOG downloads never enter partial state.

downloadGame() hands off to downloadGameGen1() at Lines 181-193 before this block runs, so interrupted legacy installs will never show up in getPartialDownloads() or the Downloads screen. Apply the same transition before that branch, or mirror it inside downloadGameGen1().

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

In `@app/src/main/java/app/gamenative/service/gog/GOGDownloadManager.kt` around
lines 382 - 386, The partialInstall flag is only set after downloadGame()
already delegates to downloadGameGen1(), so Gen1 downloads never become partial;
either set the partialInstall transition before the Gen1 branch in
downloadGame() or add the same early transition at the start of
downloadGameGen1(): fetch the game via gogManager.getGameFromDbById(gameId) and,
if non-null, call gogManager.updateGame(currentGame.copy(partialInstall = true))
before handing off to or continuing the Gen1 flow so getPartialDownloads() and
the Downloads screen see the interrupted legacy installs.

Comment on lines +54 to +66
private val onDownloadStatusChanged: (AndroidEvent.DownloadStatusChanged) -> Unit = {
viewModelScope.launch(Dispatchers.IO) { pollDownloads() }
}

init {
PluviaApp.events.on<AndroidEvent.DownloadStatusChanged, Unit>(onDownloadStatusChanged)

viewModelScope.launch(Dispatchers.IO) {
while (true) {
pollDownloads()
delay(1000)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Serialize pollDownloads() invocations.

The event handler, the 1-second loop, and the post-delete refresh can all run pollDownloads() at the same time. Because each poll rebuilds the whole map before publishing it, an older run can finish last and briefly restore stale data.

🔧 Suggested fix
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
...
 class DownloadsViewModel `@Inject` constructor(
     `@ApplicationContext` private val appContext: Context,
     private val steamAppDao: SteamAppDao,
     private val epicGameDao: EpicGameDao,
     private val gogGameDao: GOGGameDao,
     private val amazonGameDao: AmazonGameDao,
 ) : ViewModel() {
+    private val pollMutex = Mutex()
     ...
-    private suspend fun pollDownloads() {
-        try {
+    private suspend fun pollDownloads() = pollMutex.withLock {
+        try {
             val items = mutableMapOf<String, DownloadItemState>()
             // existing body unchanged
             _state.update { it.copy(downloads = items) }
         } catch (e: Exception) {
             Timber.tag("DownloadsViewModel").e(e, "Error polling downloads")
         }
     }

Also applies to: 366-399

🤖 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/model/DownloadsViewModel.kt` around lines
54 - 66, Multiple callers (onDownloadStatusChanged handler, the periodic loop,
and post-delete refresh) call pollDownloads() concurrently causing race
conditions; serialize these invocations by introducing a single synchronization
point (e.g., a Mutex or a dedicated actor/Channel) and wrap every call site (the
onDownloadStatusChanged lambda, the init periodic loop, and the post-delete
refresh call) to acquire the mutex/submit a request before running
pollDownloads(), ensuring only one pollDownloads() runs at a time; reference the
onDownloadStatusChanged handler, the init { ... while(true) { pollDownloads()
... } } loop, and any post-delete refresh caller to update to the new serialized
call pattern.

Comment on lines +79 to +122
for ((appId, info) in SteamService.getActiveDownloads()) {
val key = "${GameSource.STEAM}_$appId"
val name = gameNameCache.getOrPut(key) {
steamAppDao.findApp(appId)?.name ?: "Steam App $appId"
}
val icon = gameIconCache.getOrPut(key) {
val app = steamAppDao.findApp(appId)
if (app != null && app.clientIconHash.isNotEmpty()) {
"https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/${app.id}/${app.clientIconHash}.ico"
} else {
""
}
}
val (downloaded, total) = info.getBytesProgress()
items[appId.toString()] = DownloadItemState(
appId = appId.toString(),
gameSource = GameSource.STEAM,
gameName = name,
iconUrl = icon,
progress = info.getProgress(),
bytesDownloaded = downloaded,
bytesTotal = total,
etaMs = info.getEstimatedTimeRemaining(),
statusMessage = info.getStatusMessageFlow().value,
isActive = info.isActive(),
isPartial = false
)
}

for (appId in SteamService.getPartialDownloads()) {
if (items.containsKey(appId.toString())) continue
val key = "${GameSource.STEAM}_$appId"
val name = gameNameCache.getOrPut(key) {
steamAppDao.findApp(appId)?.name ?: "Steam App $appId"
}
val icon = gameIconCache.getOrPut(key) {
val app = steamAppDao.findApp(appId)
if (app != null && app.clientIconHash.isNotEmpty()) {
"https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/${app.id}/${app.clientIconHash}.ico"
} else {
""
}
}
items[appId.toString()] = DownloadItemState(
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use a source-qualified key for items.

This unified map is keyed only by raw appId, so downloads from different providers with the same identifier can overwrite each other or get skipped. Steam and Epic both use numeric IDs here, so collisions are plausible. app/src/main/java/app/gamenative/ui/screen/downloads/DownloadsScreen.kt Line 100 already treats ${gameSource}_${appId} as the stable identifier; this layer should do the same consistently in every provider block.

🔧 Suggested fix
+    private fun downloadKey(source: GameSource, appId: String) = "${source}_$appId"
...
             for ((appId, info) in SteamService.getActiveDownloads()) {
-                val key = "${GameSource.STEAM}_$appId"
+                val key = downloadKey(GameSource.STEAM, appId.toString())
                 ...
-                items[appId.toString()] = DownloadItemState(
+                items[key] = DownloadItemState(
                     appId = appId.toString(),
                     gameSource = GameSource.STEAM,
                     ...
                 )
             }

             for (appId in SteamService.getPartialDownloads()) {
-                if (items.containsKey(appId.toString())) continue
-                val key = "${GameSource.STEAM}_$appId"
+                val key = downloadKey(GameSource.STEAM, appId.toString())
+                if (items.containsKey(key)) continue
                 ...
-                items[appId.toString()] = DownloadItemState(
+                items[key] = DownloadItemState(
                     appId = appId.toString(),
                     gameSource = GameSource.STEAM,
                     ...
                 )
             }

Apply the same helper to the Epic, GOG, and Amazon branches as well.

🤖 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/model/DownloadsViewModel.kt` around lines
79 - 122, The items map is keyed by raw appId which can collide across
providers; update all provider loops (e.g., the Steam loops using
SteamService.getActiveDownloads() and SteamService.getPartialDownloads()) to use
a source-qualified key like "${GameSource.STEAM}_$appId" (and do the same in
Epic/GOG/Amazon branches) when reading/checking/inserting into items so the
stable identifier matches what DownloadsScreen expects; ensure you use that same
qualifiedKey when calling items.containsKey(...) and when assigning
DownloadItemState (keep DownloadItemState.appId as the same qualified id if that
layer expects the stable identifier).

Comment on lines +81 to +90
val name = gameNameCache.getOrPut(key) {
steamAppDao.findApp(appId)?.name ?: "Steam App $appId"
}
val icon = gameIconCache.getOrPut(key) {
val app = steamAppDao.findApp(appId)
if (app != null && app.clientIconHash.isNotEmpty()) {
"https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/${app.id}/${app.clientIconHash}.ico"
} else {
""
}
Copy link
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 caching placeholder metadata on DAO misses.

getOrPut() is caching fallback names like Steam App $appId and empty icon URLs. If the DAO row is missing on the first poll, that generic title/blank art stays for the rest of this ViewModel lifetime even after metadata becomes available. The same pattern repeats in the other provider blocks below.

🤖 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/model/DownloadsViewModel.kt` around lines
81 - 90, The current use of gameNameCache.getOrPut and gameIconCache.getOrPut
stores placeholder values when steamAppDao.findApp(appId) returns null, causing
permanent stale placeholders; change the logic in DownloadsViewModel so you
first call steamAppDao.findApp(appId), and only put into gameNameCache and
gameIconCache when the returned app is non-null (e.g., compute name/icon and
cache them), otherwise return the fallback (like "Steam App $appId" or empty
string) without inserting into the cache; apply the same change for the other
provider blocks that use getOrPut with steamAppDao.findApp to avoid caching DAO
misses.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt:76">
P2: `getOrPut` permanently caches placeholder metadata when the DAO returns null. If the first poll runs before game metadata is synced, fallback names like `"Steam App $appId"` and empty icon URLs are stored for the ViewModel's lifetime, even once real metadata becomes available. Consider only caching non-fallback values, e.g., by using a plain `get`/`put` pattern that skips caching when the DAO lookup returns null.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment on lines +76 to +78
val name = gameNameCache.getOrPut(key) {
steamAppDao.findApp(appId)?.name ?: "Steam App $appId"
}
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 14, 2026

Choose a reason for hiding this comment

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

P2: getOrPut permanently caches placeholder metadata when the DAO returns null. If the first poll runs before game metadata is synced, fallback names like "Steam App $appId" and empty icon URLs are stored for the ViewModel's lifetime, even once real metadata becomes available. Consider only caching non-fallback values, e.g., by using a plain get/put pattern that skips caching when the DAO lookup returns null.

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/model/DownloadsViewModel.kt, line 76:

<comment>`getOrPut` permanently caches placeholder metadata when the DAO returns null. If the first poll runs before game metadata is synced, fallback names like `"Steam App $appId"` and empty icon URLs are stored for the ViewModel's lifetime, even once real metadata becomes available. Consider only caching non-fallback values, e.g., by using a plain `get`/`put` pattern that skips caching when the DAO lookup returns null.</comment>

<file context>
@@ -71,24 +71,67 @@ class DownloadsViewModel @Inject constructor(
 
+    private suspend fun getSteamMetadata(appId: Int): Pair<String, String> {
+        val key = "${GameSource.STEAM}_$appId"
+        val name = gameNameCache.getOrPut(key) {
+            steamAppDao.findApp(appId)?.name ?: "Steam App $appId"
+        }
</file context>
Suggested change
val name = gameNameCache.getOrPut(key) {
steamAppDao.findApp(appId)?.name ?: "Steam App $appId"
}
val name = gameNameCache[key] ?: run {
val fetched = steamAppDao.findApp(appId)?.name
if (fetched != null) gameNameCache[key] = fetched
fetched ?: "Steam App $appId"
}
Fix with Cubic

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (3)
app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt (3)

74-125: ⚠️ Potential issue | 🟡 Minor

Don’t cache fallback placeholders on DAO misses.

getOrPut currently stores fallback names/icons when DB rows are missing. If metadata appears later, the ViewModel still serves cached placeholders for its lifetime.

🔧 Suggested fix pattern
 private suspend fun getSteamMetadata(appId: Int): Pair<String, String> {
     val key = "${GameSource.STEAM}_$appId"
-    val name = gameNameCache.getOrPut(key) {
-        steamAppDao.findApp(appId)?.name ?: "Steam App $appId"
-    }
-    val icon = gameIconCache.getOrPut(key) {
-        val app = steamAppDao.findApp(appId)
-        if (app != null && app.clientIconHash.isNotEmpty()) {
-            "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/${app.id}/${app.clientIconHash}.ico"
-        } else {
-            ""
-        }
-    }
-    return Pair(name, icon)
+    val cachedName = gameNameCache[key]
+    val cachedIcon = gameIconCache[key]
+    if (cachedName != null && cachedIcon != null) return cachedName to cachedIcon
+
+    val app = steamAppDao.findApp(appId)
+    val name = app?.name ?: "Steam App $appId"
+    val icon = if (app != null && app.clientIconHash.isNotEmpty()) {
+        "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/${app.id}/${app.clientIconHash}.ico"
+    } else ""
+
+    if (app != null) {
+        gameNameCache[key] = name
+        gameIconCache[key] = icon
+    }
+    return name to icon
 }

Use the same approach for Epic/GOG/Amazon metadata helpers.

🤖 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/model/DownloadsViewModel.kt` around lines
74 - 125, The helpers getSteamMetadata, getEpicMetadata, getGOGMetadata and
getAmazonMetadata are caching fallback placeholders because getOrPut stores the
fallback when the DAO returns null; change each function to first query the DAO
into a local variable, and only write to gameNameCache and gameIconCache when
the DAO returned a non-null real value (e.g., set gameNameCache[key] = name and
gameIconCache[key] = icon), otherwise return the fallback string without
inserting it into the caches so later-arriving metadata can replace the
placeholder.

132-278: ⚠️ Potential issue | 🟠 Major

Use source-qualified keys in the unified downloads map.

The map is keyed by raw IDs only. In a cross-provider list, identical IDs from different sources can overwrite/skip each other.

🔧 Suggested fix
+private fun downloadKey(source: GameSource, appId: String) = "${source}_$appId"
...
 // Steam active
-for ((appId, info) in SteamService.getActiveDownloads()) {
+for ((appId, info) in SteamService.getActiveDownloads()) {
+    val key = downloadKey(GameSource.STEAM, appId.toString())
     ...
-    items[appId.toString()] = DownloadItemState(
+    items[key] = DownloadItemState(
         appId = appId.toString(),
         gameSource = GameSource.STEAM,
         ...
     )
 }
 // Steam partial
 for (appId in SteamService.getPartialDownloads()) {
-    if (items.containsKey(appId.toString())) continue
+    val key = downloadKey(GameSource.STEAM, appId.toString())
+    if (items.containsKey(key)) continue
     ...
-    items[appId.toString()] = DownloadItemState(...)
+    items[key] = DownloadItemState(...)
 }

Apply the same pattern to Epic, GOG, and Amazon blocks.

🤖 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/model/DownloadsViewModel.kt` around lines
132 - 278, The unified downloads map currently uses raw IDs as keys causing
collisions across providers; update each producer loop
(SteamService.getActiveDownloads(), SteamService.getPartialDownloads(),
EpicService.getActiveDownloads(), EpicService.getPartialDownloads(),
GOGService.getActiveDownloads(), GOGService.getPartialDownloads(),
AmazonService.getActiveDownloads(), AmazonService.getPartialDownloads()) to
build a source-qualified key (e.g. combine GameSource.<SOURCE>.name with the id)
and use that key when writing and checking items instead of raw
appId/gameId/productId so entries from different sources cannot overwrite each
other; keep the DownloadItemState.appId field as the original id string but use
the new source-qualified key for the items map.

54-56: ⚠️ Potential issue | 🟠 Major

Serialize pollDownloads() calls to prevent stale state writes.

pollDownloads() can run concurrently from event callbacks, the 1-second loop, and post-cancel refreshes. Because each run publishes a full snapshot (Line 280), an older run can finish last and temporarily restore stale data.

🔧 Suggested fix
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
...
 class DownloadsViewModel `@Inject` constructor(
     ...
 ) : ViewModel() {
+    private val pollMutex = Mutex()
     ...
-    private suspend fun pollDownloads() {
+    private suspend fun pollDownloads() = pollMutex.withLock {
         try {
             val items = mutableMapOf<String, DownloadItemState>()
             ...
             _state.update { it.copy(downloads = items) }
         } catch (e: Exception) {
             Timber.tag("DownloadsViewModel").e(e, "Error polling downloads")
         }
     }
 }

Also applies to: 61-66, 128-130, 280-283, 360-394

🤖 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/model/DownloadsViewModel.kt` around lines
54 - 56, Multiple callers can run pollDownloads() concurrently and overwrite
newer state with stale snapshots; serialize runs by adding a coroutine Mutex
(e.g., private val pollMutex = Mutex()) and wrapping the entire body of
pollDownloads() in pollMutex.withLock { ... } so only one invocation executes at
a time. Update pollDownloads() implementation to perform its snapshot/publish
work inside the withLock block and leave all callers (onDownloadStatusChanged
lambda, the polling loop, and post-cancel refresh callers) unchanged so they
benefit from the mutual exclusion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt`:
- Around line 74-125: The helpers getSteamMetadata, getEpicMetadata,
getGOGMetadata and getAmazonMetadata are caching fallback placeholders because
getOrPut stores the fallback when the DAO returns null; change each function to
first query the DAO into a local variable, and only write to gameNameCache and
gameIconCache when the DAO returned a non-null real value (e.g., set
gameNameCache[key] = name and gameIconCache[key] = icon), otherwise return the
fallback string without inserting it into the caches so later-arriving metadata
can replace the placeholder.
- Around line 132-278: The unified downloads map currently uses raw IDs as keys
causing collisions across providers; update each producer loop
(SteamService.getActiveDownloads(), SteamService.getPartialDownloads(),
EpicService.getActiveDownloads(), EpicService.getPartialDownloads(),
GOGService.getActiveDownloads(), GOGService.getPartialDownloads(),
AmazonService.getActiveDownloads(), AmazonService.getPartialDownloads()) to
build a source-qualified key (e.g. combine GameSource.<SOURCE>.name with the id)
and use that key when writing and checking items instead of raw
appId/gameId/productId so entries from different sources cannot overwrite each
other; keep the DownloadItemState.appId field as the original id string but use
the new source-qualified key for the items map.
- Around line 54-56: Multiple callers can run pollDownloads() concurrently and
overwrite newer state with stale snapshots; serialize runs by adding a coroutine
Mutex (e.g., private val pollMutex = Mutex()) and wrapping the entire body of
pollDownloads() in pollMutex.withLock { ... } so only one invocation executes at
a time. Update pollDownloads() implementation to perform its snapshot/publish
work inside the withLock block and leave all callers (onDownloadStatusChanged
lambda, the polling loop, and post-cancel refresh callers) unchanged so they
benefit from the mutual exclusion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a358b106-5869-42b3-b7ce-55ba4441715f

📥 Commits

Reviewing files that changed from the base of the PR and between d579277 and 3bca522.

📒 Files selected for processing (1)
  • app/src/main/java/app/gamenative/ui/model/DownloadsViewModel.kt

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant